你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

hresh 381 0

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

JVM内存分配策略

分代收集理论

以下内容来源于《深入理解Java虚拟机》一文。

分代收集理论实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

1、弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。

2、强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡

基于上述两个分代假说,当前流行的垃圾收集器都遵循如下设计原则:收集器应该将 Java 堆划分出不同的区域, 然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数) 分配到不同的区域之中存储。根据二八原则可知,百分之八十的对象存活时间特别短,集中放到一块区域,被垃圾收集器频繁光顾,以便快速回收释放资源;剩下百分之二十的对象存活时间久,则可以集中放在一块区域,降低垃圾收集的开销。

在 Java 堆划分出不同的区域之后, 垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分; 也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。 这些概念都是基于分代收集理论提出的。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,这样会带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

3、跨代引用假说( Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占极少数。

基于上述假说可以得到如下推论:如果两个对象存在互相引用,我们倾向于认为这两个对象是共生共灭的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构( 该结构被称为“记忆集”, Remembered Set,下文会具体介绍) ,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系( 如将自己或者某个属性赋值) 时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

上文提到了 Minor GC 等 GC 术语,统一定义如下:

部分收集( Partial GC):指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:

  • 新生代收集( Minor GC/Young GC) :指目标只是新生代的垃圾收集。
  • 老年代收集( Major GC/Old GC) :指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单独收集老年代的行为。 另外请注意“Major GC”这个说法现在有点混淆, 在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  • 混合收集( Mixed GC) :指目标是收集整个新生代以及部分老年代的垃圾收集。 目前只有G1收集器会有这种行为。

整堆收集( Full GC) :收集整个 Java 堆和方法区的垃圾收集。

JVM堆划分

为了支持垃圾收集,堆被分为三个部分:

  1. 年轻代 : 常常又被划分为 Eden 区和Survivor(From Survivor、To Survivor)区、(Eden空间、From Survivor空间、To Survivor空间(空间分配比例是8:1:1)
  2. 老年代
  3. 永久代 (jdk 8已移除永久代)

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),关于 JVM 执行时使用在使用哪些参数以及其各参数默认值,可以执行下述命令:

java -XX:+PrintFlagsFinal -version
//这里就不列举结果了

在结果中可以看到 UsePSAdaptiveSurvivorSizePolicy 参数默认为 true。

除此之外,根据生成对象的速率,以及 Survivor 区的使用情况,我们可以动态调整 Eden 区和 Survivor 区的比例。具体可以通过参数 -XX:SurvivorRatio 来调整这个比例,比如说上文我们提到 Eden、From、To 三者之间的比例为 8:1:1,是因为 JVM 关于 SurvivorRatio 参数的默认值为 8。

如果 SurvivorRatio 为 8,说明每个 survivor 区域和 eden 区域的内存比例为 1:8,也就是说每个 survivor 区域占用的内存是新生代内存大小的 1/10(因为新生代中有两个survivor区域,10 = 1(survivor) + 1(survivor) + 8(eden))。如果 SurvivorRatio 值越小,则 survivor 区域占比越大。

需要注意的是,其中一个 Survivor 区会一直为空,因此 survivor 数值越小,则浪费的堆空间将越高。

我们来考虑一个更有难度的问题:为什么新生代的 Survivor 要设计 From 和 To 两个平行的区呢?

Survivor

在此之前,我们先了解一下 GC 时对象在各个区域间是怎么流转的。

JDK8 后 JVM 将堆内存分为 Young 和 Old 两个区:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

而 Young 区又分为 Eden、Survivor1、Survivor2, 两个Survivor 区相对地作为为From 和 To 逻辑区域, 当Survivor1作为 From 时 , Survivor2 就作为 To, 反之亦然。如下图所示:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

为什么要有 Survivor 呢?

我们都知道标记-复制算法,需要两块内存,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

最初设计 copying 收集算法时,直接将整个堆空间被分为两半,叫做 semispace。空间分配和回收的过程就是把其中一半用作 from 来分配空间,当 from 快满或满足别的一些条件时将可到达的对象复制到to,并将 from 与 to逻辑交换的过程。

原始的 copying 收集不能很好的应对长时间存活的对象,因为那样的对象每次经历收集的时候都还活着,带来拷贝的开销。出于权衡,HotSpot 里目前除 G1 外都采用相似的方式实现分代式 GC,并且在 young gen 都使用copying 收集算法。不过它使用的 copying 算法是原始算法的变种,留一块较大的区域作为 eden,在 eden 与 old gen 之间设置 semispace 来作为缓冲,让“中等寿命”的对象尽量在进入 old gen 之前被收集掉。这就是 HotSpot的 survivor spaces 了。

总结来说,在分代 GC 中,survivor 用来缓冲 eden 中的对象,将“中等寿命”的对象尽量在进入 old gen 之前被收集掉,毕竟 Minor GC 代价小一些。

回归正题,如果进行 Minor GC,会发生什么状况呢?

1、首先对象在 Eden 中分配内存,使用结束后,判定对象是否死亡,已死亡则在下次 GC 时被回收。

2、GC 时,会先将 Eden 区存活的对象移到 To区(空的),除了那些太大没法放入到 To 的对象,这些大的对象会放入到 Old 区。From 区中相对年轻的存活对象也会被移动 To 区,相对较老的对象会被移到 Old 区。

注意:如果 To 空间已满,来自 Eden 或 From 的未复制到 To 区的活动对象将被永久保存,无论它们存活了多少年轻代集合。根据定义,在复制活动结束后留在 Eden 或 From 空间中的任何对象都被认为是要回收的,并且不需要检查它们。(这些垃圾对象在图中用 X 标记,尽管实际上收集器不会检查或标记这些对象。)如下图所示:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

3、GC 操作执行完之后 Eden和 From 区将会为空(死亡对象被回收,有引用对象被移到 To 和 Old 区) ,并且From 和 To 在逻辑上的 概念调换 , From 概念上变成了 To,To 变成了 From(如果Servior1 原来作为 From 区 ,现在Servior1 现在就作为 To 区),GC执行后结果如下图:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

综上可以看出,Survivor 需要两个区的核心原因是因为 copying 算法的存在,提升回收效率,同时因为两个分区,可以对内存进行有效地压缩,使得对象在内存中排列更加紧凑。

TLAB

堆是 JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了 new 对象的开销是比较大的。

Sun Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间 TLAB(Thread Local Allocation Buffer),其大小由 JVM 根据运行的情况计算而得,在 TLAB 上分配对象时不需要加锁,因此 JVM 在给线程的对象分配内存时会尽量的在 TLAB 上分配,在这种情况下 JVM 中分配对象内存的性能和 C 基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配。

TLAB 仅作用于新生代的 Eden Space,因此在编写 Java 程序时,通常多个小的对象比大的对象分配起来更加高效。

聊完 TLAB 后,我们继续看看对象在堆内存中是如何流转的。

所有新创建的 Object 都将会存储在新生代 Yong Generation 中,如果 Eden 区的空间耗尽了,JVM 则会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

前面提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

MaxTenuringThreshold 参数默认为 15,是因为 HotSpot 会在对象头中的标记字段里记录年龄,分配到的空间只有4位,最多只能记录到15。

记忆集与卡表

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set) 的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC 和 Shenandoah 收集器, 都会面临相同的问题, 因此我们有必要进一步理清记忆集的原理和实现方式。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。下面列举了一些可供选择(当然也可以选择这个范围以外的) 的记录精度,由高到低依次为:

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

