Java后端高频面试问题:垃圾回收机制

1.如何判断对象是否死亡?

堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。

①引用计数法

给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤它,计数器就加1;当引⽤失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使⽤的。

(但是,引用计数法很难解决对象之间相互循环引用的问题)

②可达性分析算法

通过一些列的称为GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots之间没有任何引用链相连,则证明这个对象是不可用的。

2.判断不可达的对象一定会被回收吗?

即使在可达性分析算法判断不可达的对象,也并非是“非死不可的”。

如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被调用过,finalize()方法都不会执行,该对象将会被回收。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,然后由Finalizer线程去执行。GC将会对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么在第二次标记时将会被移除“即将回收”的集合,否则该对象将会被回收。

(比如:把自己(this关键字)赋值给某个类变量(static修饰)或者对象的成员变量)

3.GC Roots有哪些?

1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

3.在方法区中常量引用的对象,譬如字符串常量池里的引用。

4.在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

5.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

6.所有被同步锁(synchronized关键字)持有的对象。

7.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

4.垃圾收集算法

①标记-清除算法

算法分为“标记”和“清除”阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不⾜进⾏改进得到。这种垃圾收集算***带来两个明显的问题:

1. 效率问题,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量的增长而降低。

2. 空间问题(标记清除后会产⽣⼤量不连续的碎⽚,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作)

②标记-复制算法

标记-复制算法简称“复制算法”

为了解决效率问题,“复制”收集算法出现了。

它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收。

优点:1.解决了标记-清除算法面对大量可回收对象时执行效率低的问题。2.避免了内存碎片。

缺点:将可用空间缩小为原来的一半,空间利用率较低。

新生代中大部分对象“朝生夕灭”,熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代内存空间。

将新生代划分为一个Eden区和两个Survivor区,大小为8:1:1

每次分配内存时只使用Eden区和其中一个Survivor区。发生垃圾回收时,将Eden和Survior中仍然中存活的对象一次性复制到另外一块Survivor空间中,然后直接清理掉Eden和已经使用过的Survivor区。

③标记-整理算法

根据⽼年代的特点推出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。

(复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法)

优点:不会产生内存碎片。

缺点:移动存活对象并更新引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。(即Stop the world)

④分代收集算法

当前虚拟机的垃圾收集都采⽤分代收集算法,根据对象存活周期的不同将内存分为⼏块,⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择标记-复制算法,只需要付出少量存活对象的复制成本就可以完成每次垃圾收集。

⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。

5.垃圾回收器

垃圾收集器主要有:Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、CMS、G1

①Serial收集器

Serial收集器是一个新生代、单线程的收集器,采用标记-复制算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

②Serial Old收集器

Serial Old收集器是一个老年代、单线程的收集器,采用标记-整理算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

③ParNew收集器

ParNew收集器是一个新生代、多线程的收集器,采用标记-复制算法。只有Serial和ParNew收集器能与CMS配合工作。ParNew收集器是激活CMS后的默认新生代收集器。

④Parallel Scavenge收集器

Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法实现的。能够进行并行收集的多线程收集器。

Parallel Scavenge收集器也被称作:吞吐量优先收集器。

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。

CMS等收集器的关注点是尽可能地缩短垃圾收集时的用户线程的停顿时间。

吞吐量:处理器用于运行用户代码的时间与处理器总消耗时间的比值。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

-XX:GCTimeRatio:垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

吞吐量 = 运行用户代码时间 /(运行用户代码时间+运行垃圾收集时间)

Parallel Scavenge具有自适应调节策略,通过设置参数-XX:+UseAdaptiveSizePolicy。

当这个参数被激活之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。只需要把基本的内存数据设置好,如堆的大小,然后使用控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数,给虚拟机设置一个优化目标,那具体细节参数的调节就由虚拟机完成了。

自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

⑤Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集(多个GC线程),基于标记-整理算法实现。

在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器这个组合。

⑥CMS收集器

CMS收集器是老年代收集器,基于标记-清除算法实现的。

CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⽽⾮常符合在注重⽤户体验的应⽤上使⽤。

CMS(Concurrent Mark Sweep)收集器是HotSpot虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是⼀种 “标记-清除”算法实现的,它的运作过程相⽐于前⾯⼏种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:(初始标记和重新标记这两步仍然要stop the world)

stop the world:除垃圾收集器线程之外的线程都被挂起。

①初始标记: 暂停用户线程,并标记下GC Roots能直接关联到的对象,速度很快 ;

②并发标记: 同时开启GC和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以GC线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅。

从GC Root开始对堆中的对象进行可达性分析,找出存活的对象。

(书本:并发标记阶段就是进行GC Roots Tracing的过程)

③重新标记: 暂停用户线程,重新标记阶段就是为了修正并发标记期间因为⽤户程序继续运⾏⽽导致标记产⽣变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短。

④并发清除: 同时开启⽤户线程和GC线程,清理掉在标记阶段判断的已经死亡的对象。

CMS收集器的优缺点:

从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点:

①对CPU资源敏感

在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

②⽆法处理浮动垃圾

