浅入理解JVM(二)—— JVM 垃圾回收机制

 

Java内存中的线程私有部分(程序计数器、虚拟机栈、本地方法栈)的内存分配与回收都是确定的,它们随方法退出或者线程结束而回收。因此,垃圾回收主要是围绕Java堆和方法区展开的,更确切地说,垃圾回收问题就是对象内存的分配与回收问题

1.对象可回收判断

垃圾回收需要考虑三件事:哪些内存需要回收?什么时候回收?如何回收?

1.1 引用计数算法

引用计数算法是一种判断对象是否还“存活”的简单算法,它给每个对象添加一个引用计数器,每当有一个地方引用它,计数器就加一;当计数器值为零时,则认为该对象已经“死亡”,可以被回收。

但主流的 Java 虚拟机从未选用过这种方法去管理内存,主要原因在于它无法解决“对象循环引用”问题。考虑下面的这段代码,对象 A 被 objA 引用,而后又被对象 B 的 child 变量引用,对象 B 同理。此时,两个对象的引用计数为二。但当我们把 objAobjB 都设为 null 之后,会发现它们的引用计数不为零,无法回收,这是由于两个本应该被回收的对象互相引用产生了不合理的引用计数造成的。

public class ReferenceCountingGC {
    
    Object child = null;

    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.child = objB;
        objB.child = objA;
        
        objA = null;
        objB = null;
        
        System.gc();
    }
}

1.2 可达性分析算法

可达性分析本质是把“无效引用”去掉,只保留“有效有用”,从而正确判断对象是否存活。

而在Java中,和一系列被称为 GC Roots 的对象通过引用相连的对象就是“有效引用”。

可达性分析

GC Roots 对象包括:

(01)在虚拟机栈中引用的对象,主要是栈帧中的本地变量表,对应方法参数局部变量临时变量;

(02)在方法区中类静态属性引用的对象,也就是类中的引用类型静态变量;

(03)在方法区中常量引用的对象,主要是字符串;

(04)在本地方法栈中引用的对象;

(05)基本数据类型对应的Class对象,一些常驻的异常对象(指针异常、内存溢出等),系统类加载器

(06)所有被同步锁持有的对象;

(07)其他难记的:)

1.3 引用强度分级

引用强度分级是为了描述这样的对象:当内存空间足够时,就保存它们,当内存空间不足时就抛弃掉——而这在很多系统缓存功能是很常见的场景。

(01)强引用:就是最“传统”的引用,通过 new 关键字进行引用赋值;

(02)软引用:在系统抛出 OOM 之前,会对软引用对象进行第二次回收(第一次是指回收不可达对象),如果依然内存不足,才正式抛出 OOM

在 JDK 1.2 后提供 SoftReference 以实现软引用。

(03)弱引用:当垃圾回收器开始工作,无论当前内存是否足够,都会对他们进行回收;

在 JDK 1.2 后提供 WeakReference 以实现软引用。

(04)虚引用:跟没有引用的对象的唯一区别是,可以在它被回收时收到一个系统通知。

在 JDK 1.2 后提供 PhantomReference 以实现软引用。

1.4 从回收中逃逸

并不推荐使用,了解即可,只是很多面试官老是问到。

一个对象在经过可达性分析后发现没有与 GC Roots 相连接的引用链,则被第一次标记。之后判断是否要执行对象的 finalize 方法( Objectprotected 方法,任何对象都有的),如果该方法已经被执行过了一次,则直接回收对象。如果该方法还没有被执行过,则将该对象加入到名为 F-Queue 的队列中等待执行。

而要想成功逃离被回收的命运,只要在执行 finalize 方法时,重新同引用链上的任何一个对象建立关系即可。

1.5 方法区回收

方法区主要回收废弃的常量不再使用的类型

我们用一个字符串“Zion”来示范,加入“Zion”曾经进入过常量池中,但是当前系统没有任何一个字符串对象的值是它。如果这时候发生内存回收,而且垃圾回收器判断确实有必要的话,“Zion”就会被清理出常量池。

而要判断一个类型是否可以被回收,需要满足:该类所有实例已经被回收;加载该类的类加载器已经被回收;该类对应的 java.lang.Class 对象没有被任何地方引用

可以看到,回收废弃类型的条件是相当苛刻的,一般只有在频繁自定义类加载器的场景才会碰到。

2.垃圾收集算法

2.1 分代收集理论

分代收集理论是建立在两个假说之上的:

1)弱分代假说:绝大多数对象都是朝生夕灭的。

2)强分代假说:熬过越多次垃圾收集过程的对象就越难消灭。

