简介

Garbage Collectors本质上是JVM的一种线程, 它们有单线程的, 也有多线程的, 作用则是在特定的时机对垃圾对象进行清除, 回收内存;

追踪每一个对象, 如果有对象被标记为两次, 则下一次GC该对象会被清除;

即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程:

  • 第一次标记: 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链
  • 随后进行第二次筛选后标记, 筛选的条件是此对象是否有必要执行finalize()方法, 因为finalize()有机会让对象在第二次被标记为可达的(比如在finalize方法里将自己的this关键字赋值给某个类变量或者对象的成员变量)
    • 假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”, 那在第二次标记时它将仍然存在于“即将回收”的集合, 回收时它会被回收
    • finalize()方法是对象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱, 那基本上它就真的要被回收了

判断对象是否可达

引用计数法(Reference Counting)

每个对象都有个与之对应的引用计数器, 对象每被引用的一次, 计数器加一;

缺点, 无法解决循环引用: objA.instance=objB, objB.instance=objA.

可达性分析法(Reachability Analysis)

在Java技术体系里面, 固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等.

  • 本地方法栈中JNI(即通常所说的Native方法)引用的对象.

  • 方法区中类的静态属性引用的对象, 譬如Java类的引用类型静态变量.

  • 方法区中常量引用的对象, 譬如字符串常量池(StringTable)里的引用.

  • Java虚拟机内部的引用

    • 基本数据类型对应的Class对象
    • 一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等
    • 系统类加载器(App/System Class Loader)
    • 所有被同步锁(synchronized关键字)持有的对象.

Java中4种引用与GC的关系

JDK 1.2版之后, Java对引用的概念进行了扩充, 将引用分为强引用(Strongly Re-ference)、软引用(SoftReference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种, 这4种引用强度依次逐渐减弱.

强引用(Strongly Reference)

使用场景:

  • Object obj=new Object()

强引用是最传统的“引用”的定义, 是指在程序代码之中普遍存在的引用赋值, 即类似“Object obj=new Object()”这种引用关系;

无论任何情况下, 只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象.

软引用(Soft Reference)

使用场景:

  • org.springframework.util.ConcurrentReferenceHashMap.SoftEntryReference

软引用是用来描述一些还有用, 但非必须的对象.只被软引用关联着的对象, 在系统将要发生内存溢出异常前, 会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存, 才会抛出内存溢出异常.

在JDK 1.2版之后提供了SoftReference类来实现软引用.

弱引用(Weak Reference)

使用场景:

  • ThreadLocal的内部类ThreadLocalMap的Entry类java.lang.ThreadLocal.ThreadLocalMap.Entry.

弱引用也是用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止.当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象.

在JDK 1.2版之后提供了WeakReference类来实现弱引用.

虚引用(Phantom Reference)

使用场景:

  • 暂时不打算写, 找到了一些场景, 都太不熟悉了

虚引用也称为“幽灵引用”或者“幻影引用”, 它是最弱的一种引用关系.一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例.为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知.

在JDK 1.2版之后提供了PhantomReference类来实现虚引用.

GC作用的JVM区域

堆区

毋庸置疑堆区肯定是GC的核心处理区域.

方法区

方法区的垃圾收集主要回收两部分内容:

  • 废弃的常量
  • 不再使用的类型.

但是呢, 也有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载), 方法区垃圾收集的“性价比”通常也是比较低的;

在大量使用反射动态代理CGLib字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力, 以保证不会对方法区造成过大的内存压力.

GC回收算法

3个分代假说

弱分代假说(Weak Generational Hypothesis)

绝大多数对象都是朝生夕灭的.

强分代假说(Strong Generational Hypothesis)

熬过越多次垃圾收集过程的对象就越难以消亡;

这两个分代假说奠定了多款常用GC的实现设计原则;

实际就是在Java堆中划分出不同的区域(新生代和老年代), 垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了MinorGC, Major GC, Full GC这样的回收类型的划分;

也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”, “标记-清除算法”, “标记-整理算法”等针对性的垃圾收集算法;

值得注意的是, 分代收集理论也有其缺陷, 最新出现(或在实验中)的几款垃圾收集器都展现出了面向全区域收集设计的思想(比如G1), 或者可以支持全区域不分代的收集的工作模式.

跨代引用假说(Intergenerational ReferenceHypothesis)

跨代引用相对于同代引用来说仅占极少数;

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象, 是应该倾向于同时生存或者同时消亡的;

