Java 垃圾收集

每一次重新阅读,都有新的收获,也将过去这段时间以来一些新的零散的知识点串联在一起。
沿着周志明老师的行文脉络,了解问题发生的背景,当时的人如何思考,提出了哪些方案,各自有什么优缺点,附带产生的问题如何解决,理论研究如何应用到工程实践中,就像真实地经历一段研发历史。这让人对垃圾收集的认识不再停留在记忆上,而是深入到理解中,相关的知识点不再是空中楼阁,无根之水,而是从一些事实基础和问题自然延申出来。
尽管在更底层的实现上仍然缺乏认识和想象力,以至于在一些细节上还是疑惑重重,但是仍然有豁然开朗的感觉呢。比如以前看不同垃圾收集器的过程示意图如鸡肋,如今看其中的停顿和并发,只觉充满智慧。

概述

垃圾收集(Garbage Collection,简称 GC)需要考虑什么?

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

为什么要去了解垃圾收集和内存分配?
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化技术”实施必要的监控和调节。

在 Java 中,垃圾收集需要关注哪些内存区域?
程序计数器、虚拟机栈和本地方法栈,随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着入栈和出栈操作,每个栈帧中分配多少内存可以认为是编译期可知的,因此这几个区域地内存分配和回收具备确定性。
但是 Java 堆和方法区这两个区域则有显著的不确定性,只有运行期间,我们才知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

哪些内存需要回收?

哪些对象是还存活着,哪些已经死亡?

对象死亡即不可能再被任何途径使用。其实曾经的我会怀疑,遗落在内存中的对象,真的没有办法“魔法般地”获取其引用地址吗?引用变量的值不就是 64 位的数字吗?

引用计数算法(Reference Counting)

优点:

  • 原理简单
  • 判定效率高

缺点:

  • 例外情况多,需要额外处理(比如循环引用)

提及引用计数算法,人们好像认定它无法应对循环引用因而被抛弃。虽说 Java 虚拟机中没有选用它,但是在其他计算机领域有所运用。循环引用也并非它绕不过去的难题,事实上,跨代引用问题中,老年代引用新生代形成的引用链不是也可能是一个尚未回收的孤岛吗?

可达性分析算法(Reachability Analysis)

  • 选取一系列称为“GC Roots”的根对象作为起始节点集。
  • 根据引用关系向下搜索。
  • 如果某个对象到 GC Roots 间没有任何引用链相连,即该对象不可能再被使用。用图论的话说,就是 GC Roots 到该对象不可达。

那么可作为 GC Roots 的对象有哪些呢?
固定的 GC Roots,主要是在全局性引用和执行上下文中:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的变量。
  3. 在方法区中类常量引用的对象,比如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中 JNI,即 Native 方法引用的对象。
  5. Java 虚拟机内部的引用,如基本类型的 Class 对象,常驻的异常类型,还有系统类加载器。
  6. 所有被同步锁(synchronized)持有的对象
  7. 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等(不懂)。

临时性的GC Roots:
除了固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入。

比如,当针对新生代发起垃圾收集时,如果老年代对象引用了它,那么被引用的对象就不应该被回收,尽管老年代对象可能已经不可达。为此,老年代对象需要临时性加入 GC Roots 集合。
当然,为了避免将所有老年代对象加入 GC Roots 集合这样一看就很不合理的操作,会做一些优化处理。

“引用”的概念扩充

对于判断对象是否存活而言,“引用”的重要性不言而喻。但是如果对象只有“被引用”和“未被引用”两种状态,对于描述一些“内存足够就保留,内存不足就抛弃”的对象就显得无能为力。
缓存系统就是这样的一个典型应用场景。当内存充足时,就保留作为缓存;当内存不足时,就抛弃腾出空间给其他资源。

