APP下载

面试 - 详解JVM 的垃圾回收机制

消息来源:baojiabao.com 作者: 发布时间:2026-05-13

报价宝综合消息面试 - 详解JVM 的垃圾回收机制

一、 技术背景先了解一波

按照套路是要先装装X,谈谈JVM垃圾回收的前世今生的。说起垃圾回收(GC),大部分人都把这项技术当做Java语言的伴生产物。

事实上,GC的历史比Java久远,早在1960年Lisp这门语言中就使用了内存动态分配和垃圾回收技术。

二、 哪些内存需要回收?

猿们都知道JVM的内存结构包括五大区域:程式计数器、虚拟机器栈、本地方法栈、堆区、方法区。

其中程式计数器、虚拟机器栈、本地方法栈3个区域随执行绪而生、随执行绪而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者执行绪结束时,内存自然就跟随着回收了。

而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的物件哪些可以被回收,哪些暂时还不能回收,这就要用到判断物件是否存活的算法!(面试官肯定没少问你吧)

2.1 引用计数算法

2.1.1 算法分析

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个物件例项都有一个引用计数。

当一个物件被建立时,就将该物件例项分配给一个变数,该变数计数设定为1。

当任何其它变数被赋值为这个物件的引用时,计数加1(a = b,则b引用的物件例项的计数器+1),但当一个物件例项的某个引用超过了生命周期或者被设定为一个新值时,物件例项的引用计数器减1。

任何引用计数器为0的物件例项可以被当作垃圾收集。当一个物件例项被垃圾收集时,它引用的任何物件例项的引用计数器减1。

2.1.2 优缺点

优点:引用计数收集器可以很快的执行,交织在程式执行中。对程式需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父物件有一个对子物件的引用,子物件反过来引用父物件。这样,他们的引用计数永远不可能为0。

2.1.3 感觉很无趣 ?来段程式码压压惊

public class ReferenceFindTest {

public static void main(String[] args) {

MyObject object1 = new MyObject();

MyObject object2 = new MyObject();

object1.object = object2;

object2.object = object1;

object1 = null;

object2 = null;

}

}

这段程式码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null

也就是说object1和object2指向的物件已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

2.2 可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程式把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点

找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的物件。

在Java语言中,可作为GC Roots的物件包括下面几种:

虚拟机器栈中引用的物件(栈帧中的本地变量表);方法区中类静态属性引用的物件;方法区中常量引用的物件;本地方法栈中JNI(Native方法)引用的物件。2.3 Java中的引用你了解多少

无论是通过引用计数算法判断物件的引用数量,还是通过可达性分析算法判断物件的引用链是否可达,判定物件是否存活都与“引用”有关。

在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。

强引用

在程式程式码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的物件。

软引用

用来描述一些还有用但并非必须的物件。

对于软引用关联著的物件,在系统将要发生内存溢位异常之前,将会把这些物件列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会丢掷内存溢位异常。

弱引用

也是用来描述非必需物件的,但是它的强度比软引用更弱一些,被弱引用关联的物件只能生存到下一次垃圾收集发生之前。

当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的物件。

虚引用

也叫幽灵引用或幻影引用(名字真会取,很魔幻的样子),是最弱的一种引用关系。

一个物件是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个物件例项。

它的作用是能在这个物件被收集器回收时收到一个系统通知。

不要被概念吓到,也别担心,还没跑题,再深入,可就不好说了。小编罗列这四个概念的目的是为了说明,无论引用计数算法还是可达性分析算法都是基于强引用而言的。

2.4 物件死亡(被回收)前的最后一次挣扎

即使在可达性分析算法中不可达的物件,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个物件死亡,至少要经历两次标记过程。

第一次标记:如果物件在进行可达性分析后发现没有与GC Roots相连线的引用链,那它将会被第一次标记;

第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此物件是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的物件将真的会被回收,如果物件在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

猿们还跟的上吧,嘿嘿。

2.5 方法区如何判断是否需要回收

方法区储存内容是否需要回收的判断可就不一样咯。方法区主要回收的内容有:废弃常量和无用的类。

对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

该类所有的例项都已经被回收,也就是Java堆中不存在该类的任何例项;载入该类的ClassLoader已经被回收;该类对应的java.lang.Class物件没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。讲了半天,主角终于要粉墨登场了。