举个例子, 如果某个新生代对象存在跨代引用老年代对象, 由于老年代对象难以消亡, 该引用会使得新生代对象在收集时同样得以存活, 进而在年龄增长之后晋升到老年代中, 这时跨代引用也随即被消除了;

依据这条假说, 我们就不应再为了少量的跨代引用去扫描整个老年代, 也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用, 只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”, Remembered Set), 这个结构把老年代划分成若干小块, 标识出老年代的哪一块内存会存在跨代引用;

此后当发生Minor GC时, 只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描;

虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性, 会增加一些运行时的开销, 但比起收集时扫描整个老年代来说仍然是划算的.

GC分代收集的各种名词

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集.

    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集.

      目前只有CMS收集器会有单独收集老年代的行为.另外请注意“Major GC”这个说法现在有点混淆, 在不同资料上常有不同所指, 需按上下文区分到底是指老年代的收集还是整堆收集.

    • 混合收集(Mixed GC):指目标是收集整个新生代和部分老年代的垃圾收集.目前只有G1收集器会有这种行为.

  • 整堆收集(Full GC):收集整个Java堆方法区的垃圾收集.

Mark-Sweep

它是最基础的收集算法, 后续的收集算法大多都是以标记-清除算法为基础, 对其缺点进行改进而得到的.

“标记-清除”(Mark-Sweep)算法一共两个阶段:

  1. 标记: 二次标记对象是否可达

  2. 清除: 清除标记的对象

缺点: 内存空间的碎片化问题, 标记、清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记-复制算法(新生代)

标记-复制算法常被简称为复制算法, 为了解决标记-清除算法面对大量可回收对象时执行效率低的问题;

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代:

  • 新生代分为一块较大的Eden空间两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor
  • 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间
  • HotSpot虚拟机默认Eden和Survivor的大小比例是8:1, 也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%), 只有一个Survivor空间, 即10%的新生代是会被“浪费”的.

思考: 在HotSpot新生代中, 为什么默认的Survivor比Eden空间要小得多(1:8)?

因为经研究表明, 所有的新生代中的对象有98%的对象熬不过第一轮GC收集, 所以存活的对象太少太少, 只需要十分之一(1/8+1+1)就行;

结合上面的分代假说, 多次还存活的对象也会进入到老年代.

标记-整理(Mark-Compact)算法(老年代)

标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低, 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种标记-复制算法;

标记-整理(Mark-Compact)算法:

  • 针对老年代对象的存亡特征, 标记过程仍然与“标记-清除”算法一样

  • 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存, “标记-整理”算法的示意图如图:

    img

Stop The World

  • 可达性分析期间发生: 需要保证整个执行系统的一致性, 对象的引用关系不能发生变化, 所以需要将用户的正常的工作线程全部停掉, 避免对象的引用关系变化, 与可达性分析不一致

  • 目前无论哪一种GC回收算法, 都会进行可达性分析, 导致GC进行时必须停顿所有Java执行线程(称为"Stop The World")

  • 即使是号称停顿时间可控, 或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器, 枚举根节点时也是必须要停顿的

  • GC线程是JVM在后台自动发起和自动完成的, 在用户不可见的情况下, 把用户正常的工作线程全部停掉

衡量垃圾收集器的三项最重要的指标

Footprint, Throughput, Latency三者共同构成了一个“不可能三角”;

三者总体的表现会随技术进步而越来越好, 但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的, 一款优秀的收集器通常最多可以同时达成其中的两项.

吞吐量(Throughput)

所谓吞吐量就是处理器用于运行用户代码的时间处理器总消耗时间的比值, 比如有10%的时间在进行GC, 则吞吐量就是90%, 即:

img

延迟(Latency)

可以理解为用户线程的暂停时间, 是GC导致的延迟;

进行GC时, 需要让用户线程暂停运行, 即在这段时间内用户线程无法对请求做出相应, 这里时间段比如STW;

这是由于垃圾回收导致应用程序暂停(无响应)的时间, 既要考虑最大暂停时间, 又要考虑各种统计数据, 例如平均值和标准偏差, 如果希望应用程序具有尽可能高的响应速度, 则希望最大程度地减少垃圾回收导致的延迟.

内存占用(Footprint)

GC是一个或多个线程, 会占用一定的内存;

在追求高的吞吐量和低延迟, 显然内存占用会变大.

GC算法实现中的一些常用数据结构

Safe Region

  • 安全区域是一块区域, 能够确保在某一段代码片段之中, 引用关系不会发生变化;

  • 因此, 在这个安全区域中任意地方开始垃圾收集都是安全的.

