一、什么是垃圾回收?
垃圾回收(英语:Garbage Collection,缩写为 GC),在计算机科学中是一种自动的存储器管理机制。当一个电脑上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会。垃圾回收最早起源于 LISP 语言。当前许多语言如 Smalltalk、Java、C#和 D 语言都支持垃圾回收器。--摘自 wiki
二、常用的垃圾回收算法
2.1 标记清除法( Mark-Sweep )
介绍
标记清除算法是现代垃圾回收算法的思想基础。标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象
标记清除法图示
缺点
- 执行效率不稳定。执行效率都随对象数量增长而降低
- 内存空间的碎片化问题。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.2 标记复制算法(Copying)
核心思想
- 将原有的内存空间分为两块,每次只使用其中一块
- 在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中
- 之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收
算法图示
- 可以看到每次只对一半区域进行收集,这样就不用考虑内存碎片等复杂情况了,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是这种算法的代价是将内存缩小为原来的一半,内存成本高
- 复制算法一般用于收集新生代,因为新生代大部分的对象的存活时间很短,因此新生代中存活的对象远远少于垃圾对象
- 现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代。HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。
新生代的内存布局
- 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。
- HotSpot 虚拟机默认 Eden 区与 Survivor 区的大小比例是 8:1,也即是说 Eden 区占新生代的 80%,两个 Survivor 分别占 10%。
新生代复制算法执行规则
- 每次使用复制算法进行垃圾回收时,会将 Eden 区和其中一块 Survivor 区的所有存活对象复制到另一块空闲 Survivor 区中,在复制操作中,大对象和老年对象将直接复制到老年代
- 然后将原来的 Eden 区和 Survivor 区的对象一次性清理掉
- 如果在执行复制算法时一块空闲 Survivor 区域不能够容纳原来的 Eden 区和 Survivor 区的对象,就需要依赖老年代,将多余的对象直接复制到老年代
2.3 标记整理算法(Mark-Compact)
介绍
在对象存活率较低的新生代使用复制算法效率高。那么在对象存活率高的老年代,使用复制算法效率将会变得很低。根据老年代的特点,有人提出了“标记 - 整理”算法。算法流程如下:
- 首先需要从根节点开始,对所有可达对象做一次标记
- 将所有的存活对象压缩到内存的一端
- 清理边界外所有的空间
这种方法既避免了碎片的产生 ,又不需要两块相同的内存空间,因此,其性价比较高。
算法工作图示
三、HotSpot 算法实现
3.1 根节点枚举
存在问题:
耗时:固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,但随着 Java 应用越做越庞大,方法区庞大,引用众多,若要逐个检查以这里为起源的引用肯定消耗不少时间。
保障一致性:现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。因此 GC 进行时必须停顿所有的 Java 执行线程 (STW,Stop The World)
解决方案:
在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构来解决
- 一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来
- 在 JIT 编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用
- 这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。
3.2 安全点(Safe Point)
问题:
- 在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而来,OopMap 内容变化的指令过多导致需要大量额外空间的问题
解决:
- HotSpot 没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safe Point),即程序执行时只有在到达 Safe Point 时才能更新自己的 OopMap。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。有以下两种方式:
抢先式中断(Preemptive Suspension):
- 抢先式中断不需要线程的执行代码主动去配合
- 在垃圾收集发生时,系统首先把所有用户线程全部中断
- 如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上
- 现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
主动式中断(Voluntary Suspension):
- 当垃圾收集需要中断线程的时候,不直接对线程操作
- 仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志
- 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
- 轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
3.3 安全区域(Safe Region)
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。
问题:
但是当线程没有分配 CPU 时间(如线程处于 Sleep 状态或者 Blocked 状态),这时候线程无法响应 JVM 的中断请求以继续到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域(Safe Region):
是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
- 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
- 当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;
- 否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
3.4 记忆集与卡表(Remembered Set)
问题:
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。来缩减 GC Roots 扫描范围的问题。
记忆集:
是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记录精度
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
卡表”(Card Table)
记录精度中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集。卡表最简单的形式可以只是一个字节数组。以下这行代码是 HotSpot 默认的卡表标记逻辑
CARD_TABLE[this.address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数。
卡表与卡页对应示意图:
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
3.5 写屏障(Write Barrier)
解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏?:
- 有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏
- 变脏时间点原则上应该发生在引用类型字段赋值的那一刻
卡表元素如何变脏: 即如何在对象赋值的那一刻去更新维护卡表呢?
假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;
-
但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层
面的手段,把维护卡表的动作放到每一个赋值操作之中。
写屏障(Write Barrier)
在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。注意这里的写屏障与解决并发乱序执行问题中的“内存屏障”区分开来。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。
- 在赋值前的部分的写屏障叫作写前屏障(Pre-WriteBarrier)
- 在赋值后的则叫作写后屏障(Post-Write Barrier)
问题:
- 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,所以每次只要对引用进行更新,就会产生额外的开销。
- 卡表在高并发场景下还面临着“伪共享”(False Sharing)问题
解决“伪共享”(False Sharing)问题:
- 为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏
- 在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。
3.6 并发的可达性分析
三色标记(Tri-color Marking):
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程:
初始状态: 只有 GC Roots 是黑色,注意图中的箭头,引用是有向的,对象只有被黑色对象引用才能活,否则,如果没有黑色对象引用它,它再怎么引用其他对象都是会消亡的。看以下图示:
扫描过程中,以灰色为波峰的波纹从黑向白推进,灰色对象是黑、白对象的分界线
扫描顺利完成,此时黑色对象是可存活对象,白色对象是已消亡可回收对象。
但用户线程在标记进行时并发修改了引用关系,扫描就不会如此顺利完成了。
如:在波纹推进过程中,正在扫描的灰色对象的一个引用被切断了,同时原来的引用对象又与扫描过的黑色对象建立了引用关系
又譬如,这种切断后重新被黑色对象引用的对象可能是原有引用链中的一部分。
由于黑色对象不会重新扫描,这将导致扫描结束后出现两个被黑色对象引用的对象仍是白色,这个对象就会消失,这就很危险了
并发扫描时的对象消失问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决:只需破坏这两个条件的任意一个即可
- 增量更新(Incremental Update):增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
- 原始快照(Snapshot At The Beginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
参考
- 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践第三版》