聊聊虚拟机的垃圾回收算法细节问题-根节点枚举、安全点、安全区、记忆集与卡表、写屏障、并发可达性分析中的三色标记法

简介: 聊聊虚拟机的垃圾回收算法细节问题-根节点枚举、安全点、安全区、记忆集与卡表、写屏障、并发可达性分析中的三色标记法


一、根节点枚举


虚拟机搜索GCRoot的流程图解:


image.png


根节点枚举就是找出适合做GCRoot的引用对象。


枚举出这些个GC Root我们需要考虑到这个分析过程所产生结果的准确性及枚举效率,也就是我们平时要讲的保证“一致性”快照和提高枚举效率。


效率慢原因:GC Roots的节点主要是在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,查找起来要做到高效并非一件容易的事情。


原因分析:现在Java应用越做越大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要以这里做为GC Roots的起源向下逐个检查引用肯定要消耗大量时间。


现在收集器在根节点枚举时,都必须暂停用户线程。因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”问题。


虽然现在查找引用链能和用户线程一起并发执行,但前提是要在能保证一致性的快照中才得以进行。


一致性:一致性的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。


一致性的保证是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。


由于目前主流Java虚拟机使用的都是准确式GC,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象引用的——OoMap(一种数据结构)


保守式GC:不能直接识别指针和非指针类型数据


准确式GC:可以直接识别出是指针还是非指针类型数据(主流Java虚拟机使用的都是准确式垃圾收集)


譬如内存中有一个32bit的整数123456,虚拟机将有能力分辨出它到底是一个指向了123456的内存地址的引用类型还是一个数值为123456的整数,准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。


OoMap是HotSpot虚拟机为了解决在查找适合做GCRoots引用对象时,需一个不漏的从方法区,栈等区域查找所带来的效率问题,它能在特定的位置(安全点,下面会写到)记录下栈里和寄存器里哪些位置是引用类型。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。


二、安全点


图解安全点:


image.png


在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但是导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么无疑将会需要大量的额外存储空间,这给垃圾收集带来的空间成本非常高昂。


所以HotSpot并没有为每条指令都生成OopMap,前面我已经说过,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。


有了安全点的设定,可以解决用户程序执行时并非是在任意代码指令流的任意位置停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。


所以,安全点的设定既不能选的太少,也不能选的太多。太少会让收集器等待时间过长,太多会增大运行时的内存负荷。


安全点的选定标准是:是否具有让程序长时间执行的特征


我们知道,每条指令执行的时间都是非常短暂的,程序不太可能因为指令流长度太长这样的原因而长时间执行。长时间执行的指令一般都是指令的复用,如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。


既然安全点已经设定好了,那么就要考虑如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。


解决上面的问题,虚拟机有两种方式:


抢先式中断(Preemptive Suspension):抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。


主动式中断(Voluntary Suspension):主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志与安全点重合时就主动中断挂起。


除了轮询标志的地方要和安全点重合,还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。


由于轮询操作在代码中会频繁出现,所以要求它必须足够高效。(虚拟机采用内存保护陷阱的方式,这个大家可以去了解一下)


现在大部分虚拟机都采用是”主动式中断”方式,因为它相对“抢先式中断”方式避免了一个中断——>启动——>又中断的一个过程(频繁地线程切换,性能差)。


三、安全区域


图解安全区域:


image.png


使用安全点的设计似乎已经是完美解决了如何停顿用户线程,让虚拟机进入垃圾回收状态的问题,但Java程序复杂多样,在一些特殊的场景下情况和我们想的并不一样。


我们知道,安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?


所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态。


当线程处于睡眠或阻塞状态时,线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。


对于,线程无法响应虚拟机中断请求处于不执行状态时,就必须引入安全区域(Safe Region)来解决。


安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。


当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。


四、记忆集与卡表


image.png


在上一篇写到分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。


事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,那么我们开始来看看这个记忆集到底是个什么东东吧!


记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,存在于收集区域(如分代收集中,记忆集就建立在新生代中)。


如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。


但是,显然这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。


其实,我们只需要在垃圾收集时判断出某一块是否存在跨代引用(非收集区域指向收集区域的引用)就可以了,并不需要了解这些跨代引用的全部细节。


