主流虚拟机的垃圾收集器都遵循了 “分代收集” 的策略,一般至少把堆分为两个区域:新生代、老年代。新生代经常会有大量对象死去,每次回收后仅留下一小部分,而老年代正好相反。两个区域适合用不同的算法收集垃圾。
下面是一些垃圾回收的常用名词:
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC):Major GC有时候是说整堆GC。只有CMS单独收集老年代
- 混合GC(Mixed GC):收集整个新生代和部分老年代。G1收集器
- 整堆收集(Full GC):回收整个堆和方法区
一、标记清除
顾名思义,标记清除算法分为两步:1.标记要回收的对象 2.统一收集被标记的对象
应用:CMS
缺点:
- 要进行大量标记和清楚的动作,效率低
- 产生难以利用的内存碎片
二、标记复制
标记复制算法是将内存分为两个区域,每次只使用其中一块。具体步骤:1.标记要回收的对象 2.将活着的对象统一复制到另一块空间,清除原空间所有对象。适合新生代垃圾回收。
应用:Serial、ParNew等新生代收集器
缺点:
- 浪费内存。但是Serial、ParNew都将新生代分为Eden、和两个Survivor,每次使用Eden和一块Survivior,浪费的内存很少
优点
2. 不会产生内存碎片。删除原内存所有对象,把存活的对象一起迁移到新内存。一般情况下新生代的存活对象只有2%,这种情况下Serial、ParNew都可以直接把存活对象迁移到另一块Survivor中。如果存活的对象超过Survivor的话,还需要分配担保。
3. 如果存活对象比较少的话,复制效率很高。新生代存活比率很少,98%的对象熬不过第一轮收集。
三、标记整理
标记整理算法步骤:1. 标记处存活的对象 2.让存活对象都向内存空间的一端移动,然后清理掉边界以外的内存。移动对象时必须暂停用户程序(Stop The World)适用于老年代垃圾回收。
应用:Parallel Scavenge
优点:
- 与标记清除相比不会产生内存碎片
- 分配内存时效率高
缺点:
- 垃圾收集时停顿时间长。老年代存活的对象比较多,移动时还要考虑对象之间的引用,效率很低
标记清除、标记整理互有优缺点。考虑内存分配要比垃圾回收频率高的多,Parallel Scavenge用了标记整理;关注延迟的CMS用了标记清除,并且在内存碎片过多时会进行整理
HotSpot垃圾收集算法实现
一、根节点枚举
目前所有的收集器在枚举根节点时都要暂停用户线程,如果不这样的话,在枚举时对象引用关系还在不断变化,就无法保证分析结果的准确性。
GC Roots包含方法区中的常量和静态变量、栈帧中的本地变量表等,数量非常多。比如在枚举栈帧中的根节点时,如果把虚拟机栈全部扫描一遍,看看哪个变量是引用的话会非常耗时,所以引入了oopmap记录哪些是对象引用。这样在枚举根节点时可以直接扫描一遍oopmap,不需要检查所有变量
二、安全点
如果给每条指令都生成oopmap会需要大量存储空间,实际上oopmap只在GC前、在特定位置生成,这些位置叫安全点。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准
进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点
在垃圾收集之前,所有线程(不包括执行JNI调用的线程)要跑到最近的安全点停下来,这里有两种方案:
- 抢先式中断:系统中断所有用户线程,如果发现某个线程不在安全点上,就恢复该线程,直到跑到安全点上。一般不用这个算法
- 主动式中断:需要垃圾收集时设置一个标志位,而线程执行时会不断轮询这个标志位,发现标志为真时就在最近的安全点主动挂起。
三、安全区域
安全区域是安全点的拉伸拓展。如果在GC发生时有线程处于Sleep、Blocked状态,他们无法跑到安全点去中断挂起自己,因此引入了安全区域:
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点
当线程走到安全区域时,GC Roots不会发生变化,因此在安全区域内的任意指令执行时都可以收集垃圾。当用户线程执行到安全区域的代码时,会标识自己进入到了安全区域,这样在收集垃圾时,虚拟机可以直接回收这个线程的垃圾了,不需要等待线程执行到安全点。如果线程要离开安全区域,则要限检查虚拟机是否完成了根节点枚举,如果完成,线程继续往下执行,否则等待,直到收到可以离开安全区域的信号。
四、记忆集和卡表
有些对象间存在跨代引用关系,比如老年代引用新生代对象,但这种情况是很少发生的(因为对象年龄增大到一定程度后会晋升到老年代,也就不存在跨代引用了),如果因为这个在枚举根节点时就扫描整个老年代的话非常耗时,所以在新生代建立了一种数据结构:记忆集(Remembered Set),把老年代划分为几个区域,标志出哪个区域存在跨代引用,这样在枚举根节点时只需要扫描这些区域就好了。其实不只是老年代和新生代之间存在跨代引用,任何部分区域收集(Partial GC)都需要考虑这个问题,比如:G1、ZGC、Shenandoah收集器。
记忆集用来记录从非收集区指向收集区的指针的数据结构,记忆集要记录的内容(对象还是内存区域),可以从下面选择:
- 字长精度:记录精确到机器字长,该字包含跨代指针
- 对象镜度:记录精确到对象,对象包含跨代指针
- 卡精度:记录精确到一块内存区域,该区域包含跨代指针
第三种卡精度是目前最常用的方式,一般用卡表来实现记忆集。卡表每一项都对应内存区域一块内存,只要该块内存有对象含有跨代引用指针,这一项就标记为脏(Dirty),扫描GC Roots时,把卡表中脏项对应的内存一并扫描就好了
五、写屏障
HotSpot通过写屏障来维护卡表,下面是写后屏障更新卡表状态的代码逻辑:
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
写屏障可以看作虚拟机层面对“引用类型字段”赋值这个动作的AOP切面,这样每次对引用字段赋值都增加了更新卡表的操作,但与每次Minor GC都要扫描整个老年代相比,这个开销很小。
此外,写屏障还存在伪共享问题,简单来说就是卡表中的不同项可能在同一个缓存行中,这会导致读写效率降低。但在这里用字节填充的方法解决不合适,hotspot实现了卡表更新判断:先检查卡表标记,只有这一项没被标记时才把他标记为脏。用这个参数开启卡表更新判断:-XX: +UseCondCardMark
六、并发的可达性分析
垃圾回收的第一步就是标记出哪些对象是可达或者不可达,我们引入三色标记(Tri-color Marking)来推到遍历对象图时会遇到的问题:
三色标记
- 白色:未被收集器访问。可达性分析刚开始时所有对象都是白色,分析结束后,仍是白色的对象为不可达对象
- 黑色:已被收集器访问,并且对象所有的引用也被访问。黑色表示存活下来的对象
- 灰色:已被收集器访问,但还有引用没被访问。可达性分析结束后灰色对象数量为0
在可达性分析执行过程中,如果用户线程被冻结,只有收集器线程执行,那是没有问题的,但如果用户线程也在执行,会发生两种问题:1.把原本死亡的对象标记为存活 2.把存活的对象标记为死亡
- 死亡的对象被标记为存活:
扫描完B、C两点后,由于B没有引用对象所以B被标记为黑色,C引用了D,所以C被标记为灰色,然后扫描D。之后A到B的引用被删除,但B仍为黑色,不会被回收。
- 存活的对象被标记为死亡(对象消失):
在扫描C时,增加了A->B的引用,同时C->B引用被删除。这样虽然B是存活对象,但却被标记为白色了
第一个问题影响不大,B只是碰巧逃过了本次收集,等下次再收集也可以。但第二个问题会导致程序错误,当且仅当满足下面两个条件时才会导致存活对象被标记为白色:
- 赋值器插入了从黑色到白色的引用
- 赋值器删除了全部从灰色对象到该白色对象的引用(包含直接、间接引用)
有两种方法解决对象消失:
- 增量更新。破坏第一个条件,CMS用了增量更新。当插入了从黑色指向白色的引用时,把这个黑色对象记录下来,等并发扫描结束后,再以这些黑色对象为根重新扫描一次
- 原始快照。破坏第二个条件,G1用了原始快照。如果要删除从灰色指向白色的引用,就把这个引用记录下来,在扫描完成后,以这些灰色节点为根重新扫描。相当于按照最开始的对象图快照来搜索。这样就算是删除了引用,一些实际上不可达的对象最后也会被标记为黑色,产生浮动垃圾,等到下一次GC再收集就好了。