0%

Java垃圾回收算法

如何判定一个对象是否可以被垃圾回收?

引用计数

记录每个对象被引用数量,产生新引用计数加1,引用失效计数减1

优点:实现简单
缺点:无法解决环形依赖引用

可达性分析

一个对象到GC Roots对象没有引用链,表示此对象是没有地方使用,可以被回收。

可以作为GC Roots的对象

  1. 虚拟机栈中的引用的对象,即栈帧中的局部变量表中引用的对象
  2. 本地方法栈中引用的对象,即native方法中引用的对象
  3. 方法区中类的静态属性引用的对象
  4. 方法区中常量引用的对象

优点:

  • 分析更加精确,解决了环形引用的问题

缺点:

  • 实现复杂,分析时间长
  • 分析过程需要保证引用关系不能发生变化,需要GC停顿,即让所有线程的执行都暂停(Stop the world)

参考《深入理解Java虚拟机(第2版)》3.2 对象已死吗

GC停顿是什么?

对象的可达性分析需要枚举GC Roots节点对象,必须要保证对象之间的引用关系不能一直变化,必须要停顿所有线程的执行,这就是Stop the world,也是垃圾回收的性能瓶颈所在。暂停线程的工作,需要等到安全点才能暂停。

怎么让线程在安全点暂停?

主动式中断(Voluntary Suspension),GC需要中断线程时,不直接中断线程,仅仅给每个线程设置一个标志,各线程主动轮询这个标志,发现中断标志为true就自己中断挂起。

线程不执行代码时(处于睡眠、等待或阻塞),无法主动轮询响应中断标志,虚拟机如果等待其恢复运行时间可能太长,垃圾回收时应该怎么应对?

如果一段代码中引用关系不会变化,称为这一段为安全区域,线程执行到安全区域的代码时,会标记自己已经进入了安全区域;虚拟机发起垃圾回收时,就不用管已经位于安全区域的线程了。

线程离开安全区域时,要检查系统是否已经完成了GC Roots的枚举(或者是检查整个垃圾回收是否已结束),如果还没有完成,就必须要等待,直到收到可以安全离开安全区域的信号为止。

引用

垃圾回收算法有哪些?分别有什么优缺点 ?

垃圾回收算法主要有:

  1. 标记-清除(Mark-Sweep)
  2. 复制(Copying)
  3. 标记-整理(Mark-Compact)
  4. 分代收集

标记-清除(Mark-Sweep)

先标记出内存中所有需要回收的对象,再统一回收所有被标记的对象。标记过程就是对象到GC Roots对象的可达性分析。

缺点:

  1. 记和清除两个过程都不高效。
  2. 容易产生大量不连续的空间碎片,分配大对象的时候无法找到连续的内存空间时会再次触发垃圾回收。

复制(Copying)

将内存分为大小相等的两块,每次只使用其中一块,当这一块内存用完后,将还存活的对象复制到另外一块内存上,再把已使用过的那块内存一次清理掉。

优点:

  1. 每次对整半块的内存清理,实现简单
  2. 内存分配时也没有内存碎片的问题,整体上更高效

缺点:

  1. 有一半的内存空间闲置不用浪费了
  2. 对象存活率较高时,就要进行较多的复制操作,效率降低

标记-整理(Mark-Compact)

先标记出内存中所有存活的对象,再统一向内存的一端移动,最后直接清理掉边界以外的内存

优点:

  1. 不会产生不连续的内存碎片

缺点:

  1. 标记和整理的速度较慢

分代收集(Generational Collection)

根据对象的存活周期将堆内存划分为几块,这样可以针对不同的区域的特性使用最合适最高效的垃圾回收算法,一般把堆划分为新生代和老年代,默认新生代占堆的三分之一空间,老年代占堆的三分之二空间。

新生代又被划分为一块较大的Eden区域和两个较小的Survivor区域,每次使用Eden区和其中一个Survivor区。

在新生代中每次垃圾回收时都有大量的对象死去,只有少量对象存活,采用复制算法,当开始垃圾回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间,只要付出少量存活对象的复制成本就可以完成内存回收。在HotSpot虚拟机中Eden区和Survivor区大小比值默认为8:1,新生代只有10%的内存空间被浪费。当新生代空间不够时,需要老年代空间做分配担保,大对象直接存入老年代。

老年代中对象存活率高,必须使用标记清除或标记整理来进行回收。

参考《深入理解Java虚拟机(第2版)》 3.3 垃圾收集算法

分代收集有什么好处?

对传统的、基本的GC实现来说,由于它们在GC的整个工作过程中都要“stop-the-world”,如果能想办法缩短GC一次工作的时间长度就是件重要的事情。如果说收集整个GC堆耗时太长,那不如只收集其中的一部分?
于是就有好几种不同的划分(partition)GC堆的方式来实现部分收集,而分代式GC就是这其中的一个思路。

这个思路所基于的基本假设大家都很熟悉了:weak generational hypothesis——大部分对象的生命期很短(die young),而没有die young的对象则很可能会存活很长时间(live long)。

这是对过往的很多应用行为分析之后得出的一个假设。基于这个假设,如果让新创建的对象都在young gen里创建,然后频繁收集young gen,则大部分垃圾都能在young GC中被收集掉。由于young gen的大小配置通常只占整个GC堆的较小部分,而且较高的对象死亡率(或者说较低的对象存活率)让它非常适合使用copying算法来收集,这样就不但能降低单次GC的时间长度,还可以提高GC的工作效率。

