乐趣区

关于程序员:浅析垃圾回收

大家好,我是易安!

Java 虚拟机的主动内存治理,将本来须要由开发人员手动回收的内存,交给垃圾回收器来主动回收。不过既然是主动机制,必定没法做到像手动回收那般精准高效,而且还会带来不少与垃圾回收实现相干的问题。明天咱们来简略分析下垃圾回收的概念。

援用计数法与可达性剖析

垃圾回收,顾名思义,便是将曾经调配进来的,但却不再应用的内存回收回来,以便可能再次调配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便波及了一个要害的问题:如何分别一个对象是存是亡?

咱们先来讲一种古老的分别办法:援用计数法(reference counting)。它的做法是为每个对象增加一个援用计数器,用来统计指向该对象的援用个数。一旦某个对象的援用计数器为 0,则阐明该对象曾经死亡,便能够被回收了。

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

除了须要额定的空间来存储计数器,以及繁琐的更新操作,援用计数法还有一个重大的破绽,那便是无奈解决循环援用对象。

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

目前 Java 虚拟机的支流垃圾回收器采取的是可达性剖析算法。这个算法的本质在于将一系列 GC Roots 作为初始的存活对象合集(live set),而后从该合集登程,摸索所有可能被该汇合援用到的对象,并将其退出到该汇合中,这个过程咱们也称之为标记(mark)。最终,未被摸索到的对象便是死亡的,是能够回收的。

那么什么是 GC Roots 呢?咱们能够临时了解为由堆外指向堆内的援用,一般而言,GC Roots 包含(但不限于)如下几种:

  1. Java 办法栈桢中的局部变量;
  2. 已加载类的动态变量;
  3. JNI handles;
  4. 已启动且未进行的 Java 线程。

可达性剖析能够解决援用计数法所不能解决的循环援用问题。举例来说,即使对象 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 的 API 来实现上述三个操作,因而 Java 虚拟机仅需在 API 的入口处进行平安点检测(safepoint poll),测试是否有其余线程申请停留在平安点里,便能够在必要的时候挂起以后线程。

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

其余几种状态则是运行状态,须要虚拟机保障在可预感的工夫内进入平安点。否则,垃圾回收线程可能长期处于期待所有线程进入平安点的状态,从而变相地进步了垃圾回收的暂停工夫。

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

执行即时编译器生成的机器码则比较复杂。因为这些代码间接运行在底层硬件之上,不受 Java 虚拟机掌控,因而在生成机器码时,即时编译器须要插入平安点检测,以防止机器码长时间没有平安点检测的状况。HotSpot 虚拟机的做法便是在生成代码的办法进口以及非计数循环的循环回边(back-edge)处插入平安点检测。

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

第一,平安点检测自身也有肯定的开销。不过 HotSpot 虚拟机曾经将机器码中平安点检测简化为一个内存拜访操作。在有平安点申请的状况下,Java 虚构机会将平安点检测拜访的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因拜访该不可读内存而触发 segfault 的线程,并将它们挂起。

第二,即时编译器生成的机器码打乱了本来栈桢上的对象散布情况。在进入平安点时,机器码还需提供一些额定的信息,来表明哪些寄存器,或者以后栈帧上的哪些内存空间寄存着指向对象的援用,以便垃圾回收器可能枚举 GC Roots。

因为这些信息须要不少空间来存储,因而即时编译器会尽量避免过多的平安点检测。

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

不论如何,其目标都是在可承受的性能开销以及内存开销之内,防止机器码长时间不进入平安点的状况,间接地缩小垃圾回收的暂停工夫。

除了垃圾回收之外,Java 虚拟机其余一些对堆栈内容的一致性有要求的操作也会用到平安点这一机制。

垃圾回收的三种形式

当标记完所有的存活对象时,咱们便能够进行死亡对象的回收工作了。支流的根底回收形式可分为三种。

第一种是革除(sweep),即把死亡对象所占据的内存标记为闲暇内存,并记录在一个闲暇列表(free list)之中。当须要新建对象时,内存治理模块便会从该闲暇列表中寻找闲暇内存,并划分给新建的对象。

革除这种回收形式的原理及其简略,然而有两个毛病。一是会造成内存碎片。因为 Java 虚拟机的堆中对象必须是间断散布的,因而可能呈现总闲暇内存足够,然而无奈调配的极其状况。

另一个则是调配效率较低。如果是一块间断的内存空间,那么咱们能够通过指针加法(pointer bumping)来做调配。而对于闲暇列表,Java 虚拟机则须要一一拜访列表中的项,来查找可能放入新建对象的闲暇内存。

第二种是压缩(compact),即把存活的对象汇集到内存区域的起始地位,从而留下一段间断的内存空间。这种做法可能解决内存碎片化的问题,但代价是压缩算法的性能开销。

第三种则是复制(copy),即把内存区域分为两等分,别离用两个指针 from 和 to 来保护,并且只是用 from 指针指向的内存区域来分配内存。当产生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且替换 from 指针和 to 指针的内容。复制这种回收形式同样可能解决内存碎片化的问题,然而它的毛病也极其显著,即堆空间的应用效率极其低下。