三、常用的垃圾收集算法

3.1 标记-清除算法

标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的物件进行标记

标记完毕后,再扫描整个空间中未被标记的物件,进行回收,如下图所示。

标记-清除算法不需要进行物件的移动,只需对不存活的物件进行处理,在存活物件比较多的情况下极为高效

但由于标记-清除算法直接回收不存活的物件,因此会造成内存碎片

3.2 复制算法

复制算法的提出是为了克服控制代码的开销和解决内存碎片的问题。

它开始时把堆分成 一个物件面和多个空闲面, 程式从物件面为物件分配空间,当物件满了,基于copying算法的垃圾收集就从根集合(GC Roots)中扫描活动物件,并将每个活动物件复制到空闲面(使得活动物件所占的内存之间没有空闲洞)

这样空闲面变成了物件面,原来的物件面变成了空闲面,程式会在新的物件面中分配内存。

3.3 标记-整理算法

标记-整理算法采用标记-清除算法一样的方式进行物件的标记,但在清除时不同,在回收不存活的物件占用的空间后,会将所有的存活物件往左端空闲空间移动,并更新对应的指标。

标记-整理算法是在标记-清除算法的基础上,又进行了物件的移动,因此成本更高,但是却解决了内存碎片的问题。

具体流程见下图:

3.4 分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据物件存活的生命周期将内存划分为若干个不同的区域。

一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。

老年代的特点是每次垃圾收集时只有少量物件需要被回收,而新生代的特点是每次垃圾回收时都有大量的物件需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

3.4.1 年轻代(Young Generation)的回收算法

a) 所有新生成的物件首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的物件。

b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。

大部分物件在Eden区中生成,回收时先将eden区存活物件复制到一个survivor0区,然后清空eden区。

当这个survivor0区也存放满了时,则将eden区和survivor0区存活物件复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的

然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

c) 当survivor1区不足以存放 eden和survivor0的存活物件时,就将存活物件直接存放到老年代。

若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

3.4.2 年老代(Old Generation)的回收算法

a) 在年轻代中经历了N次垃圾回收后仍然存活的物件,就会被放到年老代中。

因此,可以认为年老代中存放的都是一些生命周期较长的物件。

b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代物件存活时间比较长,存活率标记高。

3.4.3 持久代(Permanent Generation)的回收算法

用于存放静态档案,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者呼叫一些class,例如Hibernate 等,

在这种时候需要设定一个比较大的持久代空间来存放这些执行过程中新增的类。

持久代也称方法区,具体的回收可参见上文2.5节。

猿们加油跟上,离offer不远啦!!!

四、常见的垃圾收集器

下面一张图是HotSpot虚拟机器包含的所有收集器,图是借用过来滴:

Serial收集器(复制算法)新生代单执行绪收集器,标记和清理都是单执行绪,优点是简单高效。是client级别预设的GC方式,可以通过-XX:+UseSerialGC来强制指定。

Serial Old收集器(标记-整理算法)老年代单执行绪收集器,Serial收集器的老年代版本。

ParNew收集器(停止-复制算法) 新生代收集器,可以认为是Serial收集器的多执行绪版本,在多核CPU环境下有着比Serial更好的表现。

Parallel Scavenge收集器(停止-复制算法)并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 使用者执行绪时间/(使用者执行绪时间+GC执行绪时间)。适合后台应用等对互动相应要求不高的场景。是server级别预设采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定执行绪数。

Parallel Old收集器(停止-复制算法)Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。

CMS(Concurrent Mark Sweep)收集器(标记-清理算法)高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。

五、GC什么时候触发的(面试 5 星问题)

由于物件进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种型别:Scavenge GC和Full GC。

5.1 Scavenge GC

一般情况下,当新物件生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活物件,并且把尚且存活的物件移动到Survivor区。然后整理Survivor的两个区。

这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分物件都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。

因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

5.2 Full GC

对整个堆进行整理,包括Young、Tenured和Perm。

Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。

在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。

有如下原因可能导致Full GC:

老年代(Tenured)被写满;持久代(Perm)被写满;System.gc()被显示呼叫;上一次GC之后Heap的各域分配策略动态变化;

2020-01-26 11:00:00

相关文章