[Java] JVM垃圾回收

GC原则与常见的GC算法、收集器

Posted by Penistrong on January 28, 2023

JVM GC机制

回收原则和内存分配策略

垃圾回收即$\textrm{Garbage Collection}$,简称$\textrm{GC}$。对于Java开发者而言,由于JVM自动内存管理机制的存在,不需要像C/C++开发者进行free/delete等手动内存管理操作,JVM通过GC机制管理虚拟机所使用的内存

回收原则

Minor GC 和 Full GC

  • Minor GC: 回收新生代,因为新生代对象存活时间短,Minor GC会频繁执行,执行速度很快
  • Full GC: 回收新生代和老年代,老年代对象存活时间长,Full GC执行频率低,执行速度慢

HotSpot VM GC

HotSpot VM的垃圾回收范围有些许不同:

  1. 部分收集(Partial GC):
    • 新生代收集(Minor GC / Young GC): 只对新生代进行GC
    • 老年代收集(Major GC / Old GC): 只对老年代进行GC
    • 混合收集(Mixed GC): 对整个新生代和部分老年代进行GC
  2. 整堆收集(Full GC): 对整个堆和方法区进行GC

FullGC触发条件

  1. 主动调用System.gc()建议JVM执行Full GC,但是JVM不一定真正执行

    不建议使用该方式,应该让JVM自己管理内存

  2. 老年代空间不足

    内存分配时,由于大对象会直接进入老年代、长期存活对象也会晋升到老年代中,当老年代空间不足时,就会触发一次Full GC,

  3. 空间分配担保失败

    一般来说,回收新生代的Minor GC使用的都是复制算法(Eden + From 复制到 To中),空间分配担保是一层兜底机制,见空间分配担保一节,失败时就会触发Full GC

  4. Concurrent Mode Failure

    使用CMS进行GC的时候,由于三色标记法多标问题导致部分应该被清除的垃圾对象没有被回收,成为了浮动垃圾,这些多余的垃圾对象也可能会晋升到老年代中(根据分代年龄),如果老年代空间不足便会抛出Concurrent Mode Failure错误,并触发Full GC

内存分配策略

对象优先在Eden区域分配

大多数情况下,新建对象实例将在新生代的Eden区域分配

若Eden区没有足够空间进行分配时,JVM将发起一次Minor GC

大对象直接进入老年代

大对象指需要大量连续内存空间的对象(比如超长字符串和大数组),创建大对象实例时直接在老年代Tenured中分配内存,使用-XX:PretenureSizeThreshold指定阈值

目的是避免为大对象分配内存时,由于JVM空间分配担保机制的存在,引发GC过程中在Eden和Survivor之间的大量内存复制,提高效率

长期存活对象移入老年代

JVM采用分代收集思想来管理内存,所以给每个对象定义了一个年龄计数器,降生在Eden区的对象初始年龄为0

每次Minor GC时,会将能够存活的对象复制到To中,然后对EdenFrom这两个区域的“垃圾”进行清除,然后互换From和To的身份。对象每熬过一次Minor GC便将年龄计数器+1,超过一个动态计算的年龄阈值后就会晋升到老年代Tenured

由于对象头的Mark Word中留给分代年龄的字段只有4 bit,因此最大年龄就是15($(1111)_b$)

HotSpot会遍历新生代中的所有对象,按照年龄从小到大的顺序对每个年龄占用的总内存进行计算,当某个年龄下对象的累积大小超过了From这块Survivor区的TargetSurvivorRatio(默认50%)时,就会取该年龄MaxTenuringThreshold中更小的那个值作为新的晋升年龄阈值,对应函数如下所示:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
    size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
    size_t total = 0;
    uint age = 1;
    while (age < table_size) {
        total += sizes[age];
        if (total > desired_survivor_size) {
            break;
        }
        age++;
    }
    uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
    ...
}

官方文档: MaxTenuringThreshold并不是一个全局年龄阈值,不同的垃圾收集器拥有不同默认值,最大的默认值是15,同时也是Parallel Scavenge的默认值,但是对于CMS为6

空间分配担保

空间分配担保是为了确保在Minor GC之前,老年代的剩余空间可以容纳新生代的所有对象,如果条件成立,那么这次Minor GC可以确保安全

JDK6 Update 24后,只要老年代的连续空间大于新生代所有对象总大小或者历次晋升的平均大小,就认为可以进行Minor GC,否则执行一次Full GC

Parallel Scavenge这样的收集器会利用JVM根据系统运行情况收集的性能监控信息(比如历次晋升平均大小),进行GC自适应调节

