关于后端:GC面临的困境JVM是如何解决跨代引用的

6次阅读

共计 3239 个字符,预计需要花费 9 分钟才能阅读完成。

本文已收录至 GitHub,举荐浏览 👉 Java 随想录

微信公众号:Java 随想录

原创不易,重视版权。转载请注明原作者和原文链接

后面咱们讲了可达性剖析和根节点枚举,介绍完了 GC 的前置工作,上面开始讲 GC 的工作过程。

然而在 GC 开始工作之前,有一个不得不解决的问题摆在咱们背后:「跨代援用问题」。

本篇文章就来聊聊什么是跨代援用问题,以及 JVM 是如何解决跨代援用问题的。

跨代援用问题

跨代援用是指新生代中存在对老年代对象的援用,或者老年代中存在对新生代的援用。

为什么说这是一个问题呢?请看下图。

如果当初要进行一次只局限于新生代区域的 YGC,但新生代中的对象是齐全有可能被老年代所援用的,为了找到新生代中的存活对象,不得不遍历整个老年代来确保可达性剖析后果的正确性。

首先,咱们得明确一点,跨代援用是极少的,这很重要。

举个例子阐明:如果某个新生代对象存在跨代援用,因为老年代对象难以沦亡,该援用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后降职到老年代中,这时跨代援用也随即被打消了。

这几乎就是原子弹炸鸟,起重机吊鸡毛。因为跨代援用是极少的,为了找出那么一点点跨代援用,却得遍历整个老年代!

而 JVM 里 GC 回收无疑是十分频繁的动作,如果每次都这么搞,性能必定吃不消,无疑会为内存回收带来很大的性能累赘。

别慌,JVM 的设计者曾经思考到了这个场景,并想到了解决办法,那就是应用一种叫做:「记忆集(Remembered Set)」的数据结构。

记忆集

记忆集位于新生代中,是一种用于记录从非收集区域指向收集区域的指针汇合的形象数据结构。用以防止把整个老年代加进 GC Roots 扫描范畴。

记忆集的作用和咱们之前讲的 OopMap 很类似,保护了相似一种映射表的关系,防止了全局扫描,实质是用空间换工夫。

尔后当产生 YGC 时,只有把记忆集加进来一起扫描,就能晓得新生代对象被老年代援用的状况,而不用扫描整个老年代!

尽管说减少了保护记忆集的老本,但比起收集时扫描整个老年代来说这波还是血赚!

下面不晓得大家有没有注意我的说辞:「形象数据结构」。意思就是说记忆集是一种逻辑上的概念,并没有规定具体的实现,相似办法区。

在 HotSpot 中,采纳卡表去实现记忆集。能够把记忆集和卡表的关系了解为 Map 跟 HashMap。

卡表

卡表能够了解为是记忆集的具体实现,英文叫:Card Table。

垃圾收集器只须要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就能够了,并不需要理解这些跨代指针的全副细节。

那设计者在实现记忆集的时候,便能够抉择更为粗暴的记录粒度来节俭记忆集的存储和保护老本,上面列举了一些可供选择(当然也能够抉择这个范畴以外的)的记录精度:

其中,第三种「卡精度」所指的就是「卡表」的形式去实现记忆集,这也是目前最罕用的一种记忆集实现模式,HotSpot 采纳的就是卡表。

在 HotSpot 虚拟机外面,卡表采纳的是字节数组的模式。以下这行代码是 HotSpot 默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作「卡页(Card Page)」。

一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过下面代码能够看出 HotSpot 中应用的卡页是 2 的 9 次幂,即 512 字节。

意味着如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,别离对应了地址范畴为 0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF 的卡页内存块,如图所示:

一个卡页的内存中通常蕴含不止一个对象,只有卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。

简略来说,就是卡页的字节数组只有 0 和 1 两种状态,1 示意哪些内存区域存在跨代指针,那么只有把 1 的退出 GC Roots 中一并扫描,就能晓得哪些进行跨代援用了,这样就不必挨个去扫描了。

OK,到了这步咱们的思路就清晰了。

能够把老年代划分为一个个内存区域,每块内存区域别离对应卡表的元素,而后把卡表中变脏的元素,间接退出 GC Roots 中一并扫描,跨代援用问题就迎刃而解了。

如图,对象 A 在老年代 0x0000~0x01FF 内存区域被援用,那只有把对应的卡表标记为 1,YGC 的时候扫描卡表,就能晓得对象 A 被老年代哪块内存区域援用了。

but,咱们还剩下一个问题,卡表元素如何保护?相似问题 OopMap 也遇到过。

卡表元素如何保护?何时变脏?谁来把它们变脏?

HotSpot 解决的方法是应用写屏障。

写屏障

先来解决何时变脏的问题,这个问题很简略,即 其余分代区域中对象援用了本区域对象时,其对应的卡表元素就应该变脏,变脏工夫点原则上应该产生在援用类型字段赋值的那一刻

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

在 HotSpot 虚拟机里是通过「写屏障(Write Barrier)」解决的。

留神:这里提到的 写屏障 和 volatile 的写屏障不是一回事。

写屏障能够看作在虚拟机层面对「援用类型字段赋值」这个动作的 AOP 切面,在援用对象赋值时会产生一个环形(Around)告诉。用过 Spring 的弟兄们对 AOP 必定不生疏。

在赋值前的局部的写屏障叫作「写前屏障(Pre-Write Barrier)」,在赋值后的则叫作「写后屏障(Post-Write Barrier)」。

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

利用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中减少了更新卡表操作,无论更新的是不是老年代对新生代对象的援用,每次只有对援用进行更新,就会产生额定的开销,不过这个开销与 YGC 时扫描整个老年代的代价相比还是低得多的。

当引入一个解决方案的时候,随之而来的可能还有其余问题。卡表在高并发场景下还面临着「伪共享(False Sharing)」问题。

写屏障的伪共享问题

伪共享是解决并发底层细节时一种常常须要思考的问题,号称并发的「隐形杀手」。

古代中央处理器的缓存零碎中是以缓存行(Cache Line)为单位存储的,当多线程批改相互独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、有效化或者同步)而导致性能升高。

core1 更新 A,同时 core2 更新 B,因为数据的读取和更新是以「缓存行」为单位的,这就意味着当这两件事同时产生时,就产生了竞争,导致 core1 和 core2 有可能须要从新刷新本人的数据(缓存行被对方更新了),最终导致系统的性能大打折扣,这就是伪共享问题。

为了防止伪共享问题,一种简略的解决方案是不采纳无条件的写屏障,而是先查看卡表标记,只有当该卡表元素未被标记过期才将其标记为变脏。

行将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;

相当于说其实就是多了一个「if 判断条件」。

在 JDK 7 之后,HotSpot 虚拟机减少了一个新的参数「-XX:+UseCondCardMark」,此参数默认是敞开的,用来决定是否开启卡表更新的条件判断。

开启会减少一次额定判断的开销,但可能防止伪共享问题,两者各有性能损耗,是否关上要依据利用理论运行状况来进行测试衡量。

看到这,本篇文章就完结啦,这章讲了跨代援用和记忆集。

GC 收集还有很多是须要咱们去搞清楚的。晓得的越多,不晓得的越多 ,这只是个开始,一起期待下篇的「 三色标记算法」吧。


感激浏览,如果本篇文章有任何谬误和倡议,欢送给我留言斧正。

老铁们,关注我的微信公众号「Java 随想录」,专一分享 Java 技术干货,文章继续更新,能够关注公众号第一工夫浏览。

一起交流学习,期待与你共同进步!

正文完
 0