JVM 垃圾收集器与内存分配策略

垃圾收集(Garbage Collection, GC)需要完成三件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

了解 GC 和内存分配,最主要的目的是在当垃圾回收作为系统性能瓶颈时,如果是系统达到更高的性能。

程序计数器,本地方法栈,虚拟机栈随线程和方法的结束,内存也会自动的回收了,所以,垃圾回收最主要的考虑的区域是堆和方法区

1. 如果判断对象已死?

垃圾收集器在对堆进行回收前,首先需要确定哪些对象已“死”,需要被回收。

1.1. 对象存活判定算法

1.1.1. 引用计数算法(Reference Counting)

给每个对象添加一个引用计数器,当对象被引用时,计数器 +1;当一个引用失效时,计算器 -1。所以,任何时候,只有计数器的值为 0,表示对象没有被使用了。

这种计数器的实现简单,判断效率高,但是解决不了对象的循环引用问题。加入 A 指向 B,B 指向 A,然后 A 和 B 却又都不被引用了。此时,两个计数器的值都不为 0 ,但是又不可能被访问到,导致垃圾回收器无法回收这两个对象的内存空间。

所以,现在的 Java 虚拟机实现里面,也没有采用引用计数算法来判断对象的存活。

1.1.2. 可达性分析算法(Reachability Analysis)

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所做过的路径叫做引用链(Reference Chain)。当一个对象不与任何一个 GC Roots 相连,证明此对象时不可用的,如图:
reachability-analysis
在 Java 语言中,可以作为 GC Roots 的对象包括:

  • 虚拟机栈中(栈帧中的本地变量表)引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(Native 方法) 引用的对象

1.2. 引用类型

在 JDK 1.2 之前,引用的定义:如果一个 reference 类型中存储的数值代表的是另外一块内存的起始值,就称这块内存代表着一个引用。这样,一个对象就只有两种状态,引用(有一个reference指向)和没有被引用(没有reference指向)。但是,如果想要表示另外一种对象:在执行垃圾回收时,只要内存足够使用,对象依然可以留在内存中;只有当进行垃圾回收后内存还是很紧张时,才会回收的对象,就没有办法(系统缓存,对象缓存)。
所以 JDK 1.2 之后,对引用的概念进行了扩充,分为:

  1. 强引用,就是平常使用的引用,只要强引用还在,垃圾收集器永远不会回收的对象。
  2. 软引用,用来描述一些还有用但是非必要的对象。在系统将要发生内存溢出之前,会把软引用关联的对象列为回收范围,进行第二次回收,如果这次回收后还是没有足够的内存,才会抛出内存溢出异常。SoftReference 类。
  3. 弱引用,用来描述非必要对象的,但是它的强度比软引用还要弱。被弱引用关联的对象最多活到下一次垃圾回收发生之前(软引用关联的对象,最多能活到下下次垃圾回收之前)。当垃圾回收器工作时,无论当前内存是否够用,都会回收弱引用关联的对象。WeakRefernce。
  4. 虚引用,又称为幽灵引用或者幻影引用,最弱的引用关系。一个对象是否有虚引用的存在,完全不会对对象的生存时间受到任何影响,也无法根据一个虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾回收器回收时,收到一个系统通知。PhantomReference.

1.3. 对象是否真的已死?

判断一个对象是否是真的已死有两个标准:

  1. 先对没有 GC Roots 到该对象的引用链进行标记。
  2. 再判断标记的对象有没有必要执行 finalize() 方法。(没有覆写 finalize() 方法 或者 已经执行过 finalize() 方法 这两种情况被认为没有必要)。

当一个同时满足了上面两个条件,则认为对象已死,需要被回收。

当进行过第 1 步后,对象被标记后,如果有必要执行 finalize 方法,会放入一个 F-Queue 的队列,并由一个由虚拟机自动创建的,低优先级的 Finalizer 线程去执行。执行的时候,不会等待执行的结果,怕如果 某个 finalize() 方法执行缓慢,或者存在死循环,会导致 F-Queue 队列的其它对象一直处于等待,从而导致整个内存回收系统奔溃。