垃圾收集算法与收集器

对象回收性判断

GC的目的是为了回收堆和方法区中不再需要的对象和数据,代替Java开发者完成内存管理工作,那么首先要判断对象是否需要被回收,JVM判定对象是否需要存活都与指向这个对象的引用有关

引用类型

JDK1.2之前,Java对于引用的定义十分简单,如果一个引用类型的数据(reference)存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用;JDK1.2之后,Java提供了四种强度不同的引用类型

  1. 强引用

    $\textrm{StrongReference}$关联的对象不会被回收,强引用也是使用最普遍的引用,即使内存空间不足,JVM也不会回收逻辑上仍需存活的强引用对象,宁愿抛出OOM异常

    使用new关键字新建对象以创建强引用

    Object obj = new Object();
    
  2. 软引用

    $\textrm{SoftReference}$关联的对象类似与可有可无的生活物品,当内存空间不足时,JVM就会主动回收软引用对象

    使用SoftReference类创建软引用,还能和引用队列ReferenceQueue联合使用,如果软引用对象被GC,JVM会把这个软引用放入到关联的引用队列中

    Object obj = new Object();
    SoftReference<Object> sf = new SoftReference<Object>(obj);
    obj = null;  // 释放之前的强引用,使原来的obj只被软引用关联
    
  3. 弱引用

    $\textrm{WeakReference}$比软引用对象的生命周期更短暂,垃圾收集器对其负责的内存区域进行扫描时,一旦发现只具有弱引用的对象,不论当前内存空间是否足够,都会触发一次针对该弱引用对象的GC

    使用WeakReference类创建弱引用,也能和ReferenceQueue联合使用

    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>();
    obj = null;  // 释放强引用
    
  4. 虚引用

    $\textrm{PhantomReference}$不会对对象的生命周期有任何影响,如果一个对象仅持有虚引用,它被回收的时机与它没有持有虚引用时一致

    虚引用必须和引用队列联合使用,当垃圾回收器准备回收持有虚引用的对象时,会将这个虚引用加入到引用队列中。为对象设置虚引用的唯一目的是让程序通过判断引用队列里是否入队了虚引用,就会知晓被引用的对象是否被垃圾回收,这样程序就可以在持有虚引用的对象被回收前采取某些行动

    使用PhantomReference类创建虚引用

    Object obj = new Object();
    PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
    obj = null;
    

弱引用和虚引用极少使用,软引用可以加速JVM回收垃圾,减少OOM异常的出现

引用计数算法

给对象添加一个引用计数器:

  • 每当有一个地方新引用该对象,计数器+1
  • 当这个引用失效时,计数器-1
  • 计数器为0的对象被垃圾收集器扫描后会直接回收

但是JVM不使用这种方法,因为它难以避免对象循环引用的问题,比如:

public class ReferenceCountingGC {
    
    Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC a = new ReferenceCountingGC();
        ReferenceCountingGC b = new ReferenceCountingGC();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
        doSth();
    }
}
  1. 创建对象a与b时,在main方法作用域里a与b的计数器值初始为1

  2. 对象a与b分别持有1个对象实例的引用类型属性,设置a与b互相引用对方,然后它们的计数器还会再+1

  3. 将对a与b这两个对象的引用去除后,它们的计数器都会-1,虽然之后再也用不到a与b这两个对象,但它们的计数器不为0,垃圾回收器永远无法回收内存中的这两个对象

可达性分析算法

可达性分析算法是JVM常用的对象回收性判断算法,以被称为GC Roots的对象作为起点,往下搜索其他对象,搜索的路径称为引用链。搜索完成时,如果一个对象向上不能通过任何引用链与GC Roots相连,就认为此对象不可用,被打上“待回收”的标记

通常CMS回收器会使用可达性分析算法对待回收垃圾执行标记,对象被打上标记后并不是板上钉钉地被”宣告死亡”,如果程序继续运作有可能会导致该对象重新加入引用链里,即CMS至少需要两次标记过程才能执行清理

GC Roots对象有以下几种:

  • 虚拟机栈的栈帧里局部变量表中引用的对象
  • 本地方法栈中JNI引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

GC Roots

类与常量回收性判断

除了堆上的对象之外,GC机制还要负责对方法区里无用的类与常量的垃圾回收

判断类是否无用

