You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

12 | 垃圾回收(下)

在读博士的时候我曾经写过一个统计Java对象生命周期的动态分析并且用它来跑了一些基准测试。

其中一些程序的结果恰好验证了许多研究人员的假设即大部分的Java对象只存活一小段时间而存活下来的小部分Java对象则会存活很长一段时间。

pmd中Java对象生命周期的直方图红色的表示被逃逸分析优化掉的对象

之所以要提到这个假设是因为它造就了Java虚拟机的分代回收思想。简单来说就是将堆空间划分为两代分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时则将其移动到老年代。

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

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

这时候Java虚拟机往往需要做一次全堆扫描耗时也将不计成本。当然现代的垃圾回收器都在并发收集的道路上发展来避免这种全堆扫描的情况。

今天这一篇我们来关注一下针对新生代的Minor GC。首先我们来看看Java虚拟机中的堆具体是怎么划分的。

Java虚拟机的堆划分

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

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

当然,你也可以通过参数-XX:SurvivorRatio来固定这个比例。但是需要注意的是其中一个Survivor区会一直为空因此比例越低浪费的堆空间将越高。

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

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

Java虚拟机的解决方法是为每个司机预先申请多个停车位并且只允许该司机停在自己的停车位上。那么当司机的停车位用完了该怎么办呢假设这个司机代客泊车

答案是再申请多个停车位便可以了。这项技术被称之为TLABThread 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字段的写屏障混淆

写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。

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

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

CARD_TABLE [this address >> 9] = DIRTY;

虽然写屏障不可避免地带来一些开销但是它能够加大Minor GC的吞吐率 应用运行时间/(应用运行时间+垃圾回收时间) 。总的来说还是值得的。不过在高并发环境下写屏障又带来了虚共享false sharing问题[2]。

在介绍对象内存布局中我曾提到虚共享问题讲的是几个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虚拟机将堆分为新生代和老年代并且对不同代采用不同的垃圾回收算法。其中新生代分为Eden区和两个大小一致的Survivor区并且其中一个Survivor区是空的。

在只针对新生代的Minor GC中Eden区和非空Survivor区的存活对象会被复制到空的Survivor区中当Survivor区中的存活对象复制次数超过一定数值时它将被晋升至老年代。

因为Minor GC只针对新生代进行垃圾回收所以在枚举GC Roots的时候它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代Java虚拟机引入了名为卡表的技术大致地标出可能存在老年代到新生代引用的内存区域。

由于篇幅的原因我没有讲解Java虚拟机中具体的垃圾回收器。我在文章的末尾附了一段简单的介绍如果你有兴趣的话可以参阅一下。

今天的实践环节我们来看看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虚拟机中的垃圾回收器

针对新生代的垃圾回收器共有三个SerialParallel 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中已被废弃[3]。

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

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

即将到来的Java 11引入了ZGC宣称暂停时间不超过10ms。如果你感兴趣的话可参考R大的这篇文章[4]。

1

http://psy-lob-saw.blogspot.com/2014/10/the-jvm-write-barrier-card-marking.html

2

https://blogs.oracle.com/dave/false-sharing-induced-by-card-table-marking

3

http://openjdk.java.net/jeps/291

4