Remembered Set

位置

垃圾收集器在堆区(例如新生代)中建立了名为记忆集(Remembered Set)的数据结构

作用

  • 解决跨代引用(例如MinorGC时新生代被老年代引用)的问题

  • 新生代需要Minor GC时, 会枚举GC Roots对象, 如果有老年代对象跨代引用新生代对象, 该新生代对象不应该直接被GC, 所以需要扫描老年代获得那些被跨代引用新生代对象

  • Remembered Set把老年代划分成若干小块, 标识出老年代的哪一块内存存在跨代引用

  • 有了Remembered Set, 就不再需要扫描老年代, 只需要把卡表中Dirty的元素加入到GC Roots

例子

新生代需要Minor GC时, 新生代的对象A被老年代的对象B引用, 所以在Card Table中该对象是Dirty, 加入到GC Roots, 这次该对象是可达的.

Remembered Set的实现

Remembered Set是一种对规范的抽象, 目前已知有三种实现

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数, 如常见的32位或64位, 这个精度决定了机器访问物理内存地址的指针长度), 该字包含跨代指针.
  • 对象精度:每个记录精确到一个对象, 该对象里有字段含有跨代指针.
  • 卡精度(卡表, CardTable):每个记录精确到一块内存区域, 该区域内有对象含有跨代指针.

卡表(Card Table)

  • 卡表(Card Table)实现了记忆集, 这也是目前最常用的一种记忆集实现形式

  • 卡表最简单的形式可以只是一个字节数组, 而HotSpot虚拟机确实也是这样做的. 以下这行代码是HotSpot默认的卡表标记逻辑

    CARD_TABLE[this address >> 9] = 0;
    
  • 字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块, 这个内存块被称作“卡页”(Card Page)

  • 卡表与卡页的关系如下图

    image-20210118145300745

  • 卡表CardTable的数组元素的值 = 对象被其他区域的(一个或多个)对象引用? 1(称为这个元素变脏(Dirty)) : 0(没有)

Write Barrier

注意

这里GC中的写屏障(Write Barrier)与volatile通过内存屏障保证有序性是不同的概念.

例1

当新生代一个对象被老年代引用, 就会通过写屏障把该对象对应的Card Table的位置变脏.

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障, 在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

例2

G1在并发标记阶段, 引用关系可能发生变化;

为了防止漏标记存活的对象, 在重新标记阶段, 通过原始快照(Snapshot At TheBeginning, SATB)算法;

"当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来", 这里记录也是使用的写屏障.

总结

Remembered Set可以缩减GC Roots扫描范围的问题;

写屏障(Write Barrier)解决卡表元素如何维护的问题, 例如它们何时变脏, 谁来把它们变脏等;

写屏障保证的是在多线程(多个GC线程和多个用户线程)环境下, 对应操作的正确性(比如防止多线程重复将已经Dirty的值改为Dirty);

总而言之, 写屏障(Write Barrier)的过程像AOP, 它保证了在特定操作的前或后需要做的一些操作;

结合这句话, 再去看看上面两个例子, 应该就清晰多了.

三色标记(Tri-color Marking)

白色

表示对象尚未被垃圾收集器访问过;

显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的;

若在分析结束的阶段, 仍然是白色的对象, 即代表不可达.

黑色

表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过;

黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象属性引用了黑色对象, 无须重新扫描一遍.

灰色

表示对象已经被垃圾收集器访问过, 但这个对象上还有至少一个引用的对象没有被扫描过, 未扫描的引用的对象可达性未知.

GC线程并发标记阶段与用户线程例子

下图来源于<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) >>

image-20210126175747414

上面出现的问题, G1通过SATBWrite Barrier解决.

存活对象未被标记的原因

Wilson于1994年在理论上证明了, 当且仅当以下两个条件同时满足时, 会产生“对象消失”的问题, 即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

漏标存活对象的解决方案

只需破坏存活对象未被标记的原因的任意一个即可.

由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At TheBeginning, SATB).

增量更新(Incremental Update)

增量更新要破坏的是第一个条件, 当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次;

黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了;

CMS重新标记阶段采用增量更新来实现.

SATB

SATB即原始快照(Snapshot At The Beginning);

原始快照要破坏的是第二个条件, 当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次;

无论引用关系删除与否, 都会按照刚刚开始扫描那一刻的对象图快照来进行搜索;

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的;

G1重新标记阶段是用原始快照来实现.