在大量使用反射和动态代理的场景里,为了避免加载里过多的类导致内存溢出,JVM必须能够卸载无用的类,类需要满足下列3个条件才能被卸载

  • 该类所有的实例都已经被回收,堆中不存在该类的任何实例
  • 加载该类的ClassLoader也已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,即,不会再通过反射的方式访问该类的方法

JVM可能会对满足上述3个条件的无用类执行回收,但也不是必然的

判断常量是否无用

垃圾收集器需要对方法区的运行时常量池中存在的废弃常量执行回收,比如字符串常量池中存在对堆中的字符串对象"aaa"的引用,创建String对象时如果赋值为"aaa"就会引用该字符串常量以节省内存,如果没有任何String对象引用它的话,字符串常量引用的实际String类对象"aaa"就可以被垃圾回收,从常量池中被清理出去

垃圾收集算法

按照分代收集的思想,对于新生代和老年代将采取不同的垃圾收集算法

  • 新生代: 使用 复制 算法,新生代里的对象存活几率低且GC频率高,只需要对少量对象执行复制就可以完成GC,同时还可依赖老年代进行空间分配担保
  • 老年代: 使用 标记-清除 或者 标记-整理 算法,老年代里的对象存活几率高而GC频率低,且不会有”老老年代”这样的空间分配担保存在,所以一般采用这两个回收算法

标记-清除

标记-清除算法

Mark-Sweep算法分为两个阶段:

  1. 标记阶段:检查每个对象是否需要被回收,对不需要被回收的对象,在其头部打上标记
  2. 清除阶段:回收头部没有标记的对象,并删除存活对象头部里的标记

标记-清除算法还会判断回收后的内存分块与其前一个空闲分块是否连续,如果连续则会执行合并,然后将其链接到一个被称为”空闲链表”的单向链表,后续需要分配内存时只需要遍历该空闲链表,即可找到空闲分块

在内存分配时,程序会搜索空闲链表找到第一个空间大于等于新对象大小size的块block,如果刚好相等则直接返回该block,否则会将该分块切割为size+block_size - size两部分,返回目标分块,并将剩余空闲分块插入到空闲链表里

缺点:

  • 效率: 标记和清除的过程效率都不高
  • 空间: 由于分块不连续,会产生大量不连续碎片,无法给大对象分配连续内存

标记-整理

标记-整理算法

为了解决标记-清除算法的空间碎片问题,标记-整理算法会将所有存活对象向空闲内存的一端进行移动,然后直接清空端边界之外的内存。

缺点:

  • 需要移动大量对象,效率更低了

该算法适合老年代的特点,因为老年代中经常需要给大对象分配连续空间,且老年代的GC频率较低,效率上的缺点也可以接受

复制

复制算法

复制算法会将所有内存划分为大小相同的两块,每次只使用其中的一块,当该块内存用完后就会将存活的对象复制到另一块上,然后一次清理干净本块内存

HotSpot VM中的新生代就采用复制算法的思想,将空间划分为了8:1:1的一块Eden区域和两块Survivor区域,每次只使用Eden和其中一块Survivor(称为From)。进行GC时,会将Eden和From里的存活对象全部复制到另一块Survivor(称为To)中,最后清理Eden和From这两块区域,并调换From和To的身份

这样HotSpot VM的新生代内存利用率就可以达到90%,如果GC时有多于10%空间的对象存活,那么就需要老年代进行空间分配担保,将放不下的对象移入老年代中

垃圾收集器

垃圾收集器为GC时具体采用的回收程序,一般以线程的形式启动垃圾收集器,根据运行方式的不同有如下区分:

  • 单线程/多线程

Serial

Serial收集器

Serial(串行)收集器是最基本的垃圾收集器,它是一个单线程收集器,GC时只会使用一个线程进行工作,且GC期间会暂停其他所有线程(暂停的时间称作停顿时间),直到它完成垃圾回收,GC停顿又称STW($\textrm{Stop The World}$)

Serial收集器的优点是简单与高效,由于没有线程交互开销,拥有最高的单线程收集效率

工作在新生代的Serial收集器使用复制算法执行GC,工作在老年代的Serial收集器使用标记-整理算法

Serial Old

Serial收集器的老年代版本,通常有两种用途

  • JDK1.5及之前,与Parallel Scavenge收集器搭配使用(Parallel Old收集器还没有诞生)

  • 作为CMS收集器的后备预案,在并发收集阶段产生Concurrent Mode Failure时启动

ParNew

ParNew收集器

ParNew可以理解为Parallel New Generation, 即工作在新生代的Serial收集器的多线程版本,除了GC时会使用多个线程之外,其余行为与Serial完全一致