卡精度所指的是用一种称为“卡表”(card table)的方式实现记忆集,是目前最常用的一种实现形式,卡表与记忆集的冠以,类似于 Java 语言中 HashMap 与 Map 的关系。

卡表技术指:将整个堆划分为一个个指定大小的内存块,这个内存块被称作“卡页”,卡页大小在 HotSpot 中默认为 512 字节,并且维护一个卡表(可以是一个字节数组)。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多) 对象的字段存在着跨代指针, 那就将对应卡表的数组元素的值标识为1, 称为这个元素变脏(Dirty) , 没有则标识为0。

有了卡表之后,我们看看其在实际应用中是怎么工作的。比如说在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里(Minor GC 时JVM将从脏卡中的对象查找,作为GC Roots)。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

CMS 收集器和 G1 收集器关于卡表的实现有所不同,我们挨个介绍。

CMS卡表技术

我们先来看一下 CMS 卡表的具体含义:记录老年代区域是否存在新生代对象的引用。

可能大家疑惑为什么只讨论老年代卡表呢

当进行 Minor GC 时,我们出于清理新生代区对象的目的,在进行可达性分析过程中,需要判断该对象是否被老年代对象所引用。我们都知道老年代区域比新生代大,如果扫描整个老年代,这是件很消耗性能的事情。而老年代卡表则可以避免老年代的全扫描。