所以记录那些细节来降低成本就显得非常重要了,一般有下面三种方式来记录:


字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。


对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。


卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。


目前最常用的一种记忆集实现形式就是第三种“卡精度”,它是用一种称为“卡表”(Card Table)的方式去实现记忆集。


前面说过记忆集其实是一种“抽象”的数据结构,而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。


它在虚拟机中的定义形式如下


CARD_TABLE [this address >> 9] = 0;


CARD_TABLE数组中记录的都是内存区域中一块特定大小的内存块地址,这个内存区域称为卡页(Card Page)


一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出虚拟机中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。


下面表示了卡表与卡页的内存关系图:


image.png


一个卡页的内存中通常记录的不止一个对象,但只要卡页内有一个对象的字段存在跨代引用,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代引用,把它们加入GC Roots中一并扫描。


五、写屏障


记忆集和卡表解决了如何缩减GC Roots扫描范围的问题,但现在又出了一个新问题就是:卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。


卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。


但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?


如果是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。


解释执行:所谓解释执行就是边翻译为机器码边执行。


编译执行:编译执行(即时编译)就是先将一个方法中的所有字节码全部编译成机器码之后再执行。


在HotSpot中默认采用混合模式,其先解释执行字节码,然后将其中的热点代码(多次执行,循环等)直接编译成机器码,下次就不用再编译了,让其更快速地运行。


这时候写屏障就出来了,在HotSpot虚拟机里通过写屏障(Write Barrier)技术维护卡表状态的。


写屏障,读屏障,内存屏障这几个注意区分一下


写屏障会在引用类型赋值的前后做点小动作,在赋值前会加入写前屏障(Pre-Write Barrier),在赋值后会加入写后屏障(Post-Write Barrier)。


HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。


下面是一段更新卡表状态的简化逻辑:


void oop_field_store(oop* field, oop new_value) { 
    // 引用字段赋值操作 
    *field = new_value; 
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value); 
}


我们要知道一点,每一条指令的执行都是有一定的开销的,所以在使用写屏障之后,虚拟机就会为所有赋值操作多生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。


除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。


伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。


假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。


为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:


// 卡表更新判断
if (CARD_TABLE [this address >> 9] != 0) 
    CARD_TABLE [this address >> 9] = 0;


在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。


六、并发的可达性分析


通过上篇我们知道虚拟机判断对象是否是垃圾采用的是可达性分析法,不过它在理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析, 这意味着必须全程冻结用户线程的运行。


在根节点枚举中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。


可是从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情了。


我们要知道“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。


为了解决或者降低用户线程的停顿 ,我们有必要先搞清楚一下为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?


要弄清楚这个问题,就不得不说一下三色标记法了。


三色标记是在CMS和G1中使用的垃圾追踪算法,它把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:


黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。


灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。


白色:还没扫描过的对象,标记为白色。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。


三色标记法通过三个阶段的标记来确定要清楚的对象都有哪些,我们来看一下具体的过程。


第一步 :只要是新创建的对象,默认的颜色都是标记为“白色”。


image.png


第二步:每次GC回收开始,,将从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合。


image.png


第三步:遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合。


image.png


第四步:重复第三步,直到灰色标记表中无任何对象。


image.png


image.png


image.png


以上便是三色标记法,不难看出,如果只有收集器线程在工作那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。


一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。


一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误


下面我们来看看三色并发 (用户线程和收集线程同时工作,没有STW)标记过程:


image.png


下图:用户线程开始和收集线程并发,用户线程将对象 2 指向了对象 5 并且去除掉了对象 3 和对象 5 之间的引用


image.png


下图:垃圾线程无法链路到对象 3 所以没有对对象 5 进行扫描并且对象 2 已经被扫描过所以不会回头再去根据对象 2 的指引而去扫描对象 5



image.png


下图:对象 5 有由于一直是白色而面临被回收的命运,而原本他是不会被清除掉的


image.png


可以看出有两个问题在三色标记法中是不希望被发生的


一个白色对象被黑色对象引用(白色被挂在黑色下)

灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色)

