垃圾收集的三大假设
这是三个统计上的经验法则。
- 弱分代假说,绝大多数对象都是朝生夕灭。
- 强分代假说,熬过越多次GC过程的对象就越难以消亡
- 跨代引用假说,跨代引用只占少数。这一条实际上是前两条的推论,因为大多数对象都是同生共死的,所以一般都会位于通过区域。这也是后期实现各种分代/分区收集算法的基础——正因为少数,所以开销不至于太大。
垃圾回收算法回顾
经典算法
从缺点演进方面来梳理一下三种算法。
标记清楚算法的缺点在于内存碎片,空闲列表的维护使得整体的效率处于中等。
复制算法解决了该问题,复制算法在存活对象少的时候效率最快,复制算法的问题在于浪费了一半的空间。
标记整理算法是一种折衷的方案,它不再需要划分内存,也没有内存碎片,但是它整体下来效率是最低的。
此外,复制算法和标记整理算法,共同的缺点是由于对象的移动,都需要某种机制去维护现有的引用,这也是垃圾回收中的一个重要问题。
增量收集算法
增量收集算法意在解决STW时间过长的缺陷,它是一种并发收集的算法。例如CMS就使用了该算法。如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
分区算法
G1收集器中引入了分区的概念,对每个分区衡量回收价值,选择高价值的进行回收。这里是我一直以来的问题:分区算法和内存划分(新生代老年代等)能够共存吗?答案是肯定的。
Finalization机制与GC过程
finalization机制
Java提供了finalization的机制——一个对象被GC之前,会调用finalize()方法。它很像c++的析构方法,但由于Java是由虚拟机管理GC,使得它在功能上和析构方法完全不同。
- finalize的执行时机完全无法保证,它只和GC的时机有关
- finalize方法之后对象不一定回收,它可以复活某个对象
- 糟糕的finalize实现会极大地影响GC效率
所以GC整个过程是怎么样的呢?
对象三种状态
首先需要来了解对象可能的三种状态:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
- 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
GC过程
- 如果对象objA到GC Roots没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行finalize()方法
- 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
- 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
- finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
根节点枚举
引用分析算法的基础是GCRoots,虽然我们知道可以作为GC Roots的对象只有有限的类型。但是,事实上,Java的应用越来越大,方法区、常量池、栈帧等可能有数百兆,这个时候枚举找到GC Roots的开销就非常大。
Hotspot虚拟机通过OopMap数据结构记录字节码文件中引用的位置,相当于提前索引好,以空间换时间,当然这个是非常值得的,这种优化也非常重要。
目前而言,所有的垃圾收集器找到GC Roots都必须STW,但是由于OopMap的存在,使得这件事与堆大小无关,只和GC Roots的数量相关。
安全点和安全区
如果每条指令都生成OopMap,那会造成巨大的空间成本。另一方面,GC的时候需要暂停程序,但是程序并不是任意时刻都能停的。很自然的,Java设定了一些可以停顿的位置,这就是安全点和安全区。
安全点的选取一般是基于“是否具有让指令序列长时间执行”,这种情况,一般包括方法调用,循环跳转,异常跳转。
程序需要执行到安全点才可以放心暂停,但有时候,线程处于Sleep 状态或Blocked 状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。解决这个问题,Java又提出了安全区的概念:指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始Gc都是安全的。
如何控制中断
- 一种方式是抢先式的,直接中断所有用户线程,然后再逐一让它们运行到安全点,这显然实现是很麻烦的。
- 另一种是主动式的,设定好标志,线程运行经过时会轮询是否中断。
执行过程
这里可以体会安全点和安全区的概念,一般在垃圾收集器中STW被使用。
- 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Relgion,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
- 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;
记忆集和卡表
问题
从GC Roots进行全图遍历,可以遍历到全堆的存活对象,但问题在于,这种操作非常耗时。现代垃圾收集器一般不会进行全堆扫描,而只会进行区域扫描,例如只扫描老年代/新生代,或者分区垃圾收集器,只扫描部分区域。
在垃圾收集器中,会遇到跨代引用或跨区域引用的问题——例如,只回收新生代,但是新生代中一些对象却有来自老年代的引用。
记忆集和卡表
跨代引用对于“通过部分收集来降低整体STW时间”的垃圾收集器来说,都非常关键。试想一下,在回收新生代的时候为了知道都有什么跨代引用,把老年代枚举一遍是完全无法接受的。所以很自然地,我们会想要提前把这些跨代引用记录下来,空间换时间。这就是记忆集。
记忆集记录了跨代的映射,这里还有一个粒度选择的问题。如果是选择以对象为粒度,那么可想而知,记忆集的开销会非常大,所以划分为更大区块进行记录就理所当然,这样的区块的索引叫卡表。
写屏障
记忆集肯定需要维护,那么如何在跨代引用发生的时候进行维护呢?最简单的方式,是不去管分代,在所有赋值操作前后进行操作,这很像编程模型AOP,在底层则是通过写屏障实现的——写屏障,通过硬件,让赋值指令执行前后进行某些操作,这时候可以维护记忆集。
并发标记与重新标记的原理
问题
上面也提到,事实上,全图的可达性分析(可能是局部的)的开销非常大——它和堆的大小正相关;如果在STW中进行,当然实现非常简单直接,但是那样一来延迟就无法优化,所以我们需要想办法让这个过程可以和用户线程并发地执行。
和用户线程一同执行并发标记的问题在于,可能在并发标记的过程中,用户线程会改动已经扫描过的标记。有两种情况,一是原来存活的对象,后续引用消失,变成浮动垃圾,这种可以在下次GC解决。二是,原来不可达的对象,有了新的引用,而可能被错误回收。
增量更新和原始快照
在《深入理解Java虚拟机》中有详细分析(P87,并发的可达性分析),如果想要理解,可以参考书中描述,非常清除。
假如将对象分为黑、灰、白三种状态,分别代表对象所有引用都扫描过且确定存活、有引用未扫描过、未被扫描或不可达,出现这种情况有两个必要条件:
- 新加入的黑色对象到白色对象的引用
- 删除灰色对象到白色对象的引用
经典的解决思路——找出必要条件,破坏其中一个必要条件。
增量更新破坏第一个必要条件,它会将并发标记过程中的新增的符合条件的引用记录下来,然后再重新标记阶段进行修正。CMS收集器使用了这种方式。
原始快照破坏第二个必要条件,它会记录这种删除的引用,并再重新标记阶段进行修正。