反之,如果进行 Major GC,即要清理老年代中的对象,那么当然也可以存在该对象被新生代对象所引用的情况,然后就需要扫描整个新生代,那么是否需要新生代卡表呢?HotSpot 实际上并没有使用新生代卡表,因为新生代的引用变化率非常高,GC 比较频繁,其对应的 card table 部分可能大部分都是 dirty 的,要把新生代对象当作root 时,与其扫描 card table 还不如直接扫描整个新生代。

直白地说就是卡表记录指向新生代对象的引用,CMS 设计的卡表又称为单向卡表。这里简单用图形容一下:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

假设 page 1 区域内的对象有引用新生代的对象 A,那么则会被标记,这里暂且记为黄色,之后进行 Minor GC 时,如果要判断对象 A是否死亡,则不必扫描整个老年代,只需要扫描有颜色标记的 page 即可。

G1卡表技术

通过上文我们可知,G1的堆内存被划分为多个大小相等的 Region,不再考虑分代限制,那么 Region 里面的跨 Region 引用对象如何解决?

可以明确地是仍然采用记忆集来处理,不过相对于 CMS 收集器记忆集的应用就复杂多了。它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针, 并标记这些指针分别在哪些卡页的范围之内。

G1的记忆集在存储结构的本质上是一种哈希表,Key 然后是别的 Region 的起始地址, Value 是一个集合, 里面存储的元素是卡表的索引号。 这种“双向”的卡表结构(卡表是“我指向谁”, 这种结构还记录了“谁指向我”) 比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多, 因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于 Java 堆容量10%至20%的额外内存来维持收集器工作。

我们还是用图的形式来解释:

你知道为什么要有两个 Survivor吗?关于卡表技术又有多少了解

首先要明白这是个哈希表结构,每个 Region 对应一个卡表,如上图所示,Region 1的卡表中标识 Region 1为黄色,那么可以理解 Region 1 中存在对象引用了 Region 1中的对象 A,进行 GC 时,如果需要判断对象 A 是否死亡,则会遍历 Region 1的卡表。

写屏障

我们解决了如何使用记忆集来缩减GC Roots扫描范围的问题, 但还没有解决卡表元素如何维护的问题, 例如它们何时变脏、 谁来把它们变脏等。

什么时候让卡片变脏?

这里以 CMS 卡表为例,如果老年代的卡页中对象引用了新生代的对象,则该卡页就会变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

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

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

在 HotSpot 虚拟机里是通过写屏障(Write Barrier) 技术维护卡表状态的。

只是补充两点:

  1. 这里的写屏障和我们常说的为了解决并发乱序执行问题的"内存屏障"不是一码事,需要区分开来。
  2. 写屏障可以看作虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。

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

写屏障便可精简为下面的伪代码。这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。

CARD_TABLE [this address >> 9] = DIRTY;

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

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing) 问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

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

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

具体实现为:在 JDK 7之后, HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。其伪代码如下所示:

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

开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

总结

关于 JVM 垃圾回收就先讲到这里,通过这次学习,又对垃圾回收有了新的认识,希望大家也能有所收获。

JVM 的垃圾回收不像之前讲的一些理论性内容,比如说 Java 内存布局,方法调用等等,那些在实际应用中可能接触比较少,但是关于 GC 就有很多可说的了,尤其是 GC 调优,作为 Java 性能优化最重要的内容之一,在实际应用中 GC 的涉及面很广,所以后续还会补充对 GC 调优进行讲解。应该要不了多久就会有文章发布,千万别走开。

参考文献

《深入理解Java虚拟机》

HotSpot VM] 关于incremental update与SATB的一点理解

HotSpot VM 内存堆的两个Survivor区

发表评论 取消回复
表情 图片 链接 代码

分享