本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。
介绍
在上一篇文章中介绍了传统垃圾回收器的执行流程,本文主要介绍上篇文章中提及的垃圾回收器中存在的问题点。
垃圾回收器会分为3篇文章介绍:
- 第一篇:垃圾判断方法;垃圾清除算法;jvm中传统垃圾回收器;各个垃圾回收器的组合;文章跳转
- 第二篇:oopMap、安全点、安全区、记忆集、三色标记法以及存在的问题;ZGc、Shenandoah介绍;
- 第三篇:VisualVM工具使用,内存分析。
上文中名词介绍
oopMap
可达性分析算法需要从Gc Roots集合中开始遍历整个对象堆,这些Gc Roots包括常量、类静态属性、栈桢中的本地变量表,但目前java应用越来越庞大,方法区一两百兆非常常见,里面的类、常量更是一堆,如果都遍历的话,必然要消耗很长时间,同时这一阶段任何垃圾回收器都是需要stop the world
的,此时就诞生了oopMap
来帮助加速查找。
由于目前的虚拟机的实现大部分都是准确时垃圾收集,既内存中的地址都清楚的知道它是对象引用还是数值,所以一旦类加载动作完成的时候(java创建对象的过程:加载、链接、初始化),jvm就会把对象内各个偏移量上是什么引用类型计算出来;在即时编译执行过程中,也会在特定的位置(安全点、安全区)记录下栈里和寄存器里面那些位置是引用。对于垃圾收集只有引用类型是需要扫描的,其他类型是不需要关心的,而在oopMap把现阶段的引用都记录下来,这样收集器在扫描时就可以直接使用这些信息了,并不需要从全部Gc Roots开始查找。但各种引用改变都会导致oopMap
发生变化,如果每一条会引起变化的指令都需要更新oopMap
的话,带来的成本就很多了,需要找一个可以一次记录一批的地方。
安全点
安全点就是上述可以停下来并且记录oopMap的地方,上篇文章也介绍了各个回收阶段也要在达到安全点后才可以执行,就是因为需要使用oopMap,而oopMap需要在安全点进行记录。根据前置的需要安全点的设定不能太少以至于让垃圾回收器等待的时间过长,也不能太频繁,不然就会像oopMap
中描述的一样,每次变化都记录oopMap会增加程序的负荷。它的选择是以是否具有让程序长时间执行的特征
为标准进行选定的,例如:方法调用、循环、异常跳转等都会产生安全点。由于同一时间点可能会有多个线程都在执行,需要让这些线程都跑到安全点,此时有两种方案:
- 抢先式中断:在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程让它跑到安全点上,在进行中断,目前几乎没有虚拟机使用这种方案;
- 主动式中断:当垃圾收集器需要中断线程的时候,不直接对线程操作,只是把中断标识位置位一下,每个线程执行过程中会主动轮训这个标识,一但发现需要中断就在距离自己最近的安全点上中断挂起。
可以在程序启动时加上-XX:+PrintSafepointStatistics
参数,会在打印安全点信息,-XX:+SafepointTimeout
和-XX:SafepointTimeoutDelay=2000
会打印更详细的信息。如果大家感兴趣,可以自己尝试研究一下。以上命令在jdk8、jdk11中都可以添加,在17、21中已经被移除了。
安全区域
通过上述机制可以解决让运行中的线程都跑到安全点,但有些线程是处理Sleep或blocked状态,这时的线程是不能响应虚拟机的中断请求的,也没办法走到安全点去。此时就引入了安全区
的概念,它是指确保在某一段代码片段之中引用关系不会发生变化,因为安全区中任意地方进行垃圾回收都是安全的。此时已经在进行sleep或blocked前,线程就会声明自己已经进入安全区,如果想从安全区中出来,线程检查虚拟机是否还在需要暂停用户线程的阶段,如果需要则现在等待垃圾回收器线程执行完成,如果不需要则直接被唤醒。
三色标记 浮动垃圾(增量更新、原始快照)
前面已经了解了初始标记
阶段找出Gc Roots直接关联的对象,根据上述描述的oopMap
可以很快完成,但后续的遍历还是需要遍历全部的对象,堆越大存储的对象就越多耗费的时间也就越长,此时就希望这个阶段可以和用户线程并发执行,但是并发执行的过程中会产生不确定的因素,例如引用发生变化,所以在第一轮标记后采用暂停用户线程的方式再次重新标记
或最终标记
,来找出在与用户线程共同执行阶段发生变化的引用。这里采用三色标记
来描述一下引用发生变化的过程,把遍历对象图的过程中遇到的对象,按照是否访问过
这个条件标记成以下三个颜色:
白色:表示对象还没有访问过,扫描结束还是白色,说明不可达;
黑色:表示该对象已经被访问过,并且这个对象的全部引用也都扫描过了,说明它时存活对象;
灰色:表示该对象已经被访问过,但这个对象的引用并未被全部扫描过。
如图:
第一张图如果所有对象的引用都没有发生变化的话,所有的垃圾都可以正确的标记出来;但由于并发标记过程中需要和用户线程并发执行,此时就可能有用户线程把对象B中的引用的C复制给对象A中的一个属性,同时把B中引用C的属性置位空,如图二所示,这样再继续往下扫描时,由于A已经完成扫描了,B也不再引用C此时就会发生如图三所示,这样对象C以及对象C后续的引用就会被当作垃圾回收掉,此时程序就会发生致命错误了。这种情况其实是有两个必要条件的:
1、已经扫描完的对象存在引用其他对象的情况; 2、未扫描完成的对象存在取消引用的情况。
对于这种情况CMS和G1采用了不同的方案,CMS采用的是增加更新破快条件1,G1采用的是原始快照破快条件2。
增量更新:当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象重新作为根再次扫描;
原始快照:当灰色对象要删除指向白色对象的引用关系时,将这个删除的引用记录下来,等到并发扫描结束之后,将删除的这些对象当作根再次重新扫描一遍。
大家可以思考一下,采用原始快照
时,可能删除的对象引用真的删除了,不会被黑色对象引用,但还要把这个被删除的对象当作根重新扫描一遍,这样就产生了浮动垃圾
,但由于在并发标记的过程中本身也会新建很多对象,这些对象也不会在本次进行垃圾回收,无非就是再多一些浮动垃圾
;对于原始快照
时从删除的对象作为根进行扫描,而增量更新
是从前侧的黑色对象
作为根进行扫描,整体上看增量更新
扫描的范围更大一些,成本也要更高。
记忆集
对于前面介绍的传统垃圾回收器来讲,在逻辑层面都区分了年轻代和老年代,如果仅仅只回收一个区域或者一个区域中的一部分那么还是需要把整个堆进行扫描因为存在了跨代引用;为了解决这个问题垃圾回收器就建立了记忆集的数据结构,用来避免跨代引用把整个堆都扫描。
记忆集的初期目标
就是为了记录非收集区指向收集区的抽象数据结构,只要能实现这个功能采用什么数据结构都可以(散列表、bitMap等);目前垃圾回收器为了防止维护成本过高,虚拟机对于记忆集的实现并没有精确到每个具体的对象或指针,而是精确到一块区域。无论是CMS中的卡表又或者是G1中的RSet都是记忆集的具体实现。
- 卡表:HotSpot虚拟机默认是把内存区域分为512个卡页,每个卡页中如果有对象存在着跨代引用指针对应的卡页标识位就会变为1,只要筛选出对应内存中变脏的对象,把他们一并加入到GC Roots中就不需要整个内存区域扫描了。
- RSet:对于G1,它可以支持更精细粒度的垃圾回收,如果仅仅只有一个简单的卡表,那么扫描范围还是太大了,所以在G1中每个Region会把自身以每512B分为一个卡页,如果是1M内存自身就会被分为2048个卡页的卡表,同时还维护了RSet,其中卡表描述的是我是否有指向,而RSet描述的是谁指向了我,它是一个散列表,key:指向我的region的起始地址;value:卡表索引号集合。因为每个Region都会维护这一套引用关系,所以在G1中需要消耗10%左右的内存来维护它,带来的好处是在垃圾收集时扫描的粒度更细,并且还可以帮助G1进行模型预测。 结构图如下:
write barrier
根据上图可以看到每次有跨代引用的时候需要记录记忆集,虚拟机在这里采用了write barrier
实现的,这是jvm实现的一小段代码,保证区域间的引用关系能够维护起来。
在介绍oopMap
的时候可以知道,对于关系引用的变化是非常频繁,如果同步记录引用关系这对程序运行效率是有损耗的;JVM在实现的过程中并不是立刻更新RSet引用关系,仅仅是标记了一下Card Table,便它存放在一个Queue中,可以根据四种颜色来描述这个队列,和三色指针一样这是抽象的概念并不是真实的实现。
- 白色:没有任何工作;
- 绿色:引用工作线程被激活,开始更新RSet;
- 黄色:全部的引用工作线程都被激活,更新RSet;
- 红色:应用线程也加入进来帮忙更新RSet。
低延迟垃圾收集器
Shenandoah
Shenandoah和G1的实现很像,可以说是G1的升级版;但是它解决了G1垃圾收集器中的很多问题。
1、低版本G1中full gc时只能单线程执行,而G1采用了Shenandoah的full gc代码进而在JDK11中提供了并行full gc;
2、G1为了维护RSet消耗了较多内存资源,Shenandoah对其进行了动态规划,采用了二维图表的方式进行代替,进而使用较少资源就可以达到目的;如下图:
3、G1在筛选回收
阶段阶段需要停止用户线程,而Shenandoah采用了转发指针
(Brooks Pointers)来解决这个问题。
上图是演示图,真实要复杂一些,列代表引用的空间下标,行代表被引用的下标,如果空间3引用了空间2,那么就会在(3,2)位置上加一个标识。
Shenandoah的执行流程如下:
- 初始标记:直接从oopMap中获取Gc Roots中直接关联的对象;
- 并发标记:与用户线程并发执行,执行时间与堆大小和存活数量以及对象图的复杂程度有关;
- 最终标记:与G1一样,处理原始快照阶段剩余的对象,并在计算价值最高的Region;会停顿一小段时间;
- 并发清理:清理一个存活对象都没有的Region;
- 并发回收:这里优化G1
筛选回收
阶段需要暂停用户线程,采用了转发指针的概念,但也带来了较大的成本。 - 初始引用更新:在并发回收结束后,需要把对中所有指向旧对象的引用修正到复制后的新地址上;这里会产生一个短暂的停顿;
- 并发引用更新:这个阶段是和用户线程一起运行的,也是更改引用变更;
- 最终引用更新:这个阶段会停顿一会,为了修改因为并发引用阶段用户线程对引用发生的更改;
- 并发清理:把收集的Region清空。
上述流程前面和G1是类似的,只有最后的回收阶段不同;Shenandoah的做法是在原有的对象布局前面统一增加一个新的对象引用,在正常情况下引用指向自己,当在垃圾回收设置引用时就会指向新的对象;但由于是和用户线程并发执行,需要保证准确性,必然存在线程竞争问题,Shenandoah采用了cas的方式来更改引用指向,但由于java内存模型的关系,需要对读写都加上内存屏障,对于增加这种大量的读写屏障也进一步影响了Shenandoah的整体吞吐量。
ZGC
ZGC的诞生借鉴了C4收集器的思想,ZGC的region刚诞生时和Shenandoah一样是不分代的,在JDK21版本开始进行分代,它只区分小型Region、中型Region、大型Region。
- 小型Region:容量固定2MB,用于存放小于256KB的对象;
- 中型Region:容量固定为32MB,用于存放大于256KB小于4MB的对象;
- 大型Region:容量不固定,会动态变化,但是必须为2MB的整数倍,有可能比中型Region小,用于存放4B以上的大对象。
ZGC的执行流程:
- 初始标记:利用oopMap直接标记,仅停顿一小段时间;
- 并发标记:和G1、Shenandoah一样,不同的是ZGC的标记是标记在指针上的,而不是对象中;同时标记也是对整个内存进行标记;
- 并发预备重新分配:根据特定的查询条件统计出本次收集过程需要清理哪些Region,将Region组成重分配集。
- 并发重分配:把重分配集中的存活对象复制到新的Region中,并为每个Region维护一个转发表,记录从旧的对象到新对象的转向关系。得益于颜色指针的支持,ZGC收集器在引用上旧能知道一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问会被内存屏障所截获,然后立刻根据转发表记录将访问转发到新的对象中,同时修改引用,这种行为ZGC称之为
自愈
,这样的好处是对于访问旧的对象只慢了一次; - 并发重映射:修正整个堆中指向重分配集中的旧对象的引用。
颜色指针
是一种直接将少量信息存储在指针上的技术,在64位操作系统中,理论可以访问的内存为2的64次幂字节,实际上一些系统架构和操作系统没有把64为完整的应用起来的,例如linux目前支持46位的物理地址空间,ZGC颜色就盯上了这46位,将它的高4位存储4个标识信息,通过标识信息就可以直接从指针上看到引用对象的状态,当然这也进一步压缩的真实的使用空间,导致ZGC只能管理4TB以内的内存。但是对于JVM这种用户进程不可以随意定义内存中某些指针的,很多操作系统并不支持,对于不支持的操作系统采用了mmap技术变相实现。
特殊垃圾回收器
Epsilon 垃圾回收器
Epsilon垃圾回收器,它的特点就是不进行垃圾回收,它适合运行时间很短在内存没有耗尽前就会执行完成的应用程序上。例如k8s中的job。
文章推荐
java内存模型文章:juejin.cn/post/740515…
动态规划:juejin.cn/post/740474…
mmap:juejin.cn/post/740403…
总结
以上就是JVM垃圾回收器概念性的内容;根据阅读概念性内容其实就可以对JVM优化有一些总结;
- serial+serial old适合小内存、单cpu的应用(pod)使用;
- ps+po适合响应时间不敏感,追求吞吐量的应用(pod)使用;
- parNew+cms适合,本身是一个追求低延迟的垃圾回收组合,但当内存大的情况下发生full gc时会进行串行收集,会导致停顿时间更长,在使用时需要关注apm中full gc的次数,根据服务产生大对象的频率进行调整多少次full gc后进行整理内容,尽可能的减少长时间停顿。
- g1吞吐量比parNew+cms稍微弱一些,但在停顿时间上要比其表现的话,尤其是在JDK11引入并行full gc后是首选的垃圾回收器,而且在其他回收都有一定的优化,表现要比JDK8强很多;并且把服务从JDK8升级到JDK11要简单一些(相比升级到更高版本),我升级时还算比较顺利。
- Shenandoah对响应时间有要求,但对吞吐量不关注的应用使用;
- ZGC对响应时间有极致要求的应用使用,但目前ZGC还在不断迭代不清楚有没有什么坑。
创作不易,觉得文章写得不错帮忙点个赞吧!如转载请标明出处~