CMS在并发清理阶段用户线程还在运行, 还会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法再当次过程中处理,所以只有等到下次gc时候在清理掉,这一部分垃圾就称作“浮动垃圾” 。

③它使⽤的回收算法--“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。

空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。

为了解决这个问题,CMS提供了一个开关参数,用于在CMS顶不住要进行full gc的时候开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片没有了,但是停顿的时间变长了。

⑦G1收集器

G1收集器:新生代+老年代(将Java堆划分成多个Region),面向全堆的收集器,不需要其他新生代收集器的配合工作。

G1 (Garbage-First)是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征。

被视为JDK1.7中HotSpot虚拟机的⼀个重要进化特征。

G1收集器采用“标记-复制”和“标记-整理”。从整体上看是基于“标记-整理”,从局部看,两个region之间是“标记-复制”。

它具备⼀下特点:

①并⾏与并发:G1能充分利⽤CPU、多核环境下的硬件优势,使⽤多个CPU(CPU或者CPU核⼼)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执⾏的GC动作,G1收集器仍然可以通过并发的⽅式让java程序继续执⾏。

②分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念。

③空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

④可预测的停顿:这是G1相对于CMS的另⼀个⼤优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为M毫秒的时间⽚段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1收集器的运作⼤致分为以下⼏个步骤:

①初始标记

暂停用户线程,初始标记只是标记下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。

②并发标记

同时开启用户线程和GC线程,并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,但可与用户程序并发执行。

③最终标记

暂停用户线程,最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,这阶段需要停顿线程,但是可以并行执行。

④筛选回收

暂停用户线程,筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

[Regin:G1收集器将整个Java堆划分为多个大小相等的独立区域(Regin)]

G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region(这也就是它的名字Garbage-First的由来)。这种使⽤Region划分内存空间以及有优先级的区域回收⽅式,保证了GF收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

筛选回收:

对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉个旧的Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1收集器只有并发标记不会stop the world

                                                        G1收集器运行示意图    

6.为什么CMS采用“标记-清除”算法而不采用“标记-整理”算法?

答案:因为CMS作为第一款实现用户线程和收集线程并发执行的收集器!当时的设计理念是减少停顿时间,最好是能并发执行!但是问题来了,如要用户线程也在执行,那么就不能轻易的改变堆中对象的内存地址!不然会导致用户线程无法定位引用对象,从而无法正常运行!而标记整理算法和标记复制算法都会移动存活的对象,这就与上面的策略不符!因此CMS采用的是标记清理算法!

7.几种垃圾收集器的区别?

Serial收集器:一个新生代、单线程的收集器,采用标记-复制算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

搭配:新生代Serial+老年代Serial Old

ParNew收集器:一个新生代、多线程的收集器,采用标记-复制算法。只有Serial和ParNew收集器能与CMS配合工作。ParNew收集器是激活CMS后的默认新生代收集器。

Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法实现的。能够进行并行收集的多线程收集器。

Serial Old收集器:一个老年代、单线程的收集器,采用标记-整理算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集(多个GC线程),基于标记-整理算法实现。

CMS收集器是老年代收集器,基于标记-清除算法实现的。

G1收集器:新生代+老年代(将Java堆划分成多个Region),面向全堆的收集器,不需要其他新生代收集器的配合工作。

8.为什么新生代和老年代要采用不同的回收算法?

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就许选择“标记-复制”算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记--清除”、“标记整理”算法来进行回收。

标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

9.垃圾回收怎么解决跨代引用的问题?

记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录。

记忆集存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用。这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题。

在对新生代区域进行回收时,新生代中的对象是可能被老年代对象所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。

使用记忆集可以把老年代划分成若干小块。标识出老年代的哪一块内存会存在跨代引用。当新生代垃圾回收时,只有包含了跨代引用的小块的内存中的对象才会被加入到GC Roots进行扫描。

虽然这种方法需要在对象改变引用关系时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

卡表就是记忆集的一种具体实现

JVM中卡表的形式是个字节数组,字节数组中的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页”。

一个卡页的内存通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。

在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

在CMS和G1中都使用了卡表,在使用CMS时,只在新生代中维护了一个卡表(老年代中也有可能存在新生代对其的跨代引用,但新生代的对象大都朝生夕死,所以没有必要),而G1是每个Region都需要维护一个卡表,因此G1比CMS更浪费空间,换言之这也是为什么G1更适合堆空间较大的情况。

卡表是记忆集的实现

记忆集根据记录的精度分三类:

字长精度:每个记录精确到一个字长,该字包含跨代指针。

对象精度:每个记录精确到一个对象,该对象里面有字段含有跨代指针。

卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡表(Card Table):是以第三种卡精度的方式实现的记忆集,也是目前最常用的方式。记忆集是抽象的概念,而卡表就是记忆集的一种具体实现。希望对你有所帮助!

如果有需要资料的小伙伴,欢迎添加vx小助手:SOSOXWV 即可免费领取大厂面试资料哦!

你可能感兴趣的:(Java后端高频面试问题:垃圾回收机制)