由此,我们划分出了“新生代”和“老年代”两个区域。但这依然存在一个很明显的困难:对象之间的跨代引用会为分代收集造成较大负担。每次,我们在检查对象是否存活时,除了 GC Roots,我们还需要遍历整个老年代中的所有对象来确保可达性分析结果是正确的。由此,引出了第三条理论:

3)跨代引用假说:跨代引用相对于同代引用仅占极少数。

这条理论本质是一种推论,因为老年代对象很难消灭,所以存在跨代引用的新对象会随着年龄增长很快成为老年代对象。

依据这条假说,我们在新生代上建立一个全局数据结构“记忆集”,它将老年代分成若干小块,然后标识出老年代中哪一块会存在跨代引用。这样当 Minor GC 发生时,就只需要扫描这一小块区域即可。

【补充】名词解释(其实看懂英文就很好理解了)

Partial GC:部分收集;

Minor GC/Young GC:新生代收集;Major GC/Old GC:老年代收集;Mixed GC:混合收集;

Full GC:整堆收集 。

2.2 标记-清除算法

首先标记所有需要回收的对象,在标记完成后,同一回收掉被标记的对象。或者标记存活的对象,然后回收未被标记的对象。

标记-清除算法的缺点主要是内存空间的碎片化问题,在标记、清除后产生的大量碎片可能会导致没有足够的连续内存留给较大对象,从而不得不提前触发(剩余内存总和大于当前对象所需内存,但没有连续空间了)另一次垃圾收集动作。

2.3 标记-复制算法

其雏形是“半区复制”,将可用内存分为两半,每次只使用其中一半,当这一块内存用完了,就将存货对象复制到另一半上,然后将原来那一半空间清理掉。

半区复制的缺点显然是空间利用率太低了,因此根据 2.1 中弱分代假说的“朝生夕灭”特点,提出了“Appel式回收”。HotSpot 虚拟机的 SeraiParNew 等新生代收集器都是使用的这个策略。具体做法是把新生代分为一块较大的 Eden 空间和两个较小的 Survivor 空间,每次只使用 Eden 和其中一块 Survivor 空间。当垃圾回收发生时,将 EdenSurvivor 中还存活的对象一次性复制到另一个 Survivor 空间中,然后一次性清理掉原来的 SurvivorEden 空间。

EdenSurvivor 空间默认是 8:1:1,如果 Survivor 空间不足以容纳一次 Minor GC 后的存活对象时,将依赖老年代所在区域进行分配担保

2.4 标记-整理算法

标记-整理算法用于在对象存活率较高的情况下取代标记-复制算法,因此成为老年代回收常用算法。

首先标记所有需要回收的对象,在标记完成后,让所有存活的对象都朝内存空间一端移动,然后清理掉边界以外的内存。值得注意的是,这种移动是一种负担极大的操作,移动对象时需要全程暂停用户应用程序才行,所以又被称为“Stop The World”。

但如果不考虑移动而采用标记-清除算法的话,就需要通过“分区空闲分配链表”来解决内存分配问题,而频繁的内存访问也会使得这一环节增加了额外的负担,影响吞吐量。

关注吞吐量的 Parallel Old 收集器是基于标记-整理算法的,而关注延迟的 CMS 收集器则是基于标记-清除算法的。确切地说,后者是基于两种算法相结合的思路,在平常大多数时候使用标记-清除算法,直到内存空间碎片化到一定程度后,再采用标记-整理算法执行一次。

3.经典垃圾回收器

经典是指在JDK 7(G1收集器正式提供商用)之后,JDK 11之前,Oracle JDK中包含的全部可用垃圾回收器。

GC

图中展示了七种收集器,如果两个收集器之间存在连线,则表示它们可以搭配使用。其中,需要重点了解的是 CMSG1 这两个相对复杂而又用途广泛的收集器。

3.1 Serial 收集器

看名字就知道,这是个单线程工作的收集器,而且是基于复制算法的。它的特点是在它进行垃圾回收时,其他所有线程都要停止工作。我们把这个暂停工作线程并进行回收的过程称为 Stop the World,而把进入这一过程的时间点称为 Safepoint

Serial 收集器只适合资源受限的环境,由于只需要关心垃圾回收而不用管线程交互,能够获得最高的单线程收集效率。

3.2 ParNew 收集器

可以把它当作Serial收集器的“多线程并行”版本,同样是基于复制算法,且同一时间可以有多个收集线程并行运行(多核环境下)。

此外,ParNew还是除Serial收集器外唯一可以与CMS收集器配合的。CMS收集器是面向老年代的,如果要想运行该收集器可以使用参数-XX:UseConcMarkSweepGC,从名字就可以看出来它是使用的标记整理算法,这一点在2.4中也有提到过。