注意,完全不建议覆写 finalize() 方法。

1.4. 回收方法区(HotSpot 永久代)

新生代的回收效率很高 70% - 95%,而永久代则远低于这个效率。

永久代的回收包括了,废弃无用常量 以及 无用的类。

废弃无用常量:如果系统类没有任何引用指向这个常量,那么就可以回收了。
废弃无用的类(类卸载)需要满足三个条件:

  1. 该类没有实例对象。
  2. 该类的 ClassLoader 已经被回收了。
  3. 该类的 java.lang.Class 对象没有被任何地方引用。

在大量的使用 动态代理(JDK 和 CGlib),反射等等功能的情况下,类卸载功能可以防止堆内存溢出问题。

2. 垃圾回收算法

2.1. 标记-清除算法(Mark-Swepp)

标记-清除是最基本的垃圾回收算法,分为两个步骤,标记 和 清除。
之所以说是最基本的算法,是因为后续所有的回收算法都是在该算法的基础上,对不足进行改进而来的。
标记-清除算法的两个不足之处:

  1. 效率不高,标记和清除两个过程的效率都不高。
  2. 内存碎片化的问题,大量的内存碎片会导致需要给一个大对象分配内存空间时,无法找到足够的连续内存而不得不提前出发另一次垃圾回收动作。

2.2. 复制算法(Copy)

复制算法最主要的使用区域是新生代区域。
将一块内存区域划分为两块区域,每次只使用其中一块区域,当进行垃圾回收时,将存活的对象从当前区域拷贝到另一个区域,然后再清空当前区域即可。此时,就不用考虑内存碎片化的问题。

因为新生代的对象 98% 都是朝生夕死的对象,所以,新生代并不需要按照 1:1 来划分内存。
JVM 的堆内存划分为新生代和老年代。其中新生代继续划分为一个 Eden 和 两个 survivor 空间,Eden 和 两个 survivor 的大小比例为 8:1:1(可以通过-XX:SurvivorRatio=x修改)。任意时候,JVM 只使用 Eden 和其中一个 survivor 空间,当需要进行垃圾回收的时候,会把 Eden 和 这个 survivor 中存活的对象拷贝到另外一个 survivor 空间中,然后清空当前的 survivor,开始使用 Eden 和另外那个 survivor 空间。也就是说,任意时刻,新生代可用的内存为新生代总内存的 (80+10)%。
jvm-heap

如果当其中的某个 survivor 不足以存放 Eden 和当前 survivor 中的对象时,需要依赖其它区域的内存(老年
代)进行分配担保(见后)。

新生代中的对象经历过一定次数(默认 -XX:MaxTenuringThreshold=15 次)的 GC 后,依然存活,就会将对象移动到 老年代。

使用 java -XX:+PrintFlagsFinal -version 查看虚拟机的 JVM 默认配置。

2.3. 标记-整理算法(Mark-Compact)

复制算法在对象存活率较高的时候,就会进行过多的复制操作,效率将会变低。在老年代中,可能会出现老年代中所有对象都 100% 存活的情况,所以,在老年代,一般不能直接采用复制算法。

标记-整理算法,标记过程和标记-清理过程一致,但是在整理阶段,不是直接清理对象,而是让所有存活对象都像一端移动,然后直接清理到边界以外的内存。

2.4. 分代收集算法

现在的商用虚拟机都采用分代(Generational Collection)收集算法。将内存划分为不同的区域,一般分为新生代和老年代。针对不同的区域采用不同的收集算法。在新生代这种存活率很低的区域,采用复制算法,只使用很少的复制成本,就可以完成内存回收。而老年代,存活率高,而且没有其它内存为它做分配担保,所以,老年代往往采用标记-清除或者标记-整理算法。

3. HotSpot 的算法实现

3.1. 枚举根节点

可达性分析需要选定一系列的 GC Roots,一般选择全局性引用(类的常量引用或者静态属性引用)与执行上下文(栈帧中的本地变量表)。
一致性:在虚拟机进行可达性分析时,不能出现在分析的过程中,对象的引用也在不停的变化,造成分析的结果不准确。所以,在进行可达性分析时,需要暂停虚拟机中的执行线程,也就是 Stop The World。

