当初 Java 应 用越做越宏大,只办法区的大小就常有数百上千兆,外面的类、常量等更是恒河沙数。因而,Java 虚拟机实现这些算法时,必须对算法的执行效率有严格的考量,能力保障虚拟机高效运行。
明天咱们一起来探讨下 HotSpot 虚拟机如何发动内存回收、如何减速内存回收,以及如何保障回收正确性等问题?
如何发动内存回收?
以后支流的 JVM 都是采纳可达性剖析算法通过根节点枚举来找到曾经死去的对象。
固定可作为 GC Roots 的节点次要在全局性的援用(例如常量或类动态属性)与执行上下文(例如栈帧中的本地变量表)中,只管指标明确,但查找过程要做到高效并非一件容易的事件,外面的类、常量等恒河沙数,若要一一查看以这里为起源的援用必定得耗费不少工夫。
目前,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因而毫无疑问根节点枚举与之前提及的整顿内存碎片一样会面临类似的“Stop The World”的困扰。
尽管,当初可达性剖析算法耗时最长的查找援用链的过程曾经能够做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行。
这是导致垃圾收集过程必须进展所有用户线程的其中一个重要起因,即便是号称进展工夫可控,或者(简直)不会产生进展的 CMS、G1、ZGC 等收集器,枚举根节点时也是必须要进展的。
对于一致性的阐明
整个枚举期间执行子系统看起来就像被解冻在某个工夫点上,不会呈现剖析过程中,根节点汇合的对象援用关系还在一直变动的状况,若这点不能满足的话,剖析后果准确性也就无奈保障。
如何减速内存回收?
次要解决为优化 GC Roots 的查找和并行可达性剖析。
优化 GC Roots 的查找
因为目前支流 Java 虚拟机应用的都是精确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地查看完所有执行上下文和全局的援用地位,虚拟机该当是有方法间接失去哪些地方寄存着对象援用的。
解决方案:程序执行时采纳平安点
在 HotSpot 的解决方案里,是应用一组称为 OopMap 的数据结构来达到这个目标。一旦类加载动作实现的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的地位记录下栈里和寄存器里哪些地位是援用。
这样收集器在扫描时就能够间接得悉这些信息了,并不需要真正一个不漏地从办法区等 GC Roots 开始查找。
在 OopMap 的帮助下,HotSpot 能够疾速精确地实现 GC Roots 枚举,但一个很事实的问题随之而来:可能导致援用关系变动,或者说导致 OopMap 内容变动的指令十分多,如果为每一条指令都生成对应的 OopMap,那将会须要大量的额定存储空间。
实际上 HotSpot 也确实没有为每条指令都生成 OopMap,只是在“特定的地位”记录了这些信息,这些地位被称为平安点(Safepoint)。有了平安点的设定,也就决定了用户程序执行时并非在代码指令流的任意地位都可能停顿下来开始垃圾收集,而是强制要求必须执行达到平安点后才可能暂停。
因而,平安点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
平安点的选取
平安点地位的选取基本上是以“是否具备让程序长时间执行的特色”为规范进行选定的,因为每条指令执行的工夫都十分短暂,程序不太可能因为指令流长度太长这样的起因而长时间执行,“长时间执行”的最显著特色就是指令序列的复用,例如办法调用、循环跳转、异样跳转等都属于指令序列复用,所以只有具备这些性能的指令才会产生平安点。
对于平安点,另外一个须要思考的问题是,如何在垃圾收集产生时让所有线程(这里其实不包含执行 JNI 调用的线程)都跑到最近的平安点,而后停顿下来。
这里有两种计划可供选择:领先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
领先式中断不须要线程的执行代码被动去配合,在垃圾收集产生时,零碎首先把所有用户线程全副中断,如果发现有用户线程中断的中央不在平安点上,就复原这条线程执行,让它一会再从新中断,直到跑到平安点上。当初简直没有虚拟机实现采纳领先式中断来暂停线程响应 GC 事件。
主动式中断的思维是当垃圾收集须要中断线程的时候,不间接对线程操作,仅仅简略地设置一个标记位,各个线程执行过程时会不停地被动去轮询这个标记,一旦发现中断标记为真时就本人在最近的平安点上被动中断挂起。轮询标记的中央和平安点是重合的,另外还要加上所有创建对象和其余须要在 Java 堆上分配内存的中央,这是为了查看是否行将要产生垃圾收集,防止没有足够内存调配新对象。因为轮询操作在代码中会频繁呈现,这要求它必须足够高效。HotSpot 应用内存保护陷阱的形式,把轮询操作精简至只有一条汇编指令的水平。
解决方案:程序不执行时采纳平安区域
应用平安点的设计仿佛曾经完满解决如何进展用户线程,让虚拟机进入垃圾回收状态的问题了,但理论状况却并不一定。平安点机制保障了程序执行时,在不太长的工夫内就会遇到可进入垃圾收集过程的平安点。
然而,程序“不执行”的时候呢?所谓的程序不执行就是没有调配处理器工夫,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无奈响应虚拟机的中断请求,不能再走到平安的中央去中断挂起本人,虚拟机也显然不可能继续期待线程从新被激活调配处理器工夫。对于这种状况,就必须引入平安区域(Safe Region)来解决。
平安区域是指可能确保在某一段代码片段之中,援用关系不会发生变化,因而,在这个区域中任意中央开始垃圾收集都是平安的。咱们也能够把平安区域看作被扩大拉伸了的平安点。
当用户线程执行到平安区域外面的代码时,首先会标识本人曾经进入了平安区域,那样当这段时间里虚拟机要发动垃圾收集时就不用去管这些已申明本人在平安区域内的线程了。
当线程要来到平安区域时,它要查看虚拟机是否曾经实现了根节点枚举(或者垃圾收集过程中其余须要暂停用户线程的阶段)。
如果实现了,那线程就当作没事产生过,继续执行;
否则,它就必须始终期待,直到收到能够来到平安区域的信号为止。
如何保障内存回收正确性?
解决对象跨代援用问题:记忆集与卡表
为解决对象跨代援用所带来的问题,垃圾收集器在新生代中建设了名为记忆集(Remembered Set)的数据结构,用以防止把整个老年代加进 GC Roots 扫描范畴。
事实上并不只是新生代、老年代之间才有跨代援用的问题,所有波及局部区域收集(Partial GC)行为的垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临雷同的问题。
记忆集是一种用于记录从非收集区域指向收集区域的指针汇合的形象数据结构。
如果咱们不思考效率和老本的话,最简略的实现能够用非收集区域中所有含跨代援用的对象数组来实现这个数据结构。这种记录全副含跨代援用对象的实现计划,无论是空间占用还是保护老本都相当昂扬。伪代码如下所示:
class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
复制代码
而在垃圾收集的场景中,收集器只须要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就能够了,并不需要理解这些跨代指针的全副细节。
那设计者在实现记忆集的时候,便能够抉择更为粗暴的记录粒度来节俭记忆集的存储和保护老本,上面列举了一些可供选择(当然也能够抉择这个范畴以外的)的记录精度:
字长精度:每个记录准确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器拜访物理内存地址的指针长度),该字蕴含跨代指针。
对象精度:每个记录准确到一个对象,该对象里有字段含有跨代指针。
卡精度:每个记录准确到一块内存区域,该区域内有对象含有跨代指针
其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的形式去实现记忆集,这也是目前最罕用的一种记忆集实现模式。
记忆集与卡表的区别:
记忆集其实是一种“形象”的数据结构,形象的意思是只定义了记忆集的行为用意,并没有定义其行为的具体实现。
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简略的模式能够只是一个字节数组,而 HotSpot 虚拟机的确也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑。
CARD_TABLE [this address >> 9] = 0;
复制代码
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。
一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过下面代码能够看出 HotSpot 中应用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。
那如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,别离对应了地址范畴为 0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF 的卡页内存块,如下图所示。
一个卡页的内存中通常蕴含不止一个对象,只有卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集产生时,只有筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中蕴含跨代指针,把它们退出 GC Roots 中一并扫描。
卡表元素如何保护:写屏障
咱们曾经解决了如何应用记忆集来缩减 GC Roots 扫描范畴的问题,但还没有解决卡表元素如何保护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏的答案是很明确的——有其余分代区域中对象援用了本区域对象时,其对应的卡表元素就应该变脏,变脏工夫点原则上应该产生在援用类型字段赋值的那一刻。
但问题是如何变脏,即如何在对象赋值的那一刻去更新保护卡表呢?
如果是解释执行的字节码,那绝对好解决,虚拟机负责每条字节码指令的执行,有充沛的染指空间;
但在编译执行的场景中呢?通过即时编译后的代码曾经是纯正的机器指令流了,这就必须找到一个在机器码层面的伎俩,把保护卡表的动作放到每一个赋值操作之中。
在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术保护卡表状态的。
写屏障能够看作在虚拟机层面对“援用类型字段赋值”这个动作的 AOP 切面,在援用对象赋值时会产生一个环形(Around)告诉,供程序执行额定的动作,也就是说赋值的前后都在写屏障的笼罩领域内。
在赋值前的局部的写屏障叫作写前屏障(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 堆容量间接成正比例关系了:堆越大,存储的对象越多,对象图构造越简单,要标记更多对象而产生的进展工夫天然就更长。
要晓得蕴含“标记”阶段是所有追踪式垃圾收集算法的独特特色,如果这个阶段会随着堆变大而等比例减少进展工夫,其影响就会波及简直所有的垃圾收集器,同理可知,如果可能削减这部分进展工夫的话,那收益也将会是系统性的。
三色标记工具
想解决或者升高用户线程的进展,就要先搞清楚为什么必须在一个能保障一致性的快照上能力进行对象图的遍历?
为了能解释分明这个问题,咱们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,依照“是否拜访过”这个条件标记成以下三种色彩:
红色:示意对象尚未被垃圾收集器拜访过。显然在可达性剖析刚刚开始的阶段,所有的对象都是红色的,若在剖析完结的阶段,依然是红色的对象,即代表不可达。
彩色:示意对象曾经被垃圾收集器拜访过,且这个对象的所有援用都曾经扫描过。彩色的对象代表曾经扫描过,它是平安存活的,如果有其余对象援用指向了彩色对象,毋庸从新扫描一遍。彩色对象不可能间接(不通过灰色对象)指向某个红色对象。
灰色:示意对象曾经被垃圾收集器拜访过,但这个对象上至多存在一个援用还没有被扫描。
对于可达性剖析的扫描过程,把它看作对象图上一股以灰色为波峰的波纹从黑向白推动的过程,如果用户线程此时是解冻的,只有收集器线程在工作,那不会有任何问题。
但如果用户线程与收集器是并发工作呢?
用户线程与收集器是并发工作存在的问题
收集器在对象图上标记色彩,同时用户线程在批改援用关系 — 即批改对象图的构造,这样可能呈现两种结果。
一种是把本来沦亡的对象谬误标记为存活,这不是坏事,但其实是能够容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
另一种是把本来存活的对象谬误标记为已沦亡,这就是十分致命的结果了,程序必定会因而产生谬误。
上面演示了这样的致命谬误具体是如何产生的:
如果用户线程此时是解冻的,只有收集器线程在工作,那不会有任何问题。
但如果用户线程与收集器是并发工作呈现如下两种状况,将会导致对象隐没。
Wilson 于 1994 年在实践上证实了,当且仅当以下两个条件同时满足时,会产生“对象隐没”的问题,即本来应该是彩色的对象被误标为红色:
赋值器插入了一条或多条从彩色对象到红色对象的新援用;
赋值器删除了全副从灰色对象到该红色对象的间接或间接援用。
如何保障内存回收正确性?
咱们要解决并发扫描时的对象隐没问题,只需毁坏下面这两个条件的任意一个即可。由此别离产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。
增量更新要毁坏的是第一个条件,当彩色对象插入新的指向红色对象的援用关系时,就将这个新插入的援用记录下来,等并发扫描完结之后,再将这些记录过的援用关系中的彩色对象为根,从新扫描一次。这能够简化了解为,彩色对象一旦新插入了指向红色对象的援用之后,它就变回灰色对象了。
原始快照要毁坏的是第二个条件,当灰色对象要删除指向红色对象的援用关系时,就将这个要删除的援用记录下来,在并发扫描完结之后,再将这些记录过的援用关系中的灰色对象为根,从新扫描一次。这也能够简化了解为,无论援用关系删除与否,都会依照刚刚开始扫描那一刻的对象图快照来进行搜寻。
以上无论是对援用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有理论利用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
总结
本文简要概述了 HotSpot 虚拟机的内存回收一些细节。首先,谈到了以后支流的 JVM 都是采纳可达性剖析算法通过根节点枚举来找到曾经死去的对象,发动内存回收。同时,也存在如下问题:
当初 Java 利用越做越宏大,光是办法区的大小就常有数百上千兆
所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的
从 GC Roots 再持续往下遍历对象图,这一步骤的进展工夫就必定会与 Java 堆容量间接成正比例关系了:堆越大,存储的对象越多,对象图构造越简单,要标记更多对象而产生的进展工夫天然就更长
因而,采纳优化 GC Roots 的查找和并行可达性剖析这两种形式来缩小进展工夫,减速内存的回收。
第一,通过采纳平安点和平安区域的形式来优化 GC Roots 的查找。通过记忆集与卡表来解决跨代援用的问题。同时,提到了通过写屏障来保护卡表的元素。同时,还提到了写屏障存在的一些问题:写屏障会带来额定开销以及伪共享问题。
第二,通过用户线程与收集器是并发工作,从而达到并行可达性剖析。通过三色标记工具来了解遍历对象图的过程。同时提到了用户线程与收集器是并发工作将会导致对象隐没的问题,还提到了通过增量更新和原始快照这两种计划来解决该问题。
最初
如果你感觉此文对你有一丁点帮忙,点个赞。或者能够退出我的开发交换群:1025263163 互相学习,咱们会有业余的技术答疑解惑
如果你感觉这篇文章对你有点用的话,麻烦请给咱们的开源我的项目点点 star:http://github.crmeb.net/u/defu 不胜感激!
残缺源码下载地址:https://market.cloud.tencent….
PHP 学习手册:https://doc.crmeb.com
技术交换论坛:https://q.crmeb.com