除了单线程的Serial收集器之外,多线程收集器里只有ParNew能与CMS收集器配合使用

Parallel Scavenge

Parallel Scavenge与ParNew几乎相同,但它主要的目标是控制CPU吞吐量,即CPU用于运行用户程序的时间占总运行时间的比值。其他收集器的目标是尽可能地缩短用户线程的停顿时间以提高用户体验,而它是”吞吐量优先”收集器,停顿时间的缩短通常是以牺牲吞吐量和新生代空间换来的: 新生代空间变小导致垃圾回收频繁,致使吞吐量下降

Parallel Scavenge可以进行GC自适应调节,JVM会根据程序运行情况收集性能监控信息,动态地调整诸如新生代大小、Eden与Survivor比例、晋升老年代年龄阈值等参数

JDK8采用 Parallel Scavenge + Parallel Old 作为默认收集器

# 新生代使用Parallel Scavenge收集器,老年代使用Serial Old
-XX:+UseParallelGC
# 新生代使用Parallel Scavenge收集器,老年代使用Parallel Old
-XX:+UseParallelOldGC

对于JDK8,可以查看其JVM的默认启动参数,当给定-XX:+UseParallelGC默认会启用-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC改变老年代收集器为Serial Old

java -XX:+PrintCommandLineFlags -version

-XX:InitialHeapSize=257360832 -XX:MaxHeapSize=4117773312 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC 
openjdk version "1.8.0_362"
OpenJDK Runtime Environment (build 1.8.0_362-b09)
OpenJDK 64-Bit Server VM (build 25.362-b09, mixed mode)

Parallel Old

Parallel Scavenge收集器的老年代版本,常与前者搭配使用

CMS

CMS(Concurrent Mark Sweep)收集器是使用标记-清除(Mark-Sweep)算法的并发收集器(而不是并行Parallel)

  • 并行 Parallel : 有多条垃圾收集器线程同时工作,但是用户线程仍然处于等待状态

  • 并发 Concurrent : 用户线程与垃圾收集器线程同时执行(包括交替执行),此时用户线程在垃圾收集器工作时(基本上)没有停止

CMS收集器

CMS收集器在回收新生代时,采用的仍是基于复制算法的ParNew收集器,只是用了CMS的标记算法帮助定位垃圾

CMS工作时有以下4个阶段:

  1. 初始标记: 仅标记GC Roots的直接子节点,速度很快,但是用户线程需要停顿

  2. 并发标记: 同时运行用户线程和GC线程,使用闭包结构进行GC Roots Tracing,搜索所有引用链标记可达对象,耗时最长,但不需要停顿。该阶段结束后,不能保证标记了所有可达对象,因为用户线程也在同时工作,可能会更新引用链,所以该阶段还会跟踪处理发生引用更新的对象

  3. 重新标记: 修复并发标记阶段因用户线程工作而导致标记发生变动的对象的标记记录,需要停顿,但停顿时间远小于并发标记的时间

  4. 并发清除: 同时运行用户线程和GC线程,后者会对未被标记的对象执行清理

整个工作流程中耗时最长的并发标记和并发清除阶段,用户线程都不需要停顿,大幅提升用户体验,相对而言其缺点也很明显:

  • 吞吐量低,CPU利用率不高

  • 无法处理浮动垃圾,可能会产生Concurrent Mode Failure,浮动垃圾是指前面的并发标记阶段中使用三色标记法进行标记时,如果一个对象已被标记为灰色,而用户线程断开了对这个灰色对象的引用,但是下一趟三色标记还是会从该灰色对象开始继续向下标记,这些对象本应成为垃圾但是只能等到下一次GC时才能被标记、回收。由于浮动垃圾的存在,CMS需要预留出部分内存,如果CMS在回收老年代时产生的浮动垃圾无法存放在预留内存里,就会出现Concurrent Mode Failure,JVM将临时启用Serial Old收集器对老年代进行回收

    三色标记法详见我的另一篇笔记G1收集器-三色标记法一节

  • 标记-清除算法会产生大量空间碎片,使大对象无法被分配到足够大的连续空间里,导致提前触发Full GC

G1

G1收集器具有非常多的技术点,见我的另一篇总结笔记G1收集器,包含了可达性分析算法如何利用三色标记方法分析所有对象的可达性

ZGC

ZGC收集器是JDK11提出的低延迟全并发垃圾回收器,用来解决G1收集器及前代其他收集器的不足之处

美团技术博客-新一代垃圾回收器ZGC的探索与实践