曾经有一位热衷实践技术的同事就和我介绍了他在项目中使用弱引用实现的缓存模块,当时我还不太理解他为何这样做。事实上,享受自动垃圾收集的我并不能在一开始就敏锐地把握到对象在应用程序中的创建、存活和消亡过程。
当然我们并不推荐自己实现基于 JVM 的缓存系统,事实上他之所以提及,正是因为出了 bug。

引用的分类

  • 强引用(Strongly Reference),只要强引用还在,绝不会回收。
  • 软引用(Soft Reference),只被软引用关联的对象,在系统发生 OOM 前,会被列入回收范围进行第二次回收。
  • 弱引用(Weak Reference),只被弱引用关联的对象,只能生存到下一次垃圾收集发生为止,无论内存是否 足够,都会回收。
  • 虚引用(Phantom Reference),一个对象是否有虚引用,不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的唯一目的就是为了在对象被回收时收到一个系统通知。

虚引用的一个经典应用是是 ByteBuffer 对象被回收时自动释放直接内存。

弱引用的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ReferenceTest_3 {
private static final int _4MB = 4 * 1024 * 1024;

// -Xmx20m -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws IOException {
// list -> WeakReference -> byte[]
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
System.out.print(list.size() + " ");
for (WeakReference<byte[]> w : list) {

System.out.print(w.get() + " ");
}
System.out.println();
}
System.out.println("循环结束: " + list.size());
System.in.read();
}
}

在测试中,minor GC 并没有回收掉全部的只被弱引用关联的对象,full GC 才全部回收掉,我一度以为关于弱引用的表述不正确。后来进一步测试发现,是因为部分对象直接分配在老年代。因此更准确的表述是,每一次 GC 都会回收所在发生区域里只被弱引用关联的对象。
这是一个有趣的经验,让我对部分垃圾收集中的“部分”二字有更深刻的体会,原来非收集区域的对象真的对发生在其他区域的垃圾收集无感。

了解为什么扩充引用的概念,让人对引用的分类豁然开朗。我的脑海里情不自禁冒出了不太恰当的比喻:一个城市里的公民被区分了等级,一等公民(强)永远不会被强行驱逐;二等公民(软)在城市资源紧张时会被强行驱逐;三等公民(弱)被认为影响市容市貌,一旦有整顿就会被强行驱逐;一等公民里有一些需要被监视,一旦离开,会触发一个事件。

finalize 方法

有趣的知识点,无趣的面试考点。

方法区的垃圾回收是什么样的?

  • 《Java虚拟机规范》中提到可以不要求虚拟机在方法区实现垃圾收集
  • 确实有未实现或未完整实现方法区类型卸载的收集器
  • 原因是方法区垃圾收集的性价比通常比较低

方法区的垃圾收集主要回收两部分:

  • 废弃的常量
  • 不再使用的类型

如何判定一个常量是否废弃?
没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。
如果这时发生垃圾回收,而且垃圾收集器判断确实有必要,才会将“java”常量清理出常量池。

“虚拟机中也没有其他地方引用这个字面量”怎么理解?

如何判定一个类型是否可卸载?

  • 该类的所有的实例都已经被回收
  • 加载该类的类加载器已经被回收
  • 该类对应的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是
和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息。

条件二如此苛刻,系统类加载器不会被回收,是否意味着正常的应用程序,类一旦加载就不会卸载?
“无法在任何地方通过反射访问该类的方法”是否多余,Method 对象不是引用了 Class 对象吗?
Class 对象没有被引用时,会被回收吗?
卸载类是指回收 Class 对象加上清理方法区中的类的信息(怎么样的存储结构呢)吗?

如何回收:垃圾收集算法

分类:

  • 引用计数式垃圾收集(Reference Counting GC)
  • 追踪式垃圾收集(Tracing GC)

