# JVM实战:如何判断哪些对象可以回收?

在这里插入图片描述

# 哪些对象是存活的?

在 JVM 运行时数据区中,程序计数器、虚拟机栈、本地方法栈随线程朝生夕灭,不需要过多考虑回收;垃圾回收关注的主要是堆和方法区的内存回收。当我们进行垃圾回收的时候,首先需要判断哪些对象是存活的?

常用的方法有如下两种

  1. 引用计数法
  2. 可达性分析法

Python判断对象存活的算法用的是引用计数法,而Java则使用的是可达性分析法。

# 引用计数法

引用计数法(Reference Counting):给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,任何时刻计数器为0的对象就是不可能被再使用的。

但这种方法算法很难解决对象之间相互循环引用的问题。举个例子

对象objA和objB都有字段instance,赋值令objA.instance=objB,以及objB.instance=objA,除此之外这2个对象再无任何引用,实际上这2个对象已经不可能再被访问,但是他们因为互相引用这对方,导致他们的计数器都不为0,于是引用计数法无法通知GC收集器回收他们

示例代码如下

public class ReferenceCountingGC {

    public Object instance;

    public ReferenceCountingGC(String name){}
}

public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
}

# 可达性分析法

可达性分析算法(Reachability Analysis):通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。

在这里插入图片描述

如图,object5,object6和object7虽然互相有关联,但是他们到GC Roots是不可达的,所以他们会被判定为是可回收的对象。

所以什么样的对象需要回收呢?

对象到GC Root没有引用链,那么这个对象不可用,需要回收。Java就是用可达性分析法来判断对象是否需要被回收的