当以上两个条件同时满足时,就会出现对象丢失现象!


当然,如果上述中的白色对象 5 他还有很多下游对象的话,也会一并都清理掉。


为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?


答案就是:我们只要破坏这两个条件中的任意一个就行了。


由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。


增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。


原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。


以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。


在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。


好了,今天的内容就到这里了,我们下期见咯!


^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

目录
相关文章
|
7月前
|
存储 算法 编译器
掌握Go语言:探索Go语言递归函数的高级奥秘,优化性能、实现并发、解决算法难题(28)
掌握Go语言:探索Go语言递归函数的高级奥秘,优化性能、实现并发、解决算法难题(28)
130 0
|
5月前
|
存储 算法 安全
Java面试题:Java内存模型及相关知识点深度解析,Java虚拟机的内存结构及各部分作用,详解Java的垃圾回收机制,谈谈你对Java内存溢出(OutOfMemoryError)的理解?
Java面试题:Java内存模型及相关知识点深度解析,Java虚拟机的内存结构及各部分作用,详解Java的垃圾回收机制,谈谈你对Java内存溢出(OutOfMemoryError)的理解?
74 0
|
7月前
|
算法 Java
并发垃圾回收算法对于大规模服务器应用的优势
并发垃圾回收算法对于大规模服务器应用的优势
|
3月前
|
存储 算法 Java
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
本文介绍了 JVM 的内存区域划分、类加载过程及垃圾回收机制。内存区域包括程序计数器、堆、栈和元数据区,每个区域存储不同类型的数据。类加载过程涉及加载、验证、准备、解析和初始化五个步骤。垃圾回收机制主要在堆内存进行,通过可达性分析识别垃圾对象,并采用标记-清除、复制和标记-整理等算法进行回收。此外,还介绍了 CMS 和 G1 等垃圾回收器的特点。
120 0
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
|
2月前
|
存储 Java PHP
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
73 0
|
3月前
使用qemu来dump虚拟机的内存,然后用crash来分析
使用qemu来dump虚拟机的内存,然后用crash来分析
|
6月前
|
监控 算法 Java
Java虚拟机(JVM)使用多种垃圾回收算法来管理内存,以确保程序运行时不会因为内存不足而崩溃。
【6月更文挑战第20天】Java JVM运用多种GC算法,如标记-清除、复制、标记-压缩、分代收集、增量收集、并行收集和并发标记,以自动化内存管理,防止因内存耗尽导致的程序崩溃。这些算法各有优劣,适应不同的性能和资源需求。垃圾回收旨在避免手动内存管理,简化编程。当遇到内存泄漏,可以借助VisualVM、JConsole或MAT等工具监测内存、生成堆转储,分析引用链并定位泄漏源,从而解决问题。
55 4
|
6月前
|
算法 Java
Java垃圾回收(Garbage Collection,GC)是Java虚拟机(JVM)的一种自动内存管理机制,用于在运行时自动回收不再使用的对象所占的内存空间
【6月更文挑战第18天】Java的GC自动回收内存,包括标记清除(产生碎片)、复制(效率低)、标记整理(兼顾连续性与效率)和分代收集(区分新生代和老年代,用不同算法优化)等策略。现代JVM通常采用分代收集,以平衡性能和内存利用率。
73 3
|
5月前
|
存储 监控 算法
探索Java虚拟机:深入理解JVM内存模型和垃圾回收机制
在Java的世界中,JVM是核心所在,它不仅承载着代码的运行,还管理着内存资源。本文将带你深入了解JVM的内存模型和垃圾回收机制,通过具体数据与案例分析,揭示它们对Java应用性能的影响,并探讨如何优化JVM配置以提升效率。
|
5月前
|
算法 Java 开发者
Java面试题:Java内存探秘与多线程并发实战,Java内存模型及分区:理解Java堆、栈、方法区等内存区域的作用,垃圾收集机制:掌握常见的垃圾收集算法及其优缺点
Java面试题:Java内存探秘与多线程并发实战,Java内存模型及分区:理解Java堆、栈、方法区等内存区域的作用,垃圾收集机制:掌握常见的垃圾收集算法及其优缺点
41 0