本文核心主要是讲述:JVM 中的几种垃圾回收算法理论,以及多种垃圾收集器,并且详细参数 CMS 垃圾收集器的实现、优缺点等,最后也会解释一下三色标记法与读写屏障。
垃圾收集算法.png
标记复制算法.png
标记清除算法.png
标记整理算法.png
垃圾收集器.png
如果说垃圾收集算法是内存回收的方法理论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各收集器进行比较,但并非为了挑选出一个最好的收集器,因为直到现在为止还没有最好的垃圾收集器出现, 更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的收集器,试想一下:如果有一个完美无暇的垃圾收集器适用于所有场景,那么我们 Java 虚拟机就不会去实现那么多的垃圾收集器了。
查询当前使用的 JVM 信息查询命令 java -XX:+PrintCommandLineFlags -version
➜ ~ java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_281"
Java(TM) SE Runtime Environment (build 1.8.0_281-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.281-b09, mixed mode)
-XX:+UseSerialGC -XX:+UseSerialOldGC
Serial收集器.png
-XX:UseParNewGC
ParNew收集器.png
Parallel Old收集器.png
CMS 是一种以最短停顿时间为目标的收集器,使用CMS并不能达到GC效率最高(总体GC时间最小),但它能尽可能降低GC时服务的停顿时间, CMS收集器使用的是标记-清除算法
CMS 收集器.png
CMS 收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
1)初始标记(CMS initial mark) 暂停所有的其他线程(STW)。记录下 GC ROOT 直接引用对象,速度很快。
2)并发标记(CMS concurrent mark) 并发标记阶段就是从 GC ROOT 行的直接关联对象开始遍历整个对象的过程,这个过程耗时比较长但是不需要停顿用户线程,可以与垃圾收集器一起并发运行。因此用户程序继续运行,可能会导致已经标记过的对象状态发生变化。
3)重新标记(CMS remark) 重新标记阶段就是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动,的那一部分对象的标记记录。这个阶段的停顿时间一般比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要是用到三色标记里的增量更新算法
4)**并发清除(CMS concurrent sweep)**开启用户线程,同时 GC 线程开始对未标记的区域做清扫,这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
从它的名字可以看出他是一款优秀的垃圾收集器,主要优点:并发收集、低停顿 。但是它有以下几个明显的缺点:
参数 | 说明 |
---|---|
-XX:+UseConcMarkSweepGC | 启用 CMS |
-XX:ConcGCThreads: | 并发的 GC 线程数 |
-XX:+UseCMSCompactAtFullCollection | Full GC 之后做压缩整理(减少碎片) |
-XX:CMSFullGCsBeforeCompaction | 多少次 Full GC 之后压缩一次,默认是 0, 代表每次 Full GC 都会压缩一次。 |
-XX:CMSInitiatingOccupancyFraction | 当老年代使用达到该比例时会触发 Full GC (默认是92, 这个是百分比)。 |
-XX:+UseCMSInitiatingOccupancyOnly | 只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction 设定的值),如果不指定, JVM 仅在第一次使用设定值,后续则自动调整。 |
-XX:+CMSScavengeBeforeRemark | 在 CMS GC 启动之前启动一次 minor gc, 目前在于减少老年代的引用, 降低 CMS GC 的标记阶段时的开销,一般在 CMS 的耗时 80% 都在标记阶段。 |
-XX:+CMSParalleIlnitialMarkEnabled | 表示在出初始标记的时候多线程执行,缩短 STW。 |
-XX:+CMSParallelRemarkEnabled | 在重新标记的时候多线程执行,缩短 STW。 |
提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有效的方法,利用它可以推演回收器的正确性。
因为在并发标记期间应用线程还在继续跑,对象间的引用可能发生变化,**多标 **和 漏标 的情况还可能发生。
三色标记法.gif
我们将对象分为三种类型:
三色标记过程
三色标记算法的对象丢失
Root(黑)-> A(黑)-> C(白) Root(黑)-> B(黑)
我的理解:STAB 相对增量更新效率会很高(当然 STAB 可能造成更多的浮动垃圾),因为不需要重新标记再次深度扫描被删除引用对象,而 CMS 对增量引用的根对象会做深度扫描, G1 因为很多对象都是位于不同的 region ,CMS 是一块老年代区域,重新深度扫描对象的话 G1 的代价会比 CMS 高, 所以 G1 选择 STAB 不深度扫描对象,只是简单标记, 等到下一轮 GC 再深度扫描。
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}
当对象E的成员变量的引用发生变化时(objE.fieldG = null;
),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定=后,当时的对象图就已经确定了。比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。
一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:
void pre_write_barrier(oop* field) {
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
}
当对象D的成员变量的引用发生变化时(objD.fieldG = G;
),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field, oop old_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
}
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
漏标会导致被引用的对象被当成垃圾误删除,这个是严重的 BUG ,有两种处理方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, STAB)
增量更新 就是当黑色对象插入新的指向白色对象的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这个可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照 就当灰色对象要删除指向白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮 GC 清理中能够存活下来,等待下一轮 GC 的时候重新扫描, 这个对象也可能就是浮动垃圾)
以上无论是引用关系记录的插入还是删除,虚拟机的记录操作都是通过 写屏障 实现的。
在新生代做 GC Roots 可达性扫描过程中可能会碰到跨代引用的对象 ,这种如果又去对老年代再去扫描效率太低了。为此,在新生代可以引入记录集 (Remember Set) 的数据结枃 (记录从非收集区到收集区的指针集合) , 避免把整个老年代加入 GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集( Partial GC)行为的垃圾收集器,典型的如G1、ZC和 Shenandoah 收集器,都会面临相同的问题。
垃圾收集场景中, 收集器只需通过记忆集判断岀某一块非收集区域是否存在指向收集区域的指针即可, 无需了解跨代引用指针的全部细节hotspot使用一种叫做 "卡表"( Cardtable )的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系,可以类比为Java语言中 Hashmap与Map的关系卡表是使用一个字节数组实现: CARD TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。hotspot使用的卡页是2^9大小,即512字节
卡表与卡页.png
一个卡页中可以包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成 1 ,表示该元素变脏, 否则为 0 , GC 时, 只要筛选本收集区的卡表中变脏的元素加入 GCRoots 里。
卡表变脏上面已经说到了, 但是需要注意的是如何让卡表变脏, 即发生了引用字段赋值时,如何更新卡表对标识为 Hotspot 使用 写屏障 维护卡表状态