分代收集理论

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

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(String Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。
正因为有了区域划分,垃圾收集器才可以每次只回收一个或某些部分的区域,因而才有了“Minor GC”、“Major GC”和“Full GC”等回收类型划分;针对不同区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,因而才发展出“标记-复制”、“标记-清除”和“标记-整理”等垃圾收集算法。

了解分代收集理论,分代收集算法更显得有理有据。

一般把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。

针对不同分代的垃圾收集分类:

  • 部分收集(Partial GC):目标不是完整收集整个堆、
    • 新生代收集(Minor GC/Young GC)
    • 老年代收集(Major GC/Old GC):只有 CMS。Major GC有些混淆,应按上下文注意是老年代收集还是整堆收集。
    • 混合收集(Mixed GC):只有 G1
  • 整堆收集(Full GC)

区域划分引起另一个问题,跨代引用。这个问题在前文的 GC Roots 选择时也提到过。
根据前两条假说可逻辑推理得出隐含推论:存在相互引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

  1. 跨代引用假说(Integenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们不应再为少量的跨代引用而去扫描整个老年代。那么怎么处理跨代引用呢?在后面 HotSpot 的实现细节中我们再提。

标记-清除算法(Mark Sweep)

在 1960 年由 Lisp 之父 John McCarthy 提出,分为标记和清除两个阶段。
缺点:

  • 执行效率不稳定,对象越多,且需要回收的对象越多,效率会降低。
  • 内存空间碎片化,长时间后没有足够的连续内存分配大对象。

标记-复制算法

为解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出“半区复制”(Semispace Copying)。将内存划分为大小相等的两块,每次只使用一块,用完的时候,复制存活的对象到另一块。

优点:

  • 存活对象少时,仅需复制少量对象。
  • 不存在内存空间碎片

缺点:

  • 空间浪费

适用场景:

  • 新生代。

怎么减少空间的浪费呢?
IBM 公司研究表明新生代中 98% 的对象熬不过第一轮收集。在 1989 年,Andrew Appel 针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”。具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。
HotSpot 虚拟机默认 Eden:Suvivor 为 8:1。

在测试中,尽管 survivor 有空间,仍然只在 Eden 空间进行分配。

如果 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时怎么办?
罕见情况的“逃生门”——使用其他区域(通常是老年代)进行分配担保(Handle Promotion)。

标记-整理算法(Mark Compact)

标记-复制算法面对对象存活率较高的情况,效率会降低;更关键的是,它需要额外空间进行分配担保。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”算法,让所有存活对象都向内存空间一端移动。

移动和复制的开销有什么差距吗?撇开分配担保问题,大量复制和大量移动是类似的把?

移动对象的弊端

  • 大量对象存活的话需要大量移动对象,负担重。在以前,对象移动操作必须全程暂停用户应用程序才能进行(STW,Stop The World)。
  • 不整理内存碎片的话,需要依赖更复杂的内存分配器和内存访问器解决。内存访问最频繁,反而影响吞吐量。

不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。在这点上的区别分别演变出两种发展方向,低延迟和高吞吐量。

书中提到的内存分配器和内存访问器解决的是在碎片化的内存空间中进行内存分配,还是可以将大对象分散地存储到碎片化的空间中呢?还提到硬盘存储大文件不要求物理连续的磁盘空间以及内存访问环节的额外负担,应该是指一种分散存储的实现方案吧?

对于 CMS 的“和稀泥”解决方案,暂时容忍内存碎片,直到影响对象分配时再采用标记-整理算法收集一次,如果抛开备用的 Serial Old 单线程的效率问题不谈,除非在碎片化的内存空间中分配和访问内存在效率上低于在整理后的内存空间中,要不然“和稀泥”这种懒惰式的处理方案理论上效率更高吧?

HotSpot 的算法细节实现

根节点枚举

对象浩如烟海,怎么实现高效查找根节点的呢?

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。这是因为根节点枚举如果不在一个一致性快照中进行,准确性无法保证。

Exact VM 类型的虚拟机使用的是准确式垃圾回收,当用户线程停顿后,不需要一个不漏地检查完所有执行上下文和全局的引用位置,就有办法直接得到哪些地方存放着对象引用。HotSpot 的具体解决方案是使用一组称为 OopMap(Ordinary Object Pointer Map) 的数据结构。

  • 一旦类加载动作完成,HotSpot 会把对象内什么偏移量上是什么类型的数据计算出来。
  • 在即时编译过程中,也会在特定位置记录下栈和寄存器里什么位置是引用。

看书时,在这个地方其实有很多困惑。对于 OopMap,就像知道了一个不太懂的东西,了解了它能做什么,最明确的是,在即时编译中,会在安全点位置生成 OopMap。

如何识别数据类型

首先,我困惑的是,非准确式垃圾回收是什么东西,要找到对象引用得怎么做?
书中提到,准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中有一个 32 位的整数 123456,虚拟机将有能力分辨出它到底是一个指向了 123456 的内存地址的引用类型还是一个数值为 123456 的整数。
Exact VM 抛弃掉以前 Classic VM 基于句柄(Handle)的对象查找方式,因为在垃圾收集后对象可能被移动,如果地址改变(123456->654321),在没有明确信息表明内存中哪些数值式引用类型的情况下,虚拟机肯定不能把所有123456的值改为654321,所以要使用句柄来保持引用值得稳定。

看到这个举例之后容易理解多了,可能是没有手动管理内存的经验,对这方面体会不深刻。

以栈为例,栈帧里装有 int、double 等类型的数值,也有引用类型变量的地址,这些值在保守式 GC 看无法直接分辨是数值还是引用。但保守式 GC 其实还是有一定的分辨能力:

  1. 是不是正确的对齐值(可以理解为是一个正确的指针的值)
  2. 是否指向堆内的地址
  3. 是否指向对象的头(从这点上看,虚拟机是会尝试获取对象头校验一下吗?)

但是根据这些规则进行检测还是不够的,比如某一个 int 的值刚好指向某个对象的起始地址,就恰好满足上述的所有条件,这样就可能造成该对象被错误识别为存活对象,这个现象称为对堆的压迫。
保守式 GC 的“保守”二字体现在它将可疑的根看作是指针进行保守地处理。

死亡的对象被错误保留下来,挤占了堆空间,称其为“压迫”还听形象的。这个“保守”在理解以后才不感觉违和。

OopMap 的类型

  • 类型信息里记录了自己的 OopMap,这应该对应的是“一旦类加载动作完成,HotSpot 会把对象内什么偏移量上是什么类型的数据计算出来”,从对象向它引用的对象查找时使用。
  • 被 JIT 编译后的指令流,也会在特定的位置(安全点)记录下 OopMap。
  • 奇怪的是,暂时没有找到明确指出在 Java 字节码的特定位置(安全点)也记录着 OopMap。

方法区的类静态属性和类常量也是根据类型信息里的 OopMap 查找的吗?

安全点

为什么引入安全点,它解决了什么问题?

在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots。但是非常多的指令可能导致 OopMap 的内容发生变化。

书中“可能导致引用关系变化……的指令非常多”这句话让人困惑,OopMap 不是为了定位哪里是引用而非引用指向哪吧?如果只看“导致 OopMap 内容变化的指令非常多”更易理解。

在哪里生成 OopMap 呢?
既然指令会导致 OopMap 内容发生变化,最简单粗暴的就是为每一条指令生成 OopMap,但是这样会空间成本会急剧上升。
HotSpot 只在特定的位置生成 OopMap,这些位置被称为“安全点”(Safepoint)。
安全点的设定决定了并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是要求必须到达安全点才能够暂停。

安全点怎么选择呢?

  • 不能太少以至于让收集器等太久
  • 不能太频繁以至于过分增大运行时的内存负担

安全点的位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的:

  • 所有的非计数循环的末尾
  • 方法返回之前/调用方法的 call 指令后
  • 每条 Java 编译后的字节码的边界(不理解)
  • 可能抛异常的地方

有没有权威的标准描述啊。

如何在垃圾收集发生时,让所有线程(不包括 JNI 调用的线程)都跑到最近的安全点,然后停顿下来呢?

  • 抢先式中断(Preemptive Suspension),系统先中断全部用户线程,如果有用户线程中断的地方不是安全点,就恢复该线程执行,再中断,直到跑到安全点。(没人选)
  • 主动式中断(Voluntary Suspenstion),设置一个标志位,线程执行时不停主动轮询,发现中断标志为真就在最近的安全点主动挂起。

这个问题和如何通知多线程一起做一件事很像,设置标志位,再让线程运行过程中轮询。

什么时候进行轮询?
轮询标志的地方与安全点是重合的,另外还要加上创建对象和其他需要在堆上分配内存的地方,这样可以检查是否即将发生垃圾收集,避免没有足够内存分配给新对象。

仔细一想,与安全点重合似乎是理所当然,额外选取的地方也有恰当的理由。

轮询在印象里都意味着额外开销,虚拟机如何应对?
HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。

很遗憾,看懂又不懂,总之,轮询操作优化得很高效。

安全区域

为什么引入安全区域,它解决了什么问题?

安全点机制保证了程序执行时,在不太长的时间内,就会遇到可进入垃圾收集过程的安全点,但是如果程序“不执行”的时候,比如线程处于 Sleep 或者 Blocked 时,就无法通过轮询获知中断标识。对于这种情况,引入了安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。安全区域可以看作是扩展拉伸了的安全点。

  1. 当用户线程执行到安全区域里的代码,首先会标识自己进入了安全区域
  2. 虚拟机发起垃圾回收时,不去管这些写线程
  3. 当线程要离开安全区域,会检查虚拟机是否已完成根节点枚举(或者其他需要暂定用户线程的阶段),看起来是否处于用户线程暂停是可以检测的
  4. 如果已完成,继续;如果未完成,等待至收到可以离开安全区域的信号

不去管处于安全区域的线程的意思是什么,不是线程主动轮询是否中断的标志吗?这个标志不是全局共享的吗,是虚拟机为各个线程单独设置的?

记忆集和卡表

为什么引入记忆集,它解决了什么问题?

为了解决对象跨代引用所带来的问题,比如在标记新生代时,避免将整个老年代的对象都加入 GC Roots 进行扫描。

这个问题起初让我很迷惑,直到我认识到“老年代里有一些已经不可达但还没回收的对象,它们可能引用了新生代里的对象”这个现象,我在想我可能理解了跨代问题。但是此时,我又迟疑了,如果引用新生代对象的老年代对象已经不可达,回收掉就好了,如果老年代对象可达,那么从固定的 GC Roots 一定可以找到老年代对象,再找到新生代对象。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
如果不考虑效率和成本,最简单的方法可以用非收集区域中所有含跨代引用的对象数组来实现。

设计者通常选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,比如:

  • 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里包含跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡表(Card Table)是记忆集的一种具体实现。
卡表最简单的形式可以只是一个字节数组,HotSpot 虚拟机也是这样做的。

1
CARD_TABLE[this address >> 9] = 0;

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在跨代引用指针,就将卡表的数组元素的值标识为 1,成该元素变脏(Dirty)。

这是否以为着卡表长度为 (老年代容量 / 512),这内存占用还是不低欸。

区域内怎么判断出对象的开始和结束位置呢?虚拟机可以通过一个地址,知道该地址是一个对象的起始地址吗?

写屏障

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

卡表元素何时变脏的答案是明确的——有其他分代区域中对象引用本区域对象时,其对应的卡表元素就应该变脏。

问题是如何在对象赋值的那一刻去更新维护卡表呢?
在解释执行字节码的情况中,虚拟机有充分的介入空间;但是在编译执行的场景中,即时编译后的代码已经是纯粹的机器指令流了。
HotSpot 虚拟机是通过写屏障(Write Barrier)技术维护卡表状态,在机器码层面介入每一个赋值操作。

又是一个看懂又不懂的事情,很多资料里尝试用伪代码表达,但是我很想知道它是怎么实现在机器码层面介入每一个赋值操作,是在 JVM 源码中用 C++ 实现的吗?是在解释器和 JIT 编译器共同支持下,在机器码层面加入了代码片段吗?
总之,理解为引用字段赋值的 AOP 切面吧。

  • 写前屏障(Pre-Write Barrier),直到 G1 收集器才使用到。
  • 写后屏障(Post-Write Barrier)。
1
2
3
4
5
6
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 个卡表元素共享一个缓存行,对应的卡页总内存为 32 KB,如果不同线程更新的对象都在这 32 KB 的内存区域内,就会导致更新卡表时发生伪共享问题。
一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有未被标记才标记为变脏。

在 JDK 7 后,新增 -XX:+UseCondCardMark 参数决定是否开启卡表更新的条件判断。开启后增加一次额外的判断开销,但能够避免伪共享问题。

并发的可达性分析

从 GC Roots 再继续向下遍历对象图,停顿时间必然与堆容量成正比。如果能够削减这部分的停顿时间,对于所有使用追踪式垃圾收集算法的收集器而言,“标记”阶段都能收益匪浅。

为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
三色标记(Tri-color Marking),扫描过程就想灰色波峰从黑向白推进。
白色:表示对象尚未被垃圾收集器访问到。
黑色:表示对象已经被垃圾收集器访问到,且这个对象的所有引用都已经扫描过了。
灰色:表示对象已经被垃圾收集器访问到,但这个对象上至少有一个引用还没有被扫描过。

如果用户线程是冻结的,那么不会有问题。如果用户线程在收集过程中修改了引用关系,就会出现两种后果:

  • 把原本消亡的对象,错误标记为存活。(尚可以容忍,只是产生了浮动垃圾,下次收集即可)
  • 把原本存活的对象,错误标记为死亡。这是不允许发生的。

Wilson 于 1994 年在理论上证明,当且仅当以下两个条件同时满足,会产生“对象消失”的问题:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
  • 赋值器删除了全部从灰色到白色对象的直接或间接引用。

第二个条件的描述需要很精确,不能忽略关键字眼。

要解决并发扫描时的对象消亡问题,只需要破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:

  • 增量更新(Incremental Update),当黑色对象插入新的指向白色对象的引用时,记录,在并发扫描结束后,以这些黑色对象为根,重新扫描。
  • 原始快照(Snapshot At The Beginning,SATB),当灰色对象要删除指向白色对象的引用关系时,记录,在并发扫描结束后,以这些灰色对象为根,重新扫描。

和破坏死锁的思路类似。

不太理解“以这些灰色对象为根,重新扫描”,反而是“按照扫描的那一刻的对象图进行扫描”更容易理解。
应该是以这些灰色对象为根,根据记录的被删除引用,继续扫描。把继续扫描到的对象,统统视为存活对象。
理解起来有点绕,因为第一次扫描是沿着引用链进行,删除引用后,确实没办法继续往下。但是引用关系的变化通过写屏障记录了下来。第二次扫描需要 STW。
如果没有理解错的话,这时候会产生一种错误将白标记为黑的浮动垃圾;第一种方案不会出现该情况;但两种方案都不能避免已标记为黑的对象在并发时成为浮动垃圾。

理解了这部分,再看垃圾收集器的过程示意图中的停顿和并发,不再枯燥反觉有趣了。

其实我不太理解

参考文章

  1. 《深入理解Java虚拟机》
  2. JVM中的OopMap
  3. 图解 OopMap、Safe Point、Safe Region
  4. 详解GC(一)理论篇