JVM学习笔记9:垃圾回收

引用计数法与可达性分析

  • 引用计数没啥可说的,就是对堆中的对象记录有多少对象来持有它。在我的理解中最多的应该还是变量来引用,还有如果是与native的代码交互,可能还有指针来持有。

  • 可达性是用来解决互相引用问题的,这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

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

    • Java 方法栈桢中的局部变量;
    • 已加载类的静态变量;
    • JNI handles;
    • 已启动且未停止的 Java 线程。
  • PS:本质上对当前运行环境中所有可能持有对象的情况进行扫描,当发现没有堆中有对象不可达,那么就任务可以free。比如C持有A,AB形成了互引用。当C不在持有A了,AB虽然引用计数不为0,但是实际上已经不可达了。

  • 弊端:

    • 在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。
    • 误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会,相当于野指针。
    • 漏报则比较麻烦,因为未被访问过的对象会被认为是未达,垃圾回收器就可能回收这个实际上仍被引用的对象内存,这就是空指针错误了。

Stop-the-world和安全点

  • CLR与传统JVM会在GC时停止其他非垃圾回收线程的工作,直到完成垃圾回收。
  • 当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
  • 安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。
  • 比较常见的安全点:
    • 当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。
    • 解释执行字节码、执行即时编译器生成的机器码和线程阻塞。
      • 阻塞的线程由于处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。
      • 其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。
      • 对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。
      • 执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受 Java 虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。

垃圾回收的三种方式

清除(sweep)

  • 把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

image

  • 优点:够简单
  • 缺点:产生大量的内存碎片

压缩(compact)

  • 把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

image

  • 优点:解决内存碎片
  • 缺点:压缩算法性能开销

复制(copy)

  • 把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

image

  • 优点:解决内存碎片
  • 缺点:只有一半内存被使用,浪费。

JVM分代

image

  • Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。
  • 默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。
  • 也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

new的过程

  • 每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。线程创建的对象被存储在这个buffer中,如果buffer不够用了就申请新的内存。
  • 线程申请内存的这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
  • 接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

Minor GC

  • 当 Eden 区的空间耗尽Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

  • 当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

  • Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

  • PS:注意要么次数到了,要么达到一个阈值。

  • 总而言之,当发生 Minor GC 时,应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。

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

卡表

  • HotSpot 给出一项叫做卡表(Card Table)的技术,解决老年代引用新生代时,需要进行全堆扫描的问题。

该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

  • 要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。在JIT生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。

总结

  • 可以看到垃圾回收的一个演变的过程

    • 一开始采用引用计数,产生互引用问题。
    • 加入可达性的检查,有了GC ROOT的概念,产生多线程问题。
    • 线程挂起,有了安全点的概念。
  • 三种回收方式各有优缺点

  • 对于堆对象采用分代的机制,结合回收方式有了Minor GC。

  • 为了解决老年代引用新生代时GC效率的问题,加入了卡表。

# Java
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×