当然,古代的垃圾回收器往往会综合上述几种回收形式,综合它们长处的同时躲避它们的毛病。

垃圾回收具体实现

大部分的 Java 对象只存活一小段时间,而存活下来的小局部 Java 对象则会存活很长一段时间。

(pmd 中 Java 对象生命周期的直方图,红色的示意被逃逸剖析优化掉的对象)

Java 虚拟机的分代回收思维,简略来说,就是将堆空间划分为两代,别离叫做新生代和老年代。新生代用来存储新建的对象。当对象存活工夫够长时,则将其挪动到老年代。

Java 虚拟机能够给不同代应用不同的回收算法。对于新生代,咱们猜想大部分的 Java 对象只存活一小段时间,那么便能够频繁地采纳耗时较短的垃圾回收算法,让大部分的垃圾都可能在新生代被回收掉。

对于老年代,咱们猜想大部分的垃圾曾经在新生代中被回收了,而在老年代中的对象有大概率会持续存活。当真正触发针对老年代的回收时,则代表这个假如出错了,或者堆的空间曾经耗尽了。

这时候,Java 虚拟机往往须要做一次全堆扫描,耗时也将不计成本。(当然,古代的垃圾回收器都在并发收集的路线上倒退,来防止这种全堆扫描的状况。)

咱们先来看看 Java 虚拟机中的堆具体是怎么划分的。

Java 虚拟机的堆划分

后面提到,Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小雷同的 Survivor 区。

默认状况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),依据生成对象的速率,以及 Survivor 区的应用状况动静调整 Eden 区和 Survivor 区的比例。

当然,你也能够通过参数 -XX:SurvivorRatio 来固定这个比例。然而须要留神的是,其中一个 Survivor 区会始终为空,因而比例越低节约的堆空间将越高。

通常来说,当咱们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。因为堆空间是线程共享的,因而间接在这里边划空间是须要进行同步的。

否则,将有可能呈现两个对象共用一段内存的事变。如果你还记得前两篇我用“停车位”打的比如的话,这里就相当于两个司机(线程)同时将车停入同一个停车位,因此产生剐蹭事变。

Java 虚拟机的解决办法是为每个司机事后申请多个停车位,并且只容许该司机停在本人的停车位上。那么当司机的停车位用完了该怎么办呢(假如这个司机代客泊车)?

答案是:再申请多个停车位便能够了。这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。

具体来说,每个线程能够向 Java 虚拟机申请一段间断的内存,比方 2048 字节,作为线程公有的 TLAB。

这个操作须要加锁,线程须要保护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始地位,一个则指向 TLAB 开端。

接下来的 new 指令,便能够间接通过指针加法(bump the pointer)来实现,即把指向空余内存地位的指针加上所申请的字节数。

为什么不把 bump the pointer 翻译成指针碰撞。这里先解释一下,在英语中咱们通常省略了 bump up the pointer 中的 up。在这个上下文中 bump 的含意应为“进步”。另外一个例子是当咱们公布软件的新版本时,也会说 bump the version number。

如果加法后空余内存指针的值仍小于或等于指向开端的指针,则代表调配胜利。否则,TLAB 曾经没有足够的空间来满足本次新建操作。这个时候,便须要以后线程从新申请新的 TLAB。

当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 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),那么较高复制次数的对象也会被降职至老年代。

总而言之,当产生 Minor GC 时,咱们利用了标记 - 复制算法,将 Survivor 区中的老存活对象降职到老年代,而后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。现实状况下,Eden 区中的对象根本都死亡了,那么须要复制的数据将非常少,因而采纳这种标记 - 复制算法的成果极好。

Minor GC 的另外一个益处是不必对整个堆进行垃圾回收。然而,它却有一个问题,那就是老年代的对象可能援用新生代的对象。也就是说,在标记存活对象的时候,咱们须要扫描老年代中的对象。如果该对象领有对新生代对象的援用,那么这个援用也会被作为 GC Roots。

这样一来,岂不是又做了一次全堆扫描呢?

卡表

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且保护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的援用。如果可能存在,那么咱们就认为这张卡是脏的。

在进行 Minor GC 的时候,咱们便能够不必扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象退出到 Minor GC 的 GC Roots 里。当实现所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

因为 Minor GC 随同着存活对象的复制,而复制须要更新指向该对象的援用。因而,在更新援用的同时,咱们又会设置援用所在的卡的标识位。这个时候,咱们能够确保脏卡中必然蕴含指向新生代对象的援用。

在 Minor GC 之前,咱们并不能确保脏卡中蕴含指向新生代对象的援用。其起因和如何设置卡的标识位无关。

首先,如果想要保障每个可能有指向新生代对象援用的卡都被标记为脏卡,那么 Java 虚拟机须要截获每个援用型实例变量的写操作,并作出对应的写标识位操作。

这个操作在解释执行器中比拟容易实现。然而在即时编译器生成的机器码中,则须要插入额定的逻辑。这也就是所谓的写屏障(write barrier,留神不要和 volatile 字段的写屏障混同)。