一些GC垃圾收集器的介绍

一张图看懂常用GC的作用的堆区域

下图来源于: <<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) >>

下图展示了七种作用于不同分代的收集器, 如果两个收集器之间存在连线, 就说明它们可以搭配使用;

图中收集器所处的区域, 则表示它是属于新生代收集器抑或是老年代收集器.

img

串行(Serial) Garbage Collector

Serial

单线程GC, 这种类型的GC一旦进行垃圾回收, 所有的用户线程都会被冻结, 直到它一次GC结束;

Serial GC 是HotSpot JVM运行在Client模式下的默认新生代收集器.

img

Serial Old

Serial Old是Serial收集器的老年代版本, 它同样是一个单线程收集器, 使用标记-整理算法. 这个收集器的主要意义也是HotSpot JVM运行在Client模式下使用.

img

并行(Parallel) Garbage Collector

并行的多个线程进行GC, 这种类型的GC一旦进行垃圾回收, 用户线程也会被冻结.

并行描述的是多条垃圾收集器线程之间的关系, 说明同一时间有多条这样的线程在协同工作, 通常默认此时用户线程是处于等待状态.

ParNew

ParNew收集器是新生代收集器, 实质上是Serial收集器的多线程并行版本, 一般和CMS搭配使用.

img

Parallel Scavenge

Parallel Scavenge收集器也是一款新生代收集器, 它同样是基于标记-复制算法实现的收集器, 也是能够并行收集的多线程收集器;

Parallel Scavenge的诸多特性从表面上看和ParNew非常相似, 那它有什么特别之处呢?

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput);

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本, 支持多线程并发收集, 基于标记-整理算法实现;

注重吞吐量或者处理器资源较为稀缺的场合, 都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合.

Parallel Scavenge/Parallel Old收集器运行示意图如下:

img

并发(Concurrent) Garbage Collector

并发(Concurrent) Garbage Collector描述的是垃圾收集器线程用户线程之间的关系, 说明同一时间垃圾收集器线程与用户线程都在运行;

由于用户线程并未被冻结, 所以程序仍然能响应服务请求, 但由于垃圾收集器线程占用了一部分系统资源, 此时应用程序的处理的吞吐量将受到一定影响.

CMS(Concurrent Mark Sweep) garbage collector

整个过程

整个过程分为四个步骤, 包括:

img

  1. 初始标记(CMS initial mark)
    1. 需要"Stop The World"
    2. 仅仅是标记与GC Roots直接相连的对象
  2. 并发标记(CMS concurrent mark)
    1. 从GC Roots的直接关联对象开始遍历整个对象图的过程
    2. 耗时较长但是不需要停顿用户线程, 垃圾收集线程用户线程一起并发运行
  3. 重新标记(CMS remark)
    1. 需要"Stop The World"
    2. 为了修正并发标记期间, 因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
  4. 并发清除(CMS concurrent sweep)
    1. 标记-清除, 不整理, 会产生内存碎片
    2. 并发清除阶段, 清理删除掉标记阶段判断的已经死亡的对象, 由于不需要移动存活对象, 所以这个阶段也是可以与用户线程同时并发的
    3. 会产生浮动垃圾, 因为并发清除阶段, 如果用户线程产生了垃圾, 则不会被标记-清除

优点

  • 并发收集, 低停顿, CMS GC过程中"Stop The World"耗费极少的时间

缺点

  • 不能处理浮动垃圾 (在最后一步并发清理阶段, 用户线程还在运行, 这时候可能就会又有新的垃圾产生, 而无法在此次GC过程中被回收, 这成为浮动垃圾)

  • 对 cpu 资源敏感, 占用CPU资源较大. CMS默认启动的回收线程数是(CPU数量+3)/ 4, 也就是当CPU在4个以上时, 并发回收时垃圾收集线程不少于25%的CPU资源, 并且随着CPU数量的增加而下降. 但是当CPU不足4个(譬如2个)时, CMS对用户程序的影响就可能变得很大.

  • 产生大量内存碎片(因为使用的是标记-清除算法)

将被废弃掉

Java9会被废弃掉, Java14源代码就被移除掉

As of Java 9, the CMS garbage collector has been deprecated. Therefore, JVM prints a warning message if we try to use it.

G1 Garbage Collector

GarbageFirst.

参考来源

<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) >>

https://www.baeldung.com/jvm-garbage-collectors

http://eivindw.github.io/2016/01/08/comparing-gc-collectors.html

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