垃圾回收-基础知识详解

67 阅读13分钟

垃圾回收-基础知识详解

在介绍垃圾收集器之前向大家先介绍几个基础概念:

  1. 引用计数法和可达性分析
  2. STW及安全点
  3. 垃圾收集算法

一、引用计数法和可达性分析

引用计数法(reference counting)。它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了。

它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象

举个例子,假设对象 a与b相互引用,除此之外没有其他引用指向 a或者b。在这种情况下,a和b实际上已经死了,但由于它们的引用计数器皆不为0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。

循环引用与可达性分析示例.webp

目前Java虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

那么什么是GC Roots呢?我们可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots包括(但不限于)如下几种:

  1. Java方法栈帧中指向堆对象的引用,即当前所有正在被调用方法的引用类型参数、局部变量等
  2. 类的引用类型静态变量,这里指的是引用类型,像int等基本数据类型的静态变量肯定不能作为GC Roots。
  3. JNI handles(Java Native Interface)包括JNI Local Handles和JNI Global Handles
  4. 已启动且未停止的Java线程
  5. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象a和b相互引用,只要从GC Roots出发无法到达a或者b,那么可达性分析便不会将它们加入存活对象合集之中。

虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。

比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。

误报并没有什么伤害,Java虚拟机至多损失了部分垃圾回收的机会,下次垃圾回收再回收就行。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致Java虚拟机崩溃。

二、Stop-the-world 以及 安全点

怎么解决上述问题?在Java虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。

Java虚拟机中的Stop-the-world是通过安全点(safepoint)机制来实现的。当Java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作。

当然,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。

举个例子,当Java程序通过JNI执行本地代码时,如果这段代码不访问Java对象、调用Java方法或者返回至原Java方法,那么Java虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。

只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于Java虚拟机线程调度器的掌控之下,因此属于安全点。

其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。

对于解释执行来说,字节码与字节码之间皆可作为安全点。Java虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。

执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受Java虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。

安全点检查其实就是一种中断信号,这个信号需要线程响应,比如说可以设置一个标志位,线程需要检测这个标志位是否被打开了,如果线程发现标志位被打开了,那么就需要阻塞自己。

那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个。

  1. 安全点检测本身也有一定的开销。不过HotSpot虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个segfault处理器,来截获因访问该不可读内存而触发segfault的线程,并将它们挂起。

  2. 即时编译器生成的机器码打乱了原本栈桢上的对象分布状况(我会再小结里解释)。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举GC Roots。

由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。

不过,不同的即时编译器插入安全点检测的位置也可能不同。以Graal为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。

其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。

不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。除了垃圾回收之外,Java虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。

小结

安全点是JVM中的一个稳定的执行状态 ,在这个状态下:

  • Java虚拟机的 堆栈不会发生变化
  • 垃圾回收器能够"安全"地执行可达性分析
  • 所有对象的引用关系都是确定和稳定的
不同执行状态的安全点处理

1. 线程阻塞状态

  • 已经处于安全点,无需额外处理 2. 解释执行字节码
  • 每条字节码之间都可以作为安全点
  • 当有安全点请求时,执行一条字节码就进行一次检测 3. 执行即时编译器生成的机器码
  • 最复杂的情况 ,因为机器码直接运行在硬件上,不受JVM控制
  • HotSpot的解决方案:在特定位置插入安全点检测
    • 方法出口 处
    • 非计数循环的循环回边 处 4. JNI本地代码执行
  • 如果不访问Java对象、不调用Java方法,可以继续执行,这段本地代码本身就是一个安全点。
安全点检测的优化

为什么不在每条机器码都插入检测?

  1. 性能开销 :虽然HotSpot已将检测简化为一个内存访问操作,但频繁检测仍有开销
  2. 额外信息存储 :机器码打乱了原本的对象分布,需要额外信息来标明:
    • 哪些寄存器存放着对象引用
    • 当前栈帧上哪些内存空间存放着对象引用
    • 这些信息帮助垃圾回收器枚举GC Roots
安全点的实现机制

HotSpot使用了巧妙的内存保护机制:

  • 将安全点检测简化为内存访问操作
  • 有安全点请求时,将检测访问的内存页设置为不可读
  • 定义segfault处理器,截获访问不可读内存的线程并挂起 通过这种方式,JVM能够在可接受的性能和内存开销下,确保垃圾回收的正确性和相对较短的暂停时间。

安全点机制不仅用于垃圾回收,还用于其他需要堆栈内容一致性的JVM操作。


下面是对"机器码打乱了原本的对象分布"这个概念进行解释:

原本的对象分布(字节码执行时)