3.1.1. 保守式 GC

保守式 GC 是指 JVM 在做可达性分析时,无法区分内存里某个位置上的数据到底应该解读为引用类型还是整型还是别的什么。
保守式 GC 在进行 GC 的时候,JVM 开始从一些已知位置(例如说JVM栈)开始扫描内存,扫描的时候每看到一个数字就看看它“像不像是一个指向GC堆中的指针”。这里会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针),之类的。然后递归的这么扫描出去。

保守式GC的好处是相对来说实现简单些,而且可以方便的用在对GC没有特别支持的编程语言里提供自动内存管理功能。

保守式GC的缺点有:
1、会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过GC的收集。这对程序语义来说是安全的,因为所有应该活着的对象都会是活的;但对内存占用量来说就不是件好事,总会有一些已经不需要的数据还占用着GC堆空间。具体实现可以通过一些调节来让这种无用对象的比例少一些,可以缓解(但不能根治)内存占用量大的问题。

2、由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式GC的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK的Classic VM用过这种全handle的设计,但效果实在算不上好。

参考:https://rednaxelafx.iteye.com/blog/1044951

3.1.2. 准确式 GC

准确式 GC,关键是“类型”,也就是说给个某个位置的某块数据,要能知道他的准确类型是什么,这样才可以合理的解读数据的含义。GC 所关心的含义就是这块数据是不是“指针”。

为了实现这个需求,HotSpot 增加了 OopMap 数据结构,实际上是一个映射表。OopMap 存储了对象中各个偏移量位置上的数据类型,这样虚拟机很快就能知道一个数据是什么类型了。OopMap 是在类加载过程中计算后得到并生成的。

使用这样的映射表(OopMap)一般有两种方式:
1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

3.2. Safe Point 安全点(如何进入 GC)

每个被 JIT 编译过后的方法也会在一些特定的位置记录下 OopMap,记录了执行到该方法中的某条指令的时候,栈上和寄存器哪些位置是引用,这样 GC 在扫描栈的时候,就会查询这些 OopMap 就知道哪里是引用了。
这些特定的位置主要有:

  1. 循环的末尾
  2. 方法的临返回前/调用方法的 call 指令后
  3. 可能抛异常的位置

这种位置就被称为“安全点(Safe Point)”。
之所以要选择一些特定的位置来记录 OopMap,是因为如果对每条指令(的位置)都记录 OopMap 的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。因为这样,HotSpot 中 GC 不是在任意位置都可以进入,而只能在 Safe Point 处进入。

平时这些OopMap都是压缩了存在内存里的;在GC的时候才按需解压出来使用。
HotSpot是用“解释式”的方式来使用OopMap的,每次都循环变量里面的项来扫描对应的偏移量。

这也就是说,程序在执行的时候,并非在所有的地方都能进行 GC,而是需要达到安全点时才能暂停。

对于 Safe Point 而言,另一个需要考虑的问题时,在 GC 发生时让所有的线程(不包括执行 JNI 调用的线程)都”跑“到最近的安全点后再停顿下来。

3.2.1. 抢断式中断

首先中断所有线程,如果检查到发现有线程没有中断到安全点上,则恢复线程,让它“跑”到安全点上。现在基本上没有虚拟机才有抢断式中断来暂停线程,从而响应 GC 事件。

3.2.2. 主动式中断

不主动中断线程,而是设置一个标志,每个线程运行到安全点时,都去检查这个标志,如果发现标志为真时就主动中断刮起。

3.3. 安全区域

安全点解决了程序如何进入 GC,但是需要程序自己跑到安全点上,那么如果程序不执行呢(sleep or blocked)?这个时候,程序无法响应 JVM 的中断请求,JVM 又不可能等待线程恢复执行后才中断,这就需要安全域。

