垃圾回收的困境
在早期的JVM回收之中,为了避免出现应该回收的对象没有被回收,不应该被回收的对象(重新被引用)被回收了的情况,往往整个垃圾回收都需要跑在安全点下,也就是所谓的SafePiont。而想要出现所谓的SafePoint出现,那么整个程序本身不能做任何操作,那么这个时间点对于程序来说就好像时停了一样,这个时间段就被称为STW。
稍微一想就知道,STW对于程序的响应速度来说,明显是坏的,越长的时间说明我业务处理上就越慢,那怎么解决这个问题呢?JVM在较新的版本之中,主要采用的是三色标记法,一种增量式垃圾回收算法,通过三色标记法我们就可以让回收大部分的时间都跑在 并发 下,避免出现STW太长的问题,并以此作为基础实现的垃圾回收器,例如CMS / G1 / ZGC。【当然,三色标记法没办法完全不用STW实现】
三色标记法
在介绍GC回收器之前,我们得先介绍一下一个非常关键的标记算法,也就是三色标记法。
三色标记算法是一种JVM的垃圾标记算法,CMS/G1/ZGC等新生代垃圾回收器就是使用的这种算法,它可以让JVM在不发生或者尽可能短的发生STW(Stop The World)的情况下进行垃圾的标记和清除。
顾名思义,三色标记算法是将Java堆中的对象分为了三种颜色,分别是:
- 白色:白色对象代表没有被标记过的对象,在GC标记阶段刚开始的时候所有对象都是白色对象;而在GC标记阶段结束的时候,所有白色对象表示没有被引用的对象**(代表着垃圾对象,应当被回收处理)**。
- 灰色:灰色对象表示该对象已经被标记过了,但是其引用的对象还没有被完全标记,JVM需要遍历其子对象来找到可达对象和垃圾。
- 黑色:黑色对象表示该对象及其子对象全部都被标记过了,说明该对象已经完成了标记,JVM无需继续处理它。
GCRoot
其实就是指代当前程序明确指定仍然引用使用状态对象,JVM回收的时候会通过GCRoot之中的对象开始扩展然后做回收
- 虚拟机栈之中当前栈帧包含的所有对象及其引用对象
- 本地方法栈当前栈帧包含的所有对象及其引用的对象
- 方法区,当前所有类的常量对象、类对象 (包括当前项目的,也包括第三方jar包/依赖的)
- JVM内部正常运行所以来的一些变量和对象(初始化时需要使用的对象、还有基本异常对象 例如:RuntimeException、IOException)
然而实际上三色标记法,虽然已经是非常先进的增量式垃圾回收算法了,但他在仍然存在两个明显的问题:漏标 (原先被标记为不活跃的对象,突然又被之前已经标记为检查完毕的对象引用了)、多标 (将已经不活跃的对象标记为活跃)
多标
多标其实就是单纯将不活跃的对象标记为活跃罢了,简单的来说,如果我们不做处理的话,那么最终的影响就是JVM此次的GC回收没有成功会受到这部分的对象,导致内存占用高。
而实际的情况下,如果这种对象不多的话,倒也没有那么严重
漏标
相比于多标,漏标的影响显然是更加严重的,这可能会导致实际上不该回收的对象被回收,因此必须在某个时刻针对这一类的对象做重新标记,保证不会出现误回收的问题,显然的如果我们还是想要依靠并发的阶段,也就是用户线程仍然正常使用的情况下来做重新标记那必然无法避免漏标出现的可能。
因此处理漏标的重新标记阶段是必然要在STW的时间内执行的
从逻辑上进行分析,发生漏标的对象必定满足以下两个条件:
- 必须有已经被认为完全扩展完毕的对象在并发标记阶段,偷偷增加引用
- 必须是原先保持连接的某个对象断开了引用 (使得漏标的对象变为不可达对象)
从代码来看,行为类似于:
Object objectA = readObject.referenceA; // 触发读屏障
readObject.referenceA = null; // 触发写屏障 SATB
referenceObject.referenceA = objectA; // 触发写屏障 IU
// 或者
writeRefenceObject.referenceA = readObjectA.referenceA; // 触发读屏障 & 写屏障 IU
readObjectA.referenceA = null; // 触发写屏障 SATB
怎么解决漏标问题?
内存屏障解决方案:学习过voliate的话,那么自然会知道实际上我们读取对象的时候,为了能够实时获取到对象的情况,需要使用内存屏障来保证实时获取到对象的状态,而这其中JMM规定有四种常见的屏障,读读、读写、写读、写写屏障。
三色标记法可以在并发标记阶段将读取某个被引用的动作添加读屏障 (Object objectA = readObject.referenceA);针对对象新增引用对象时,添加写屏障 (referenceObject.referenceA = objectA / readObject.referenceA = null)
利用读屏障,可以记录到新增变量引用的记录,(Object objectA = readObject.referenceA / writeRefenceObject.referenceA = readObjectA.referenceA),从而直接记录到可能存在对象引用变动。
利用写屏障,就可以将新增引用的对象做标记,实现所谓的 增量更新(Incremental Update),从而记录到新增的引用的变动;也可以原引用对象的引用变动做标记,实现所谓的 原始快照(Snapshot At The Beginning,SATB),从而记录到并发期间的引用改动。
而写屏障相比于读屏障来说,消耗的系统资源更小,在不涉及其他的问题的时候,往往会采用读屏障的方式来解决。这也就是为什么ZGC在早期版本可以直接通过染色指针 + 读屏障解决,而后期为了实现分代回收使用了写屏障的原因。
相关内容来源:增量式垃圾标记算法——三色标记算法的原理是怎样的? - 知乎
GC回收器介绍
Serial垃圾回收器
- Serial:最早的垃圾回收器 单线程 【复制清除算法】
- Serial Old:Serial垃圾回收器的老年版本,是CMS的备用方案 单线程 【标记整理算法】
Parallel Scavenge垃圾回收器 (注重吞吐量)
- Parallel:新生代Parallel垃圾回收器,并发版本的Serial 多线程 【复制清除算法】
- Paralle Old:老年代Parallel Old垃圾回收器,并发版本的Serial Old 多线程 【标记整理算法】
ParNew
serial的多线程版本 常常搭配CMS一起使用 多线程【复制清除算法】
CMS (追求低STW占比)
最小停顿时间,换取效率,适合B/S系统 多线程【标记清除算法】
-
背景:Java1.8及其以前服务器常常配置回收算法为 (年轻代:ParNew,老年代:CMS)
-
实现原理:写屏障 + 增量更新 - IU [解决三色标记法漏标问题]
-
使用CMS: -XX:+UseConcMarkSweepGC +XX: +UseParNewGC (新生代:ParNew,老年代:CMS / SerialOld)
-
CMS优化问题:
由于CMS使用标记清除算法来处理老年代对象清除,这就导致了时间一长百分百会出现空间碎片化问题,无法处理对象从年轻代迁移进老年代的问题。
什么时候会触发碎片过多变为SerialOld的回收算法? GC回收日志会报错 Concurrent Mode Failure 的时候。怎么解决?设置JVM参数
-XX:CMSFullGCBeforeCompaction=N 在发生多少次FullGC之后进行标记整理算法
-XX:+UseCMSCompactAtFullCollection 碎片空间整理 (Full GC之后做一次完整的碎片整理) -
CMS垃圾清除流程:
- 初次标记:依靠在GCRoot的对象基础上,做首次标记这个过程需要在STW下使用,时间较短
- 并发标记:在GCRoot对象的基础上,向外扩展,找到所有相关的还活跃的对象。不需要再STW下标记,效率较高 (可达性分析)
- 再次标记:此次标记的目的是解决漏标、多标的问题,重新做处理,需要STW但时间很短
- 并发清除:在并发的情况下,针对上述已经不再活跃的对象空间做回收处理
G1 (首个分代回收算法)
兼容了吞吐&停顿时间的回收器,在Java9之后成为默认的选项 (CMS被弃用) 多线程 【复制清除算法】
-
背景:G1回收器其实是首个真正意义上的分代回收器,他回收的范围包括整个JVM堆。
-
内存划分:实际上G1将整个JVM堆,切分为N个大小相同的Region块,每一块大小默认是2MB,可通过
-XX:G1HeapRegionSize
修改。虽然仍然保留着年轻代和老年代的概念,但实际上年轻代和老年代的对象混合存储,一个region可能属于eden/survivor/old,如果出现大型对象可能会跨Region存储。 -
实现原理:写屏障 -- 原始快照 SATB [解决三色标记法两大问题]
-
G1回收优化:
-
新生代 - 老年代比例配置:
-XX:G1NewSizePercent
: 配置新生代的占比,默认是 1:2
-XX:G1MaxNewSizePercent
:配置幸存者区和Eden区的占比,默认是8:1:1 -
优化配置JVM配置
-XX: MaxGCPauseMillis
:配置STW最大暂停时间 (默认200ms)
-XX: G1MixedGCLiveThreasholdPercent
: mixed GC回收时,新生代存活比例超过 N 不做回收 (默认85%)-XX: G1wastedPercent
:空闲的 Region 超过多少不做回收
-
G1回收垃圾的流程:
- 初次标记: 跟CMS完全一样,利用STW的时间,对GCRoot对象做初次标记。
- 并发标记:也跟CMS一样,通过并发的手段,依靠在GCRoot对象的基础上找出所有目前仍然存活的对象 (可达性分析)
- 再次标记:还是跟CMS一样,目的是解决漏标、多标的问题,这里将这一部分也做回收处理时间很短。(实现标记的方式是三色染色垃圾回收的原始快照 STAB技术实现的)
- 筛选回收:G1不同于CMS在回收处理的时候,并没有实现并发清除,因此整个回收流程需要在STW下进行。又因为G1可以配置垃圾回收的停顿时间 -XX: MaxPauseMills,G1会将所有需要回收的垃圾对象放到一个Set之中,然后再根据回收的成本和价值来判断应当线回收谁。值得一提的是G1实际上针对单个Region或者说多个Region的回收方式是 复制清除算法。 (他的升级版本 shenandosh 实现了并发复制清除,但不常见使用)
ZGC (极低的STW时间)
基本介绍
ZGC针对G1具有较大停顿时间的GC回收器来说,最大的优化体现在了停顿时间的优化上,ZGC理论上最低停顿时间不会超过10ms,甚至在jdk15后面ZGC优化到了亚毫秒级别的停顿时间。 多线程 【复制清除算法】
内存划分
其实跟G1差不多,都是将堆内存划分为多个 region ,但是与G1不同的是,ZGC的region大小是可以变动 (吞并周围的region) 的这使得在处理大对象的时候,ZGC比G1的跨region更加灵活。
实现原理
漏标解决原理:染色指针 [记录并发标记期间的触发读屏障的被引用对象] + 读屏障 [解决三色标记法漏标问题] jdk11 (jdk21 引入分代算法之后改为写屏障了)
复制清除实现并发回收实现原理:染色指针 [记录并发回收后原对象地址和新地址的映射关系]
针对写屏障的时候往往对于系统资源损耗更加严重,*写屏障会根据实际对象内存是否发生变动来更新数据,在jdk11 之中 ZGC通过使用染色指针技术避免了*写屏障的大量使用。虽然在jdk21 之中为实现分代回收,还是使用了 * 写屏障,但是染色指针技术的使用还是较大程度上减少了 STW 的时间。
有兴趣可以参考该文章,介绍了一些简单ZGC的信息 ZGC
ZGC回收垃圾的流程
可以分为以下动作:初次标记(STW)、并发标记、再次标记(STW)、处理非强引用、重置迁移集合、验证GC状态、选择迁移集合、开始迁移(STW)、并发回收
本质上可以直接省略为:初次标记、并发标记、再次标记、准备迁移、开始迁移、并发回收
关于回收流程的源码分析,可以参考以下文章:从源码中探索新一代垃圾回收器 ZGC (虽然我也看过,但实在懒得写,特别是底层还是C++的)
**那为什么ZGC能实现并发回收呢?**不会怕影响到用户线程模型之中记录的对象指针,所指向的地址导致线程无法依靠该地址找到对象吗?这就需要介绍一下ZGC使用的染色指针技术了。
染色指针技术
其实就是在对象指针之中使用较高位的数据位存储多个标志位,来实现内存整理及用户线程的正常运行。(在64位系统之中,低42位充当寻址,47-44充当标记位,高18位目前是没有使用的)。
作用
- 协助并发标记阶段触发读屏障被读取的对象做不回收记录 (上文已经基本介绍清楚,下面介绍并发回收方面的原理)
- 在并发回收处理时,记录原地址和新地址两者的映射
实际上染色指针是通过构建映射表的方式,使得原先指向原对象位置的指针仍能继续访问对象。这就出现两个问题:1.染色指针技术做到对象迁移的映射的效果的? 2. 怎么实现不完全相同的指针(标记位不同)指向的却是同一个对象地址?
染色指针技术怎么协助对象迁移的映射
本质上其实是ZGC会维护一个 对象迁移表,一边是原对象所在地址另一边则是数据整理好了之后所在的地址。并且会在原来的指针上标记原来的指针已经被使用了,让后续进行访问的用户进程知道该指针已经停止使用。而为了保证在ZGC进行维护(也就是在迁移的期间也能够提供服务,ZGC是通过添加读屏障先将用户线程进行拦截避免的读到错误的数据和操作情况)
细节:染色指针通过M1 M0两个标记位轮流记录来判断是否当前对象是否存活 (如果存活通过good方法来进行着色处理),通过Remapped记录当前对象是否发生了转移,通过Finalizable来记录方法是否要进入回收处理之中。每次gc完毕之后,对象指针之中的标志位不会被清空处理,而是继续保留到下一次gc。而在后续gc的时候,如果有一个对象出现两次gc周期都处于活跃期,并被成功标记,在后续迁移判断处理的时候,或者说在后续读屏障的时候将会针对它的指针做修复,重新染色处理修复为MarkedN的GoodMark状态。(remap 针对指针重新进行着色和修复)
为什么要两个Marked呢?不只建立一个然后每次都直接将其清空,下次再用不就好了?
我个人认为这种实现方式其实是考虑到了大部分对象的生命周期都是比较短的这一个特点来实现的,通过两个Marked可以将针对其进行处理的方式尽量将置空操作这一行为往后拖,这样避免可能会出现的大量重复的定义操作。(没有还找到确凿的说法)
在GC的流程之中针对首次GC之前就已经存在的对象 / 为了实现内存整理需要迁移的对象,都会被标记为remapped = 1,并在后续的之中在新的或者说是其他的region之中申请空间、复制相关信息过去。并将相关的迁移信息保留到转发表之中。(需要注意的为了防止其他的线程访问对象出现指令重排等经典问题,这里是通过构建读屏障的方式来避免的) 。而因为数据的迁移会导致原来的对象指针指向的地址会出现已经迁移、甚至是数据已经清空的情况出现,ZGC提供了一个转发表,可以通过原先的对象指针在表之中找到我们的目标对象。并且需要注意的是,在读屏障生效的期间,只要有其他用户线程访问就会触发指针的自动修复的处理逻辑。还有一点需要注意的是在下一次GC之中也会进行维护。
不完全相同的指针怎么指向同一个对象地址?
其实最终的实现方式是通过构建一个类似于 kafka 实现零拷贝技术之中利用的 mmap 、scatter + getter组成的 sendfile 实现的零拷贝技术一样,通过构建CPU直接操作地址和实际内核地址之间的虚拟映射路径实现的类似的效果,从而实现只要低42位能够直接映射就能解决该问题。
引入扩展
CMS、G1、ZGC他们是怎么实现回收器回收期间的对象实际取值和自身标记的状态的一致性的?
增量式垃圾标记算法——三色标记算法的原理是怎样的? - 知乎