JVM基本原理:垃圾回收(上)

43 阅读7分钟

垃圾回收

引用计数法与可达性分析

垃圾指的是死亡的对象所占据的堆空间

如何辨别一个对象是存是亡?

  • 引用计数法

    • 为每个对象添加一个引用计数器,统计指向此对象的引用个数,一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。
    • 它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器
    • 除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
    • 举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况

      下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这

      两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。

  • 可达性分析

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

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

      • Java方法栈帧的局部变量
      • 已加载类的静态变量
      • JNI handles
      • 已启动且未停止的Java线程
    • 在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

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

Stop-the-world,安全点

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

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

一种比较另类的解释:安全词。一旦垃圾回收线程喊出了安全词,其他非垃圾回收线程便会一一停下。

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

image.png

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

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

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

image.png - 为什么不在每条机器码基本块处插入安全点检测呢?

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

    -   JIT生成的机器码打乱了原本栈帧上对象分布状况,在进入安全点时,机器码还需提供一些额外信息表明哪些寄存器,或者当前栈帧上哪些内存空间存放着对象的引用,方便垃圾回收器能够枚举GC Roots

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

    -   不过,不同的即时编译器插入安全点检测的位置也可能不同。以 Graal 为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。

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

垃圾回收三种方式

  1. 清除(sweep),把死亡对象所占空间标记为空闲内存,并记录在一个空闲列表(free list)中,需要新建对象时,内存管理模块便会空闲列表找空闲空间,划分给新建对象

image.png

2个缺点:

1.  一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
1.  分配效率低,如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
  1. 压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

image.png

  1. 复制(copy),即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。

image.png

实践

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

package com.test;
// time java SafepointTestp
//你还可以使用如下几个选项
// -XX:+PrintGC
// -XX:+PrintGCApplicationStoppedTime
// -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();
    }
}