安全域是指在一段代码片段中,引用关系不会发生变化,也就不会去更新 OopMap,这个代码片段中的任意地方 GC,都是安全的。线程执行的过程中,如果进入到安全区域内,就会标志自己已经进行到安全区域了。那么虚拟机要进行GC的时候,发现该线程已经运行到安全区域,就不会管该线程的死活了。所以,该线程在脱离安全区域的时候,要自己检查系统是否已经完成了GC或者根节点枚举(这个跟GC的算法有关系),如果完成了就继续执行,如果未完成,它就必须等待收到可以安全离开安全区域的Safe Region的信号为止。

4. 垃圾收集器(8种)

如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

HotSpot 的垃圾回收器种类,以及使用的内存区域。
hot-spot-gc-list

4.1. Serial/Serial Old 收集器

只会使用一个 CPU(内核)的单线程收集器。回收时,会暂停用户的执行线程,也就是 STW.
Serial 是新生代的垃圾回收器,采用复制算法。
Serial Old 是老年代的垃圾回收器,才有复制-整理算法。
Serial 和 Serial Old 在 Client 模式运行下,是一个不错的选择。

4.2. ParNew 收集器

仅仅是 Serial 的多线程版本,也是新生代的垃圾回收器,采用复制算法。
同样会暂停用户的所有执行线程,但是此时回收的时候,是才有多线程的方式。

ParNew 收集器是许多运行在 Server 模式下的虚拟机的首选收集器,除开一个与性能无关的原因,是只有 ParNew 作为新生代的收集器,能和 CMS(标记-清理) 收集器(老年代收集器)搭配一起使用。

  • 并行,多条垃圾回收线程并行工作,但是此时用户线程仍然处于等待状态。
  • 并发,用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交替执行)。用户线程在继续运行,垃圾回收进程运行在另一个 CPU 上。

4.3. Parallel Scavenge / Parallel Old 收集器

Parallel Scavenge 和 G1 垃圾回收器都没有使用传统的 GC 收集器代码。
Parallel Scavenge 是新生代垃圾回收器,采用多线程,复制算法。
Parallel Old 是老年代垃圾回收器,采用多线程,复制-整理算法。

CMS 垃圾回收器的目标是尽可能减少用户执行线程停顿的时间,而 Parallel Scavenge / Parallel Old 的目标是达到一个可控制的吞吐量。吞吐量 = (用户线程执行时间)/(用户线程执行时间 + 垃圾回收时间)。如果虚拟机总共运行了 100 分钟,垃圾回收 1 分钟,那么系统的吞吐量就为 99%。

Parallel Scavenge 使用两个参数来控制系统的吞吐量:-XX:MaxGCPauseMillis 和 -XX:GCTimeRatio。

  • MaxGCPauseMillis 一个大于 0 的毫秒数,收集器尽可能的保证每次回收的时间不大于这个值。不能理解为这个值设置得越小越好,如果设置得太小,相比之下,收集会变得更频繁(之前 10 秒收集一次 100ms,现在 5 秒一次,一次 70ms),累积起来的总时间不一定会少,所以吞吐量不一定高。
  • GCTimeRatio 一个大于 0 小于 100 的整数,控制垃圾回收的时间不超过 1 /(1 + GCTimeRatio ),默认是 99 ,也就是默认的垃圾回收时间不超过 1%。

由于 Parallel Scavenge 是控制吞吐量的垃圾回收器,所以,还有一个参数 -XX:+UseAdaptiveSizePolicy,这个是虚拟机动态自动调整各个分区内存大小,GC 自适应的调节策略(GC Ergonomics)。就不需要设置新生代的大小,Eden 和 survivor 的比例,晋升老年代对象年龄了。只需要设置最大堆的大小,以及 MaxGCPauseMillis 或者 GCTimeRatio 即可。

4.4. CMS 收集器

CMS : Concurrent Mark Sweep,并发,标记-清理垃圾收集器,以减少用户执行线程停顿时间为目标。
CMS 是老年代垃圾收集器。
四个步骤:

  1. 初始标记(CMS initial mark):仅仅标记 GC Roots 能直接关联到的对象,很快。
  2. 并发标记(CMS concurrent mark):GC Roots Tracing,分析对象可达性,并发。
  3. 重新标记(CMS remark):修正并发标记阶段中,因为用户线程继续执行而导致标记产生变动的那一部分对象标记记录。时间比初始标记长,但是远比并发标记短。
  4. 并发清理(CMS concurrent sweep):并发清理
    其中,1,2,3 为标记阶段,4 为清理阶段。1,3 两步依然需要暂停用户线程(STW)。但是耗时操作的并发标记和并发清理是跟用户的执行线程并发执行。

