总结: 垃圾收集算法,如何回收垃圾以及移动之后的内存空间。
算法执行效率的要求。以下举了可达性分析的例子,即我们如果要检查引用,引用是动态变化的, 那么在GC进行时必须停顿所有的Java执行线程,就就像打扫房间时家人让我们乖乖坐下别乱跑一样。那么我们是把所有位置都扫描一遍来发现引用的吗? JVM 的算法执行效率是有要求的,它是不会允许这种耗时耗力的方法的。
主流Java虚拟机使用准确式 GC, 即当执行系统停顿下来以后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得知哪些地方存放着对象引用,我们一般打扫的时候也是把我们觉得哪里比较脏的地方打扫, HotSpot 是如何记忆的呢, 它是通过使用一组称为OopMap 的数据结构来达到这个目的的。 在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用的。
除了有 OopMap , 我们还有 HotSpot ,之前说到通过 OopMap 找到引用,可是引用如果时刻发生变化,又生成了对应的 OopMap ,我们需要大量的额外空间,这样GC的空间成本太高了。
说到底还是变化的问题, 就像一个有洁癖的人一样,这里脏了这里擦擦,那里脏了那里擦擦,不如定时去擦少一些痛苦。
安全点 Safe Point 因此出现,当程序执行到安全点时暂停开始 GC。 Safe Point 也有问题,如果有些线程到遇到问题,走不到定点怎么办,我们又规定了 Safe Region ,即规定一段代码片段, 在这个时候,引用关系不会发生变化。
3.3 垃圾收集算法
由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法又各不相同,因此本节仅仅做几种算法的思想及其发展过程。
3.3.1 标记- 清除算法
最基础的收集算法是 “标记 - 清除” (Mark-Sweep )算法,算法分为‘’标记” 和 “清除” 两个阶段:
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。 之所以说它是最基础的收集算法, 是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的,它的主要不足有两个:
- 一个是效率问题, 标记和清除两个过程的效率都不高;
- 另一个是空间问题, 标记清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时, 无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.2 复制算法
为了解决效率问题, 一种称为“复制(Copying)的收集算法出现了, 它将可用内存按容量划分为大小相等的两块, 每次只能使用其中的一块。
当这一块的内存用完了,就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。
这样就可以使得每次都是对整个半区进行内存回收; 内存分配时也就不用考虑内存碎片等复杂情况, 只要移动堆顶指针,按顺序分配内存即可, 实现简单, 运行高效。 只是这种算法的代价是将内存缩小为原来的一半。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代的对象 98% 是“朝生夕死” 的, 所以并不需要按照 1 : 1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间, 每次使用 Eden 和其中一块 Survivor ,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上 最后清理掉 Eden 和 刚才用过的 Survivor 空间,
HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1, 也就是说每次新声待可用内存空间为整个新生代容量的90 %,
但是这并不能说明每次回收都只有不多于 10 %, 当 Survivor 空间不够用时,需要依赖其他内存进行分配担保(Handle Promotion)。
3.3.3 标记- 整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作, 效率就会降低。更关键的是, 如果不想浪费 50 % 的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都 100 % 存活的极端情况, 所以在老年代一般都不直接选用这种算法。
根据老年代的特点, 有人提出了另外一种“标记- 整理(Mar-Compact)” 算法,标记过程仍然与“ 标记- 清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,
3.3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用 “分代收集” (Generational Collection) 算法, 这种算法根据对象存活周期的不同将内存划分为几块,一般是把 Java堆分为新生代和 老生代, 这样可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集。 而老年代中因为对象存活率高,没有额外空间对它进行分配担保, 就必须使用“标记——清理” 或 “标记——整理” 算法来进行回收。
3.4 HotSpot 的算法实现
3.2 节 和 3.3 节从理论上介绍了对象存活判定算法和垃圾收集算法, 而在 HotSpot 虚拟机上实现这些算法时, 必须对算法的执行效率有严格的考量, 才能保证虚拟机高效运行。
3.4.1 枚举根节点
从可达性分析中 从 GC Roots 节点寻找引用链这个操作为例:
可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性) 与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆, 如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在 GC 停顿上, 因为这项工作必须在一个能确保一致性的快照中进行。
这里“一致性” 的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点, 不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。
这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为 “ Stop The World”) 的其中一个重要原因, 即使是号称(几乎)不会产生停顿的 CMS 收集器中, 枚举根节点也是必须要停顿的。
在目前主流的Java虚拟机使用的都是准确式 GC, 所以当执行系统停顿下来后, 并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得知哪些地方存放着对象引用, 在HotSpot 的实现中, 是使用一组称为 OopMap 的数据结构来达到这个目的的, 在类加载完成的时候, HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来, 在 JIT 编译过程中, 也会在特定的位置记录下栈和寄存器哪些位置是引用。
3.4.2 安全点
在 OopMap 的协助下, HotSpot 可以快速且准确的完成 GC Roots 枚举, 但是这样会不会引发一个问题:
即可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap, 那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上, HotSpot 也的确没有为每条指令都生成 OopMap ,前面已经提到, 只是在“特定的位置” 记录了这些信息, 这些位置称为“ 安全点(Safepoint)” , 即程序执行时, 并非在所有地方都停顿下来开始 CG, 只有在到达安全点时才能暂停。
Safepoint 的选定既不能太少以至于让 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征” 为标准进行选定的 —— 因为每条指令执行的时间都非常短暂, 程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行” 的最明显特征就是指令序列复用, 例如方法调用、 循环跳转、 异常跳转等,所以具备这些功能的指令才会产生 Safepoint 。
对于 Safepoint , 另一个需要考虑的问题是如何在 GC发生时让所有线程(这里不包括执行JNI 调用的线程)都“跑” 到最近的安全点上再停顿下来。
这里有两种方案可供选择:抢先式中断(Preemptive Suspension )和主动式中断(Voluntary Suspension)。
其中抢先式终端不需要线程的执行代码主动去配合, 在GC发生时,首先把所有的线程全部中断,如果 发现有线程中断的地方不在安全点上, 就恢复线程,让它“跑” 到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC 事件。
而主动式中断的思想就是当 GC 需要中断线程的时候,不直接对线程操作, 仅仅简单地设置一个标志, 各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起, 轮询标志的地方和安全点时重合的,另外再加上创建对象需要分配内存的地方,下面代码清单 3-4 中的 test 指令时 HotSpot 生成的轮询指令, 当需要暂停线程时, 虚拟机把 0x160100 的内存页设置为不可读, 线程执行到 test 指令时产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待。这样一条汇编指令便完成安全点轮询和触发线程中断。
3.4.3 安全区域
使用Safepoint 似乎已经完美地解决了如何进入GC 的问题, 但实际情况并不一定。 Safepoint 机制保证了程序执行时, 在不太长的时间内就会遇到可进入GC的 Safepoint ,但是程序“不执行” 的时候呢?
所谓的程序不执行就是没有分配CPU时间, 典型的例子就是线程处于 Sleep 状态或者 Blocked 状态, 这时候线程无法响应 JVM 的中断请求 , “走” 到安全的地方去中断挂起, JVM也显然不太可能等待线程重新被分配CPU时间。 对于这种情况, 就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中, 引用关系不会发生变化, 在这个区域中的任何地方开始 GC 都是安全的, 我们也可以把 Safe Region 看做是被扩展了的 Safepoint 。
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region ,那样,当在这段时间里 JVM 要发起 GC 时, 就不用管表示自己为 Safe Region状态的线程了 。 在线程要离开 Safe Region 时, 它要检查系统是否已经完成了根节点枚举(或者是整个 GC过程),如果完成了, 那线程就继续执行, 否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。