不可达对象会进行2次标记的过程,通过GC ROOT不可达,会被第一次标记。如果需要执行finalize()方法,则这个对象会被放入一个队列中执行finalize(),如果在finalize()方法中成功和引用链上的其他对象关联,则会被移除可回收对象集合(一般你不建议你使用finalize方法

常见的GC ROOT有如下几种

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(Native方法)引用的对象

虚拟机栈(栈帧中的本地变量表)中引用的对象

public void execute() {
    // 这里的 user 对象就是虚拟机栈中的局部变量,它是一个 GC Root
    User user = new User("张三"); 
    System.out.println(user.getName());
} // 方法执行完毕,user 退出栈帧,不再是 GC Root,垃圾回收器就可以回收它了

方法区中类静态属性引用的对象

被 static 关键字修饰的成员变量所引用的对象

public class UserService {
    // 这里的 cacheMap 是一个静态变量,属于类本身。
    // 只要 UserService 这个类还在,cacheMap 指向的 Map 对象就是 GC Root,永远不会被回收
    private static Map<String, Object> cacheMap = new HashMap<>();
}

方法区中常量引用的对象

被 final 和 static 同时修饰的常量引用的对象(比如字符串常量池里的引用)

public class Config {
    // 这个 CONSTANT_USER 对象是一旦初始化就不会改变的常量,它是全局的 GC Root
    public static final User CONSTANT_USER = new User("系统管理员");
}

本地方法栈中引用的对象

和虚拟机栈类似,一个是虚拟机层面的调用,一个是本地层面的调用

如何判断是不是GC Root?

如果删掉它,当前正在运行的方法会报错,或者全局的公共配置会崩溃,那它就是 GC Root

如果删掉它,只是某个已经执行完的方法里的历史遗留物,或者没有任何人能访问到它——那它就不是 GC Root,可以安心等待被回收

# 四种引用类型

为了让程序员能够更灵活地控制对象的生命周期,并提高垃圾回收(GC)的效率,Java从JDK 1.2开始引入了四种引用类型

这四种引用类型由强到弱依次为:强引用、软引用、弱引用和虚引用

# 强引用

这是我们平时编程中最常见的引用类型。只要一个对象被强引用关联,垃圾回收器就永远不会回收它。

语法示例

Object obj = new Object(); // 这就是一个强引用

GC回收时机: 绝不回收。即使内存不足,JVM宁愿抛出 OutOfMemoryError (OOM) 错误使程序异常终止,也不会回收具有强引用的存活对象

如何释放:显式地将引用赋值为 null,或者等待离开其作用域

obj = null; // 此时对象变为不可达,可以被GC回收

# 软引用

软引用用来描述一些有用但非必需的对象。它非常适合用来实现缓存(比如网页缓存、图片缓存)

语法示例

SoftReference<String> softRef = new SoftReference<>(new String("Hello"));

GC回收时机:系统内存不足时

  1. 如果系统内存充足,垃圾回收器不会回收它,程序可以正常使用该对象
  2. 如果系统内存即将发生溢出(OOM之前),垃圾回收器就会把这些软引用对象回收掉

应用场景: 网页缓存、图片缓存等。当内存足够时提高访问速度,内存不够时自动释放,防止崩溃

# 弱引用

弱引用的强度比软引用更弱。它同样用来描述非必需的对象,但它的生命周期更短

语法示例

WeakReference<String> weakRef = new WeakReference<>(new String("World"));

GC回收时机: 下一次垃圾回收时。 无论当前内存是否充足,只要垃圾回收器开始工作,一旦发现了弱引用对象,就会直接将其回收

应用场景: 防止内存泄漏。最典型的应用是 WeakHashMap 和 ThreadLocal(其内部的 ThreadLocalMap.Entry 继承了弱引用)

# 虚引用

虚引用也叫“幽灵引用”或“幻影引用”,它是最弱的一种引用关系

语法示例: 虚引用必须和引用队列 (ReferenceQueue) 联合使用

ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

特殊之处: 1. 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。 2. 你无法通过虚引用来获取一个对象的实例(调用 phantomRef.get() 总是返回 null)

应用场景: 它唯一的目的就是在这个对象被垃圾回收器回收时收到一个系统通知。主要用于追踪对象被垃圾回收的活动,或者管理堆外内存(如 DirectByteBuffer 的资源释放)

在使用软引用(或弱引用)包含的对象时,必须先用一个强引用把它“接住”,并进行判空处理

SoftReference<String> softRef = new SoftReference<>(new String("Hello"));

// 1. 用一个【强引用】str 去接住它
String str = softRef.get(); 

// 2. 检查对象是否还活着
if (str != null) {
    // 【安全区】在 if 块内部,由于强引用 str 的存在,GC 绝对不敢回收这个对象
    System.out.println("数据还活着: " + str.length());
} else {
    // 【回收区】对象已经被回收了,需要启动备用方案(比如重新从数据库/网络加载)
    System.out.println("数据已被回收,正在重新加载...");
    str = "重新生成的Hello"; 
    softRef = new SoftReference<>(str); // 重新缓存
}

# 三色标记法

在 Java 的垃圾回收过程中,为了减少 STW(Stop The World,即让用户线程完全停下来) 的时间,现代垃圾回收器(如 CMS、G1、ZGC 等)都引入了并发标记。也就是说,垃圾回收线程和你的代码线程可以同时运行。

在并发标记的过程中,垃圾回收器用来给对象做标记的经典算法就是“三色标记法”

三色标记是一个从根节点(GC Roots)开始向下层层扫描的过程

初始状态:所有的对象都是白色

初始标记(STW):直接被 GC Roots 引用的对象被染成灰色

并发标记(与用户线程并行)

  • 垃圾回收器从灰色对象开始,顺着它们的引用关系往下找
  • 找到一个白色对象,就把这个白色对象染成灰色
  • 当一个灰色对象所有的直接下游引用都被扫描完后,这个灰色对象自己就会变成 黑色

最终状态:当所有灰色对象都变成黑色时,扫描结束。此时:

  • 黑色对象 存活
  • 白色对象 就是无人认领的垃圾,会被直接清理掉
颜色 解释
白色 刚开始遍历的时候所有对象都是白色的
灰色 被垃圾回收器访问过,但至少还有一个引用未被访问
黑色 被垃圾回收器访问过,并且这个对象的所有引用都被访问过,是安全存活的对象(GC ROOT会被标记为黑色)

请添加图片描述

以上图为例,三色标记法的执行流程如下

  1. 先将GC ROOT引用的对象B和E标记为灰色
  2. 接着将B和E引用的对象A,C和F标记为灰色,此时B和E标记为黑色
  3. 依次类推,最终被标记为白色的对象需要被回收

# 并发标记的问题:错标和漏标

可达性分析算法根节点枚举这一步必须要在一个能保障一致性的快照中分析,所以要暂停用户线程(Stop The World ,STW),在各种优化技巧的加持下,停顿时间已经非常短了。

在从根节点扫描的过程则不需要STW,但是也会发生一些问题。由于此时垃圾回收线程和用户线程一直运行,所以引用关系会发生变化

  1. 应该被回收的对象被标记为不被回收
  2. 不应该被回收的对象标记为应该回收

# 应该被回收的对象被标记为不被回收(错标)

场景:一个对象被扫描成黑色(存活)后,你的代码突然执行了 obj = null,断开了对它的引用

后果:这个对象本来变成垃圾了,但因为已经是黑色,这一轮 GC 就不会动它 第一种情况影响不大,大不了后续回收即可

代价:无所谓,只是变成“浮动垃圾”多活了一轮,下一轮 GC 把它回收掉就行,不影响程序正确性

# 不应该被回收的对象标记为应该回收(漏标)

场景:这是最可怕的情况。必须同时满足以下两个条件才会发生

  1. 灰色对象断开了对某个白色对象的引用
  2. 某个黑色对象突然连接了这个白色对象

后果:垃圾回收器以为灰色对象没有儿子了,就把灰色变成黑色。而那个黑色对象因为已经扫描完了,不会再扫描一次。导致那个被收养的白色对象一直保持白色,直到标记结束被当成垃圾删掉!

代价:程序莫名其妙丢失了正在使用的对象,直接 NullPointerException 甚至系统崩溃

# 如何解决漏标?(读写屏障)

为了解决漏标,JVM 引入了 读写屏障(Read/Write Barrier),通过拦截对象引用的变化来打补丁。

为了解决这个问题,我们破坏2个条件中任意一个不就行了,由此产生了2中解决方案,增量更新原始快照

# 增量更新—— 盯住黑色

原理:只要有黑色对象去引用白色对象,写屏障就会把这个引用的新关系记录下来。

做法:并发标记结束后,垃圾回收器会把这些被黑色引用的白色对象重新涂成灰色,在 STW 的重新标记阶段再扫描一次。

代表作:CMS 垃圾回收器

# 原始快照—— 盯住灰色

原理:关注那些被断开的引用。在并发标记开始时,给当时的引用关系拍张“快照”。

做法:只要灰色对象想断开跟白色对象的引用,写屏障就会把这个旧的引用关系记录下来。垃圾回收器会顺着这个销毁的记录,依然把那个白色对象视作存活(涂成灰色),任性的让它活过这一轮。(参考最开始的快照来判断需要涂成灰色的对象)

代表作:G1、Shenandoah 垃圾回收器

# 分代和跨代引用

程序中的GC ROOT有很多,每次垃圾回收都要对GC ROOT的引用链分析一遍,感觉耗费的时间很长啊,有没有可能减少每次扫描的GC ROOT?

其实当前虚拟机大多数都遵循了“分代收集”理论进行设计,它的实现基于2个分代假说之上

  1. 绝大多数对象都是朝生夕灭的
  2. 熬过多次垃圾收集过程的对象就越难以消亡

因此堆一般被分为新生代和老年代,针对新生代的GC叫MinorGC,针对老年代的GC叫OldGC。但是分代后有一个问题,为了找到新生代的存活对象,不得不遍历老年代,反过来也一样

请添加图片描述

当进行MinorGC的时候,如果我们只遍历新生代,那么可以判定ABCD为存活对象。但是E不会被判断为存活对象,所以就会有问题。

为了解决这种跨代引用的对象,最笨的办法就是遍历老年代的对象,找出这些跨代引用的对象。但这种方式对性能影响较大

这时就不得不提到第三个假说

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

根据这条假说,我们就不需要为了少量的跨代引用去扫描整个老年代。为了避免遍历老年代的性能开销,垃圾回收器会引入一种记忆集的技术,记忆集就是用来记录跨代引用的表

如新生代的记忆集就保存了老年代持有新生代的引用关系

所以在进行MinorGC的时候,只需要将包含跨代引用的内存区域加入GC ROOT一起扫描就行了

# 卡表

前面我们说到垃圾收集器用记忆集来记录跨代引用。其实你可以把记忆集理解为接口,卡表理解为实现,类比Map和HashMap。

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

CARD_TABLE [this address >> 9] = 0;

请添加图片描述

HotSpot用一个数组元素来保存对应的内存地址是有有跨代引用对象(从this address右移9位可以看出每个元素映射了512字节的内存)

当数组元素值为0时表明对应的内存地址不存在跨代引用对象,否则存在(称为卡表中这个元素变脏)

# 如何更新卡表?

将卡表元素变脏的过程,HotSpot是通过写屏障来实现的,即当其他代对象引用当前分代对象的时候,在引用赋值阶段更新卡表,具体实现方式类似于AOP

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