参考:

基于分代收集回收算法,堆上的内存分配策略是怎样的?

参考《深入理解Java虚拟机(第2版)》 3.6 内存分配与回收策略

对象优先在新生代Eden区分配

当Eden区没有足够的空间分配时,虚拟机发起一次Minor GC。

大对象直接进入老年代

例如很长的字符串或很长的数组。

长期存活的对象进入老年代

每个对象都有一个年龄计数器,对象在Eden区出生,经过一次Minor GC仍然存活,并能被Survivor容纳,年龄就增加1岁,年龄增加到一定程度(默认为15),就会晋升到老年代。

动态对象年龄判定

Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

因为Eden区不足时会触发Minor GC,如果Minor GC后新生代空间仍然不足,需要老年代做分配担保,将存活的对象移动到老年代,如果老年代也没空间了,就要进行Full GC了,清理整个堆(包括老年代和新生代)。

Minor GC 前检查老年代剩余空间是否大于新生代所有对象的大小总和,如果大于,说明进行Minor GC是安全的,因为最坏情况下新生代对象全部存活并且达到年龄上限,可以安全的移动到老年代;如果老年代剩余空间小于新生代所有对象大小总和,最坏情况下老年代容纳不了新生代所有对象,新的对象也没有办法分配在Eden区了,这个时候按道理就要触发Full GC了,清理整个堆(包括老年代和新生代)。

Minor GC、Full GC分别是什么?有什么区别?分别在什么时候触发?

  • Minor GC 回收新生代
  • Full GC 回收整个堆,包括新生代、老年代、元空间(Java8新增)

Eden区不足时会触发Minor GC,如果Minor GC后新生代空间仍然不足,需要老年代做分配担保,将存活的对象移动到老年代,如果老年代也没空间了,就要进行Full GC了,清理整个堆(包括老年代和新生代)。

如果一开始就能知道老年代空间不足,就不需要先Minor GC再Full GC,直接进行Full GC更省事,所以Minor GC前会判断老年代剩余空间是否大于新生代所有对象大小总和,如果大于,最坏情况下新生代对象全部存活,全部放到老年代还是可以放得下的,如果小于,最坏情况下肯定是放不下,但也有可能经过Minor GC后,剩余存活对象Survivor放不下但老年代可以放的下,这是不确定的,此时如果设置了允许分配担保失败,会检查老年代剩余空间是否大于历次新生代晋升到老年代的对象的平均总大小,如果大于说明Minor GC应该是安全,这样也不会频繁触发Full GC,如果小于就直接进行Full GC了。

参考:

JVM是如何避免Minor GC时扫描全堆的?

垃圾回收时要判断哪些对象可以被回收,要做可达性分析,跟GC Root有引用关系的对象是存活对象,分析引用关系的时候,是不知道一个类被谁引用的,但是反过来可以知道一个类引用了别的什么类。

这就带来了一个问题,年轻代的Minor GC很频繁,做可达性分析的时候,如果有老年代的对象引用了年轻代的对象,为了找出这个引用关系,你得去扫描整个老年代,一个个检查老年代的对象是否对年轻代的对象有引用,这样效率太低了,扫描了全堆。

如果能记录下老年代有哪些类引用了年轻代类,那就不用扫描整个老年代了。这就是卡表(Card Table)的作用。

参考:从实际案例聊聊Java应用的GC优化

Minor GC时做对象的可达性分析,如果有老年代对象引用新生代时,难道要扫描一遍整个老年代?

Minor GC时检查老年代中有没有引用新生代对象是通过检查卡表来完成的,老年代内存被划分为等长的卡页,每个卡页有一个编号,卡表中每一项代表某个卡页是否有对象引用新生代对象,这样Minor GC时就不用扫描整个老年代了,保证频繁进行Minor GC不会占用太多CPU时间,提高了CPU的吞吐量。

虚拟机在对老年代中的对象更新引用时,会加入写屏障,暂时中断写操作,检查老年代的对象是否引用了新生代的对象,如果是的话,便更新CardTable,标记这一块卡页为脏。

参考:


卡表(CardTable)是什么?

讲老年代的空间划分为大小为512B的若干张卡。

卡表是单字节的数组,数组中每个元素对应着一张卡,当发生老年代对象引用新生代时,虚拟机将该卡对应的卡片元素设置为适当的值。之后Minor GC时通过扫描卡表就可以很快识别出哪些卡中存在老年代指向新生代的引用。用空间换时间,避免了全堆扫描。

参考:

System.gc()是什么使用场景?

调用此方法只会建议虚拟机进行垃圾回收,并不会强制执行垃圾回收。

由于频繁垃圾回收会导致频繁的线程暂停导致性能下降,因此应当尽量让虚拟机自动管理垃圾回收,对不用的对象置为null。

所以System.gc()可以当作不存在,需要测试垃圾回收时可能需要,平时不应当主动依赖这个方法做垃圾回收。

参考:

垃圾回收的性能瓶颈在哪

性能瓶颈在于需要暂停所有的线程,影响程序正常运行。

根据程序特性合理配置各个区域大小,减少垃圾回收触发次数,可以提高性能,因为每次垃圾回收都要暂停所有线程的工作。

参考资料