不过,后来由于G1问世,ParNew被限制成只能和CMS搭配使用了。

3.3 Parallel Scavenge 收集器

Parallel Scavenge 仍然是一款面向新生代且基于复制算法并行收集器,可以把它当作 ParNew 2 号收集器。它的特点是更关注吞吐量,而不是用户线程的停顿时间(S-T-W,Stop the World)。

\[\big.吞吐量 =\big. \frac{用户运行代码时间}{运行用户代码时间 + 运行垃圾收集时间}\]

3.4 Serial Old 收集器

Serial 收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法

3.5 Parallel Old 收集器

Parallel Scavenge 的老年代版本,支持多线程并发收集,使用标记-整理算法

3.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法。

它的整个运作过程可以分为四个步骤

(01)初始标记:标记与GC Roots直接关联的对象;

(02)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程;

(03)重新标记:修复并发标记期间,因为用户程序继续运作而导致标记产生变动的那一部分对象;

(04)并发清除:清理删除标记判断为死亡的对象。

其中,初始标记和重新标记仍需要“Stop the World”,但所幸这两个部分的工作很少。并发标记阶段和并发清除阶段耗时较长,但允许用户线程和垃圾回收线程并发运行,不用停顿。

CMS尽管获得了“并发低停顿收集器”的美名,但它仍然有三个明显缺点

(01)对处理资源敏感,CMS默认启动回收线程数是(处理器核心数 + 3)/ 4,这意味着处理器内核数量不足4个时,CMS对处理器负载将变得很高。

(02)无法处理“浮动垃圾”,在CMS并发处理的两个阶段,回收线程和用户线程是并发运行的,用户线程产生的垃圾对象无法在当次回收阶段处理,只能留给下次回收清理,它们就被称为“浮动垃圾”。因此,CMS必须要预留一部分空间给它们,当到达一定百分比就要触发Major GC(可以通过-XX:CMSInitiatingOccupancyFraction调整触发回收的百分比)。

(03)第三个缺点是由于CMS使用的标记-清除算法本身缺陷造成的,它会产生大量空间碎片,导致Full GC的提前出现。而尽管后来将内存碎片合并同Full GC相整合,但停顿时间又会变长了,这显然没有达到CMS设计者想要的效果。

3.7 Garbage First 收集器

参考别人的博客

4.内存分配回收流程

基于 Serial/Seial Old,进行一次简单的内存分配全流程描述。

4.1 对象优先在 Eden 分配

通常对象都会被丢到 Eden 区中分配,如果 Eden 区要满了,内存不足,虚拟机会先进行一次 Minor GC 给新对象腾出地方。当然,Eden 区只是新生代的一部分,另外还有两个较小的 Survivor 区。它们之间的比例可以通过参数-XX:SurvivorRatio=?来配置,通常默认是8,也就是说 E:S1:S2 = 8:1:1

Survivor 的作用就是在新生代进行·时给存活对象提供一个“避风港”,且每存活一次,就会在对象的对象头里增加“一岁”。

4.2 大对象直接进入老年代

字面意思,当超过一定大小的对象将直接进入老年代,我们可以用参数 -XX:PretenureSizeThreshold= ?手动设置。(tenure 这个单词是终身教授,加个 pre 代表提前等着领铁饭碗,后面Threshold意思是阈值,当作门槛来理解就是了。)

4.3 长期存活的对象将进入老年代

在 4.1 中提到了每存活一次,就会在对象的对象头里增加“一岁”。而如果年龄超过了一定大小,终于熬出头了,就会进入老年代。一般这个值 默认是15,不过我们也可以用参数 -XX:MaxTenuringThreshold=? 手动设置。

4.4 动态对象年龄判定

如果在 Survivor 空间中小于或等于同一年龄的所有对象大小的总和大于 Survivor 空间的一半(想象有一半人都是七星斗宗),年龄大于或等于该年龄的对象就可以直接进入老年代,无须苦熬。

4.5 空间分配担保

之前有讲到,进行 Minor GC 时,如果一个 Survivor 装不下全部存活对象,就会触发“空间分配担保”机制,让老年代分摊一些压力。

不过空间分配也是有前提的,在 Minor GC 进行前就会检查老年代最大的可用连续空间是否大于新生代所有对象总空间。如果成立,则一切正常。如果这个条件不成立,则会继续检查该连续空间是否大于历次晋升到老年代对象的平均对象大小,相当于风险评估。如果成立,则进行 Minor GC。如果仍然不满足,则必须进行一次 Full GC 了。