写屏障须要尽可能地放弃简洁。这是因为咱们并不心愿在每条援用型实例变量的写指令后跟着一大串注入的指令。

因而,写屏障并不会判断更新后的援用是否指向新生代中的对象,而是宁肯错杀,不可放过,一律当成可能指向新生代对象的援用。

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

CARD_TABLE [this address >> 9] = DIRTY;

尽管写屏障不可避免地带来一些开销,然而它可能加大 Minor GC 的吞吐率(利用运行工夫 /(利用运行工夫 + 垃圾回收工夫))。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)。

Java 对象内存布局中的虚共享问题,讲的是几个 volatile 字段呈现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载局部卡表,那么它将对应 64 张卡,也就是 32KB 的内存。

如果同时有两个 Java 线程,在这 32KB 内存中进行援用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、有效化或者同步操作,因此间接影响程序性能。

为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

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

Java 对象的生命周期对垃圾回收的影响

后面提到,Java 虚拟机的分代垃圾回收是基于大部分对象只存活一小段时间,小局部对象却存活一大段时间的假如的。

然而,现实情况中并非每个程序都合乎后面提到的假如。如果一个程序领有中等生命周期的对象,并且刚挪动到老年代便不再应用,那么将给默认的垃圾回收策略造成极大的麻烦。

上面这段程序将生成 64G 的 Java 对象。并且,我通过 ALIVE\_OBJECT\_SIZE 这一变量来定义同时存活的 Java 对象的大小。这也是一种对于垃圾回收器来说比拟直观的生命周期。

当咱们应用 Java 8 的默认 GC,并且将新生代的空间限度在 100M 时,试着估算当 ALIVE\_OBJECT\_SIZE 为多少时,这段程序不会触发 Full GC(提醒一下,如果 Survivor 区没法存储所有存活对象,将产生什么。)。理论运行状况又是怎么样的?

// Run with java -XX:+PrintGC -Xmn100M -XX:PretenureSizeThreshold=10000 LifetimeTest
// You may also try with -XX:+PrintHeapAtGC,-XX:-UsePSAdaptiveSurvivorSizePolicy or -XX:SurvivorRatio=N
public class LifetimeTest {
  private static final int K = 1024;
  private static final int M = K * K;
  private static final int G = K * M;

  private static final int ALIVE_OBJECT_SIZE = 32 * M;

  public static void main(String[] args) {
    int length = ALIVE_OBJECT_SIZE / 64;
    ObjectOf64Bytes[] array = new ObjectOf64Bytes[length];
    for (long i = 0; i < G; i++) {array[(int) (i % length)] = new ObjectOf64Bytes();}
  }
}

class ObjectOf64Bytes {
  long placeholder0;
  long placeholder1;
  long placeholder2;
  long placeholder3;
  long placeholder4;
  long placeholder5;
}

Java 虚拟机中的垃圾回收器

针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New。这三个采纳的都是标记 - 复制算法。其中,Serial 是一个单线程的,Parallel New 能够看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 相似,但更加重视吞吐率。此外,Parallel Scavenge 不能与 CMS 一起应用。

针对老年代的垃圾回收器也有三个:刚刚提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者能够看成前者的多线程版本。

CMS 采纳的是标记 - 革除算法,并且是并发的。除了少数几个操作须要 Stop-the-world 之外,它能够在利用程序运行过程中进行垃圾回收。在并发收集失败的状况下,Java 虚构机会应用其余两个压缩型垃圾回收器进行一次垃圾回收。因为 G1 的呈现,CMS 在 Java 9 中已被废除。

G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它曾经打乱了后面所说的堆构造,间接将堆分成极其多个区域。每个区域都能够充当 Eden 区、Survivor 区或者老年代中的一个。它采纳的是标记 - 压缩算法,而且和 CMS 一样都可能在利用程序运行过程中并发地进行垃圾回收。

G1 可能针对每个细分的区域来进行垃圾回收。在抉择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。

总结

明天我介绍了垃圾回收的一些基础知识:

  • Java 虚拟机中的垃圾回收器采纳可达性剖析来摸索所有存活的对象。它从一系列 GC Roots 登程,边标记边摸索所有被援用的对象。
  • 为了避免在标记过程中堆栈的状态产生扭转,Java 虚拟机采取平安点机制来实现 Stop-the-world 操作,暂停其余非垃圾回收线程。
  • 回收死亡对象的内存共有三种形式,别离为:会造成内存碎片的革除、性能开销较大的压缩、以及堆应用效率较低的复制。

而后我介绍了 Java 虚拟机中垃圾回收具体实现的一些通用常识:

  • Java 虚拟机将堆分为新生代和老年代,并且对不同代采纳不同的垃圾回收算法。其中,新生代分为 Eden 区和两个大小统一的 Survivor 区,并且其中一个 Survivor 区是空的。
  • 在只针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过肯定数值时,它将被降职至老年代。
  • 因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它须要思考从老年代到新生代的援用。为了防止扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大抵地标出可能存在老年代到新生代援用的内存区域。

本文由 mdnice 多平台公布

退出移动版