优点:并发收集,低停顿。

缺点:

  1. 对 CPU 资源敏感。在并发标记和并发清理阶段,默认会占用一定的 CPU 资源。默认的并发线程数 = ( CPU + 3)/ 4。当 CPU 是 4 核时,并发线程数为 1,此时占用 CPU 25% 的资源,会随着 CPU 的数量增加,占用会下降。特别是在 CPU 核数为 2,是会占用 50% 的资源。虽然出了一个“增量式并发收集器”,目的是在并发标记和并发清理是,让用户线程和回收线程交替执行,减少资源占用但是表现一般。
  2. 无法处理浮动垃圾(Floating Garbage),出现在标记过后,清理过程中的垃圾,称为浮动垃圾,只能等待下一次 GC 才能回收。同时,由于在并发清理是,用户线程同时在运行,需要预留足够的内存给用户线程使用。所以,CMS 无法在内存快占满是开始进行回收,而是需要预留一部分空间给用户线程使用。JDK 1.5 默认是老年代内存使用超过 68% 就会开始进行回收。到 JDK 1.6 中,这个阀值已经提高到了 92%。可以通过 -XX:CMSInitiatingOccupancyFraction 来设置。如果设置太高,导致预留的内存无法满足用户线程需要,那么会出现一次“Concurrent Mode Failure”失败,这时候,虚拟机会启动后备预案:临时启用 Sirial Old 收集器会老年代进行收集,导致时间停顿会很长。所以,这个阀值不能设置得太高。
  3. 标记-清理导致的内存碎片。如果无法找到足够大的内存分配一个大对象,会出发 Full GC。-XX:UseCMSCompactAtFullCollection 参数默认开启,用于在 CMS 要进行 Full GC 时,开启碎片整理。碎片整理是不能并发的,所以,碎片问题解决了,但是,停顿时间变长了。-XX:CMSFullGCsBeforeCompaction 表示在进行了多少次不整理的 Full GC 后,执行一次整理的(默认 0 ,表示进入 Full GC 就需要内存整理)。

4.5. G1 收集器

特点:

  1. 并行与并发
  2. 分代收集(Young - Old)
  3. 空间整合,与 CMS 不同,从整体上看基于标记-整理算法,从局部(两个 Region)来看,基于复制算法实现。不会产生内存碎片。
  4. 可预测停顿,可以指定在一个长度为 M 毫秒的运行时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 针对的是整个堆内存,而不仅仅是新生代或者老年代。G1 将对内存分为多个大小相等的独立区域(Region)。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收获得的空间大小以及回收所需要的时间经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这个也就是 Garbage First 的由来)。

由于将内存划分为了很多小的 Region,如果对象的引用跨了 Region,或者老年代和新生代中的对象相互引用,如果在对象判断存活的时候,为了保证准确性,如何避免扫描整个堆呢?