在解释执行字节码时,JVM有明确的栈帧结构:

  • 局部变量表:按照固定顺序存储方法参数和局部变量
  • 操作数栈:临时存储计算中间结果
  • 对象引用位置固定:JVM清楚地知道哪个槽位存储的是对象引用

例如,在字节码中:

slot 0: this引用
slot 1: 参数obj1
slot 2: 局部变量obj2
操作数栈顶: 临时对象引用

即时编译后的"打乱"

当即时编译器(如C1、C2)将字节码编译成机器码时,会进行大量优化:

1. 寄存器分配优化

  • 原本在栈帧slot 1的对象引用可能被分配到CPU寄存器R1
  • 原本在slot 2的引用可能被分配到寄存器R5
  • 位置完全改变了

2. 指令重排序

  • 编译器可能调整指令执行顺序来提高性能
  • 原本按顺序的变量访问可能被重新安排

3. 内联和展开

  • 方法内联后,多个方法的局部变量混合在一起
  • 循环展开后,循环变量的位置发生变化

4. 死代码消除和常量折叠

  • 一些原本的变量可能被优化掉
  • 一些引用可能被直接替换为常量

具体例子

字节码阶段:

栈帧布局:
[slot 0: this]
[slot 1: obj1]
[slot 2: obj2]
[操作数栈: temp]

机器码阶段:

寄存器分配:
RAX: obj2 (原来在slot 2)
RBX: this (原来在slot 0)
RCX: 其他数据
RDX: obj1 (原来在slot 1)
栈偏移+8: temp (原来在操作数栈)

为什么需要额外信息?

当垃圾回收器需要进行可达性分析时,它必须知道:

  • 哪些寄存器当前存储着对象引用
  • 栈帧的哪些位置存储着对象引用
  • 这些引用的确切类型是什么

但是机器码执行时,这些信息已经"丢失"了:

  • CPU寄存器对JVM来说是"黑盒"
  • 栈帧布局完全改变
  • 没有类型信息

解决方案:OopMap

即时编译器会生成OopMap(Ordinary Object Pointer Map):

安全点位置: method_exit_123
OopMap信息:
- 寄存器RAX: 对象引用,类型String
- 寄存器RBX: 对象引用,类型MyClass
- 栈偏移+16: 对象引用,类型ArrayList
- 寄存器RCX: 非引用数据

这样垃圾回收器就能正确识别所有的GC Roots,确保可达性分析的准确性。

总结:"打乱对象分布"指的是即时编译优化改变了对象引用的存储位置和方式,需要额外的OopMap信息来帮助垃圾回收器正确识别这些引用。


实践

你可以体验一下无安全点检测的计数循环带来的长暂停。你可以分别测单独跑foo方法或者bar方法的时间,然后与合起来跑的时间比较一下。

// time java SafepointTestp
// 你还可以使用如下几个选项
// -XX:+PrintGC -XX:+PrintGCApplicationStoppedTime -XX:+PrintSafepointStatistics -XX:+UseCountedLoopSafepoints
// -XX:+PrintGCApplicationStoppedTime   打印应用线程因GC和其他操作而停止的总时间
// -XX:+PrintSafepointStatistics 打印详细的安全点统计信息
// -XX:+UseCountedLoopSafepoints   开启循环安全点检测
public class SafepointTest {
  static double sum = 0;

  public static void foo() {
    for (int i = 0; i < 0x77777777; i++) {
      sum += Math.sqrt(i);
    }
  }

  public static void bar() {
    for (int i = 0; i < 50_000_000; i++) {
      new Object().hashCode();
    }
  }

  public static void main(String[] args) {
    new Thread(SafepointTest::foo).start();
    new Thread(SafepointTest::bar).start();
  }
}

这是开启了循环安全点检测结果:

开启循环安全点检测.png

这是关闭了循环安全点检测结果:

关闭循环安全点检测.png

从结果可以明显看到关闭循环检测下:

  1. foo方法应用线程停顿时间延长到将近3s。
  2. vmop虚拟机操作时常也都延长了。

注:例子里的foo方法中的for循环,其中i变量类型我从int型改成long型后,长暂停的现象不存在了?

这是C2一个诡异的地方。

for (int i=start; i<limit; i++) {..}

对于int类型的循环变量i,如果满足

  1. 基于该循环变量的循环出口只有一个,即i < limit,
  2. 循环变量随着迭代的增量为常数,例子中i++即增量为1,以及循环变量的上限(当增量为负数时则是下限)为循环无关的,即limit应是循环无关,那么C2会将其判断成计数循环(counted loop),然后默认不插入safepoint。

而对于long类型的循环变量,C2直接识别为非计数循环,需要插入safepoint。