Remembered Set 的作用,就是跨 Region 对象引用,新生代和老年代对象引用,在判断对象存活时避免进行全堆扫描。虚拟机发现程序在对 Reference 数据进行写操作时,会产生一个 Write Barrier 暂停中断写操作,检查 Reference 引用的对象是否处于不同的 Region(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象的 Region 的 Remembered Set 中。当进行可达性判断时,在 GC Roots 根节点枚举范围加上 Remembered Set 即可保证不进行全堆扫描。

不计算维护 Remembered Set 操作,G1 收集器的步骤:

  1. 初始标记:标记 GC Roots 直接关联的对象。并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象。这个阶段需要耗时很短的线程停顿。
  2. 并发标记:从 GC Roots 开始进行可达性分析,判断对象的存活,虽然耗时很长,但是可与用户线程并发执行。
  3. 最终标记:为了修改在用户线程并发执行中,导致标记产生变化的标记记录。虚拟机将这段时间内对象的变化记录存在 Remembered Set Logs 中。在这个阶段,需要将 Remembered Set Logs 中的数据 合并到 Remembered Set 中。这段时间需要用户线程停顿,但是可以并发执行。
  4. 筛选回收:对每个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

4.5.1. G1 中 Young 和 Old 大小

有三个量会影响到 Young Generation 大小:

  • -XX:G1NewSizePercent 初始 Young Generation 大小,也是 Young Generation 的最小大小,默认 5%
  • -XX:G1MaxNewSizePercent Young Generation 最大大小,默认 60%
  • -XX:MaxGCPauseMillis GC 最大停顿时间,默认 200ms

Young Generation 的大小只能在 -XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent 规定的范围内变化。

4.6. ZGC 收集器

参考:https://zhuanlan.zhihu.com/p/43608166

ZGC 标记-整理(marked-compact) GC,但是GC过程中所有的阶段都设计为可以并发的,包括移动对象的阶段,所以GC正常工作的时候除了会在自己的线程上吃点CPU之外并不会显著干扰应用的运行。

4.6.1. 着色指针

着色指针是一种将信息存储在指针(或使用Java术语引用)中的技术。因为在64位平台上(ZGC仅支持64位平台),指针可以处理更多的内存,因此可以使用一些位来存储状态。 ZGC将限制最大支持4Tb堆(42-bits),那么会剩下22位可用,它目前使用了4位: finalizable, remap, mark0 和 mark1。 我们稍后解释它们的用途。

着色指针的一个问题是,当您需要取消着色时,它需要额外的工作(因为需要屏蔽信息位)。 像SPARC这样的平台有内置硬件支持指针屏蔽所以不是问题,而对于x86平台来说,ZGC团队使用了简洁的多重映射技巧。

4.6.1.1. 多重映射

要了解多重映射的工作原理,我们需要简要解释虚拟内存和物理内存之间的区别。 物理内存是系统可用的实际内存,通常是安装的 DRAM 芯片的容量。 虚拟内存是抽象的,这意味着应用程序对(通常是隔离的)物理内存有自己的视图。 操作系统负责维护虚拟内存和物理内存范围之间的映射,它通过使用页表和处理器的内存管理单元(MMU)和转换查找缓冲器(TLB)来实现这一点,后者转换应用程序请求的地址。

多重映射涉及将不同范围的虚拟内存映射到同一物理内存。 由于设计中只有一个remap,mark0 和 mark1 在任何时间点都可以为1,因此可以使用三个映射来完成此操作。 ZGC源代码中有一个很好的图表可以说明这一点。

4.6.2. 读屏障

读屏障是每当应用程序线程从堆加载引用时运行的代码片段(即访问对象上的非原生字段non-primitive field):

1
2
3
4
5
6
void printName( Person person ) {
String name = person.name; // 这里触发读屏障
// 因为需要从heap读取引用
//
System.out.println(name); // 这里没有直接触发读屏障
}

在上面的代码中,String name = person.name 访问了堆上的person引用,然后将引用加载到本地的name变量。此时触发读屏障。 Systemt.out那行不会直接触发读屏障,因为没有来自堆的引用加载(name是局部变量,因此没有从堆加载引用)。 但是System和out,或者println内部可能会触发其他读屏障。

这与其他GC使用的写屏障形成对比,例如G1。读屏障的工作是检查引用的状态,并在将引用(或者甚至是不同的引用)返回给应用程序之前执行一些工作。 在ZGC中,它通过测试加载的引用来执行此任务,以查看是否设置了某些位。 如果通过了测试,则不执行任何其他工作,如果失败,则在将引用返回给应用程序之前执行某些特定于阶段的任务。

4.6.3. 执行阶段

4.6.3.1. 标记

现在我们了解了这两种新技术是什么,让我们来看看 ZGC 的 GC 循环。

GC循环的第一部分是标记。标记包括查找和标记运行中的应用程序可以访问的所有堆对象,换句话说,查找不是垃圾的对象。

ZGC的标记分为三个阶段。 第一阶段是STW,其中 GC roots 被标记为活对象。GC roots 类似于局部变量(包括全局变量,线程栈里面的对象指针,但不包括 GC 堆里的对象指针),通过它可以访问堆上其他对象。 如果一个对象不能通过遍历从 roots 开始的对象图来访问,那么应用程序也就无法访问它,则该对象被认为是垃圾。从 roots 访问的对象集合称为 Live 集。GC roots标记步骤非常短,因为roots的总数通常比较小。
zgc-mark

该阶段完成后,应用程序恢复执行,ZGC开始下一阶段,该阶段同时遍历对象图并标记所有可访问的对象。 在此阶段期间,读屏障针使用掩码测试所有已加载的引用,该掩码确定它们是否已标记或尚未标记,如果尚未标记引用,则将其添加到队列以进行标记。

在遍历完成之后,有一个最终的,时间很短的的Stop The World阶段,这个阶段处理一些边缘情况(我们现在将它忽略),该阶段完成之后标记阶段就完成了。

4.6.3.2. 重定位(整理)

GC 循环的下一个主要部分是重定位。重定位涉及移动活动对象以释放部分堆内存。 为什么要移动对象而不是填补空隙?(标记-整理和标记-清除的区别?) 有些 GC 实际是这样做的,但是它导致了一个不幸的后果,即分配内存变得更加昂贵,因为当需要分配内存时,内存分配器需要找到可以放置对象的空闲空间。 相比之下,如果可以释放大块内存,那么分配内存就很简单,只需要将指针递增新对象所需的内存大小即可。

ZGC将堆分成许多页面,在此阶段开始时,它同时选择一组需要重定位活动对象的页面。选择重定位集后,会出现一个 Stop The World 暂停,其中 ZGC 重定位该集合中 root 对象,并将他们的引用映射到新位置。与之前的 Stop The World 步骤一样,此处涉及的暂停时间仅取决于 root 的数量以及重定位集的大小与对象的总活动集的比率,这通常相当小。所以不像很多收集器那样,暂停时间随堆增加而增加。

移动 root 后,下一阶段是并发重定位。 在此阶段,GC线程遍历重定位集并重新定位其包含的页中所有对象。 如果应用程序线程试图在GC重新定位对象之前加载它们,那么应用程序线程也可以重定位该对象,这可以通过读屏障(在从堆加载引用时触发)实现,如流程图如下所示:
zgc
这可确保应用程序看到的所有引用都已更新,并且应用程序不可能同时对重定位的对象进行操作。

GC线程最终将对重定位集中的所有对象重定位,然而可能仍有引用指向这些对象的旧位置。 GC可以遍历对象图并重新映射这些引用到新位置,但是这一步代价很高昂。 因此这一步与下一个标记阶段合并在一起。在下一个GC周期的标记阶段遍历对象对象图的时候,如果发现未重映射的引用,则将其重新映射,然后标记为活动状态。

4.6.4. 总结

试图单独理解复杂垃圾收集器(如ZGC)的性能特征是很困难的,但从前面的部分可以清楚地看出,我们所碰到的几乎所有暂停都只依赖于GC roots集合大小,而不是实时堆大小。标记阶段中处理标记终止的最后一次暂停是唯一的例外,但是它是增量的,如果超过gc时间预算,那么GC将恢复到并发标记,直到再次尝试。

4.6.4.1. 标记(三个阶段):

  1. STW,根据 GC roots(不包括堆中的引用,仅仅是全局常量,线程栈中的对象引用,所以很小,很快)标记对象存活。
  2. 与用户线程并行,遍历所有对象,通过着色指针来标记对象是否标记过,没有标记的话加入队列等待标记。
  3. STW,处理边缘情况。

    4.6.4.2. 整理(重定向)

  4. STW,选择一组需要整理的对象页面集合(ZGC 将内存分为很多页面),重新映射该集合中 GC roots 对象到新的地址(跟 标记的 第一步一样,GC roots 很小,所以暂停很短)
  5. 并发整理(并发重定位)这组页面集合中所有的对象。

4.6.5. 未来的可能性

着色指针和读屏障提供了一些有趣的可能。

4.6.5.1. 多层堆和压缩

随着闪存和非易失性存储器变得越来越普遍,一种可能是JVM中允许多层堆,可以让很少使用的对象存储在较慢的存储层上。

该功能可以通过扩展指针元数据来实现,指针可以实现计数器位并使用该信息来决定是否需要移动对象到较慢的存储上。如果将来需要访问,则读屏障可以从存储中检索到对象。

或者对象可以以压缩形式保存在内存中,而不是将对象重定位到较慢的存储层。当请求时,可以通过读屏障将其解压并重新分配。

4.7. 阅读 GC 日志

4.8. 垃圾收集器参数总结

5. 内存回收与分配策略

Java 内存自动管理解决两个问题:给对象分配内存以及回收分配给对象的内存。
内存的分配,就是在堆上分配(也可能是 JIT 编译后被拆散为标量类型并间接的在栈上分配),对象的主要分配区域是新生代 Eden 区,如果启动了本地线程分配缓冲,将按照线程优先在 TLAB 上分配,少数情况也可直接分配在老年代中,分配规则并不是百分之百固定,主要取决于使用的那种垃圾回收器的组合,还有虚拟机的内存相关参数配置。

5.1. 对象优先在 Eden 分配

正常情况下,对象都是优先在 Eden 分配,如果 Eden 区域没有足够的区域分配时,会发起一次 Minor GC。

  • 新生代 GC(Minor GC):发生在新生代的 GC 动作,因为新生代的对象朝生夕灭,所以 Minor GC 很频繁,一般回收速度也会很快。
  • 老年代 GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,一般都会伴随着最少一次的 Minor GC(并非绝对,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程。),Major GC 一般比 Minor GC 慢 10 倍以上。

5.2. 大对象直接进入老年代

大对象是指需要大量连续的内存空间的 Java 对象。在 Java 开发中,应当尽量避免这种朝生夕灭的大对象。经常出现大对象,会导致内存还有不少空间时,就提前触发垃圾收集以获得足够多的连续空间来存放它们。
-XX:PretenureSizeThreshold 参数,如果对象大于这个参数的配置值,将直接在老年代分配。这样做避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

5.3. 长期存活对象进入老年代

新生代的对象,都有一个 Age, 没经历过一次 Minor GC,Age 增加一岁。当 Age 超过 -XX:MaxTenuringThreshold 参数的配置值(默认:15)时,将转移到老年代。

5.4. 动态对象年龄判断

如果在 单个survivor 空间中,相同年龄的所有对象大小的总和大于 单个Survivor 空间的一半,那么年龄大于或者等于该年龄的对象就可以直接进入老年代,无需达到 MaxTenuringThreshold 配置值的年龄。

5.5. 空间分配担保

在进行 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果满足,那么 Minor GC 可以确保是安全的,那么 Minor GC 就可执行。如果不满足,那么虚拟机会查看 PrintPromotionFailure 配置值是否允许担保失败。如果允许,那么会判断当前老年代最大可用连续空间,与历次晋升到老年代对象的平均大小比较,如果大于,尽管这次 Minor GC 有风险,还是会尝试一次 Minor GC。如果小于,或者 PrintPromotionFailure 配置不允许担保失败,那么这个时候要改为进行一个 Full GC.

新生代采用的是复制算法,为了提高内存的利用率,只使用了其中一个 Survivor 作为轮换备份,而且,这个 Survivor 的大小默认为新生代整个内存的 1/10,所以,当出现大量对象在 Minor GC 后依然存活的情况(最极端是 100% 存活),就需要老年代进行担保,把 Survivor 无法容纳的对象直接存入老年代。
但是在实际的 Minor GC 回收完成之前,并不知道会有多少对象存活,会进入老年代,所以,这里取了之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余容量比较,决定是否进行一次 Full GC 来让老年代腾出更多的空间。
如果某次 Minor GC 存活后的对象,远远高于平均值,会导致担保失败,导致一次 Full GC,虽然这样显得更麻烦,但是往往还是会把 PrintPromotionFailure 开关打开,避免频繁的 Full GC。

Just for my love !!