别怪 GC 不回收你:可能是你的内部类太粘人了

Java 21

最近公司组织了一场代码检视活动,群星璀璨,各路代码专家齐聚一堂,准备对产品部的代码库进行一次"健康体检"。按照规则,专家们会将发现的"坏味道"代码粘贴到在线表格中,最终由管理部门整理成一份代码质量报告。

脱离研发序列多年,竟然也"幸运地"被授予了代码库的检视权限,就这样我怀着一丝忐忑,踏上了我的代码找茬之旅。

然后,不负众望,我发现了我们今天的主角:

java 复制代码
@Data
public class ConnectEventReqDto {
    // ...
    private DetailData data;

    @Data
    public class DetailData { 
        // ...
    }
}

第一眼看去,这段代码结构清晰,似乎无可挑剔。但仔细审视,DetailData的定义方式------成员内部类,由于它隐式持有外部类实例的强引用,像一位潜伏在代码中的"内存刺客",在特定场景下会给我们的系统带来意想不到的内存泄漏风险。

本文将以这个小小的内部类为引子,不仅会分析其设计上的不妥之处,还将顺藤摸瓜,引出一个非常实际的工程问题:如果业务场景确实需要内部类访问外部实例,但又想避免这种强引用带来的内存泄漏风险,我们该怎么办?难道只能放弃这种设计吗?

当然不是。幸运的是,Java 的设计者早已为我们提供了强大的工具箱来应对这类复杂的内存管理场景。这个工具箱,就是 Java 中的引用体系。

1. 内部类的"貌合神离"------成员内部类 vs. 静态内部类

在 Java 中,将一个类定义在另一个类之内,主要分为两种:成员内部类 (Member Inner Class)和静态内部类 (Static Nested Class)。它们看似只是差了一个static关键字,实则在内存模型和生命周期上"貌合神离"。

1.1 概念辨析

成员内部类,顾名思义,是定义在另一个类的成员位置、且没有 static修饰的类。它就像是外部类的一个"成员",与实例变量、实例方法地位相同。隐式持有外部类实例的引用 是成员内部类最核心、也是最关键的特质。每个成员内部类的实例,都会在底层默默地持有一个创建它的外部类实例的引用。这意味着,只要成员内部类的实例还存活,它的外部类实例就绝对不会 被 GC 回收。依赖外部实例创建是其第二大特质。你不能独立创建成员内部类的实例,它的创建必须依托于一个已经存在的外部类实例。

静态内部类,是使用static关键字修饰的内部类。从行为上看,它更像是一个被"包装"在外部类里的顶级类(Top-level Class) ,只是在命名空间上属于外部类。不持有外部类实例的引用 是静态内部类与成员内部类最根本的区别。静态内部类的实例与外部类的任何实例都没有关系,它的生命周期是完全独立的。换句话说,可独立创建静态内部类实例而不需要依赖外部类的实例。

特性 成员内部类 静态内部类
外部类实例引用 隐式持有 不持有
创建方式 outerInstance.new Inner() new Outer.Inner()
访问外部类成员 可访问所有成员(实例+静态) 仅可访问静态成员
内存影响 ⚠️ 有内存泄漏风险 安全
序列化时是否会连同外部类一并序列化 ⚠️

接下来,我们来验证下"成员内部类隐式持有外部类实例的引用"这一结论。

java 复制代码
public class Outer {
    private String outerNonStaticField = "Outer Non-Static Field";

    public class MemberInner {
        public void printOuterField() {
            System.out.println(outerNonStaticField);
        }
    }
}

Outer类中有一实例属性outerNonStaticField,在其成员内部类MemberInner中访问了该属性。编译生成class文件后,执行javap -p 'Outer$MemberInner'指令以反汇编成员内部类的字节码,内容如下:

java 复制代码
public class io.github.innerclazz.Outer$MemberInner {
  final io.github.innerclazz.Outer this$0;
  public io.github.innerclazz.Outer$MemberInner(io.github.innerclazz.Outer);
  public void printOuterField();
}

javap的输出清晰地显示,编译器在MemberInner类中自动生成了一个名为this$0final字段,它的类型正是Outer。这个字段就是用来存储那个隐藏的、指向外部类实例的强引用。也正因如此,成员内部类才能访问外部类的实例成员

假如将Outer类的实例字段替换为静态字段,会发生什么?如下所示:

java 复制代码
public class Outer {
    private static String outerStaticField = "Outer Static Field";

    public class MemberInner {
        public void printOuterField() {
            System.out.println(outerStaticField);
        }
    }
}

同理,再次通过javap查看字节码内容:

java 复制代码
public class io.github.innerclazz.Outer$MemberInner {
  public io.github.innerclazz.Outer$MemberInner(io.github.innerclazz.Outer);
  public void printOuterField();
}

细心的你也一定发现了!编译器没有 再为MemberInner类自动生成一个名为this$0的字段了,而只是生成了一个需要传入Outer实例作为参数的构造方法。为什么this$0字段没了呢?这是来自编译器的"无用代码消除 "(Dead Code Elimination)优化。具体地,编译器发现这个传进来的Outer实例虽然在构造方法中被赋给了隐藏的this$0字段,但是在成员内部类MemberInner的任何其他地方从未被读取过,this$0成了一个"只写不读"的字段。对于一个永远不会被读取的字段,保留它就成了累赘。因此,编译器直接将这个无用的字段从最终生成的字节码中移除,以节省一点点的内存空间。


最后,再来聊一聊成员内部类的序列化。Java 序列化是将一个对象图 (Object Graph)转换为字节流的过程。即当你序列化一个对象A时,Java 序列化机制会检查A的字段,如果某个字段是对另一个对象B的引用,那么它会递归地去序列化对象B,这个过程会一直持续下去,直到所有可达的对象都被写入字节流。

我们已经知道,成员内部类的实例在底层隐式地、强制地持有一个创建它的外部类实例的引用。当您尝试序列化一个成员内部类的实例时,灾难性的连锁反应开始了:

  1. 序列化系统开始处理您的内部类实例。
  2. 它发现了那个隐藏的this$0强引用字段。
  3. 根据序列化规则,它必须去序列化这个引用所指向的对象------也就是外部类的整个实例
  4. 接着,它会继续序列化外部类实例所引用的所有其他对象......

这会导致两个严重的问题:

  • ⚠️ 序列化了预期之外的敏感数据:您可能只想保存一个小小的成员内部类对象的状态,结果却把外部类实例状态也写入了流中,如果此时外部类实例包含敏感字段信息呢?字节流被写入文件、存入数据库或通过网络传输,就等同于将敏感字段的内容公之于众,造成了严重的信息泄露。
java 复制代码
public class Outer implements Serializable {
    private String secret = "top-secret";

    public class Body implements Serializable {
        private String data;
        
        // constructor, getters, setters...
    }
}
  • ⚠️ NotSerializableException 陷阱 :如果那个被无辜牵连的外部类恰好没有实现Serializable接口,那么整个序列化过程会立即失败,直接抛出NotSerializableException------即使您的内部类本身已经正确实现了Serializable接口。这是新手非常容易遇到的一个坑。

1.2 代码诊断:DetailData错在何处?

回到我们的主角身边,DetailData它最恰当的身份应该是静态内部类 ,或者直接作为一个独立的顶级类。被定义成了一个成员内部类,这是一个典型且常见的误用 。一是堆内存占用相对会更多;二是在DetailData没有访问外部类ConnectEventReqDto实例成员的诉求下,没苦硬吃,徒增构造DetailData实例的复杂度。

仅此而已!最终检视意见并没有提到"内存泄漏"这几个字眼,翻遍整个代码仓库未曾发现把ConnectEventReqDto中的data对象取出来并传递给了一个生命周期很长的任务,比如把它缓存到了一个静态的Map中,或者交由一个后台线程去处理。比如向下面这样:

java 复制代码
public class CacheManager {
    // 一个静态缓存,生命周期和整个应用一样长
    private static Map<String, Object> cache = new HashMap<>();

    public void cacheData(ConnectEventReqDto req) {
        // 从请求中获取data对象
        ConnectEventReqDto.DetailData detailData = req.getData();
        // 将这个成员内部类的实例放入了静态缓存
        cache.put("some_key", detailData);
    }
}

此时,一条危险的引用链条形成了:

Static Cache Map -> DetailData 实例 -> ConnectEventReqDto 实例

2. 驯服"内存刺客"的银弹------Java引用体系

在先前的讨论中,我们阐明了一个基本事实:成员内部类隐式地持有一个对外部类实例的强引用。这种内在的依赖关系意味着,若该成员内部类的实例被一个具有较长生命周期 的对象持有,极有可能导致外部类实例 无法被 GC 回收,进而引发内存泄漏。开篇也曾经提到一个工程问题:如果业务场景确实需要内部类访问外部实例,但又想避免隐式"强引用"这个霸道机制所带来的外部类实例内存泄漏风险,我们该怎么办?一个常见的做法是:将对外部类实例的引用包装为WeakReference,以打破强引用链,从而允许外部类在不再被其他对象引用时被 GC 正常回收。

那么问题来了,下面两个代码片段究竟哪个才是正确的呢?

代码片段一

csharp 复制代码
public class Outer {
    private String normalInfo = "外部类信息";
    public class MemberInner {
        private String innerInfo = "内部类信息";
        private WeakReference weakReference;
        public MemberInner() {
            weakReference = new WeakReference<>(Outer.this);
        }
        public void showInfo() {
            System.out.println("内部信息: " + innerInfo);
            System.out.println("外部信息: " + weakReference.get().normalInfo);
        }
    }
}

代码片段二

csharp 复制代码
public class Outer {
    private String normalInfo = "外部类信息";
    public static class MemberInner {
        private String innerInfo = "内部类信息";
        private WeakReference<Outer> weakReference;
        public MemberInner(Outer outer) {
            weakReference = new WeakReference<>(outer);
        }
        public void showInfo() {
            System.out.println("内部信息: " + innerInfo);
            System.out.println("外部信息: " + weakReference.get().normalInfo);
        }
    }
}

代码片段一是错误的

只要MemberInner成员内部类的定位不发生改变,编译器的行为就不会发生变化。在这种情况下,成员内部类必然会生成一个名为this$0 的隐式强引用,指向其外部类实例。这样一来,原本期望通过WeakReference实现弱引用持有的意图就变得形同虚设,WeakReference也就沦为了"装饰品"。更进一步地,同时维护强引用和弱引用的做法本身也显得自相矛盾。
开发者可以通过执行javap -v 'Outer$MemberInner'命令来查看详细的字节码信息,从而验证这一结论。我之前曾尝试使用getDeclaredFields()方法去反射获取MemberInner类中的this$0 字段,但发现并没有任何输出。这其实是由于编译器进行了优化所致。如果我们将showInfo()方法中对weakReference.get()的调用改为直接通过强引用来访问外部类的成员,就可以绕过这种编译器优化,最终也能通过反射观察到该字段的存在。


理解了内部类与外部类之间的引用陷阱之后,让我们回到本章的核心议题------Java 引用体系本身,下面正式开展!

java.lang.ref包提供了引用对象类 ,这些类支持与 GC 进行有限程度的交互。程序可以使用引用对象(如:SoftReference)来维护对其他对象的引用(referent),从而使得后者仍可被 GC 回收。java.lang.ref 包面向开发者提供了三种引用对象,每种都比前一种更"弱":软引用 (soft)、弱引用 (weak)和虚引用 (phantom)。软引用用于实现内存敏感的缓存(memory-sensitive caches)。弱引用用于实现规范化映射(canonicalizing mappings),这类映射不会阻止其键(或值)被回收。虚引用用于安排对象回收后的清理操作(post-mortem cleanup actions),这些清理操作可以通过Cleaner类注册和管理。

应用程序可以在创建引用对象时,通过将其注册 到一个引用队列,从而在 referent 的可达性发生变化可以收到通知。当 GC 确定 referent 的可达性已经变为与引用类型相对应的状态时,它会清除 referent 并将其添加到关联的队列中。程序可以通过轮询ReferenceQueue.poll()或阻塞等待ReferenceQueue.remove()的方式从引用队列中获取(移除)引用对象。引用队列由ReferenceQueue类实现。

引用类型 referent 的 GC 时机
软引用 在将要发生OutOfMemoryError前,GC 会尝试回收 referent,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用 referent 只能生存到下一次 GC 发生为止。当 GC 开始工作,无论当前内存是否足够,都会回收掉弱可达的 referent。
虚引用 referent 最多生存到下一次 GC 发生为止。当 GC 开始工作,无论当前内存是否足够,都会回收掉虚可达的 referent。

针对"弱引用"和"虚引用",个人认为无需纠结的 referent 的准确回收时机,只要从引用队列中收到了"通知",就可以认为已经被 GC 掉了!

应用程序在构建软引用对象和弱引用对象时,注册一个引用对象并不是必须的,开发者可以通过定期检查ref.get() == null 来判断它们所引用的对象是否已被回收;这种方式虽然效率不高,但确实可以在没有引用队列的情况下可以实现基本功能。与软/弱引用不同,虚引用的 get() 方法永远返回 null 。这意味着,你无法通过 get() 方法来判断虚引用所指向的对象是否已被回收。因此,构建虚引用对象时注册引用队列是虚引用发挥作用的唯一途径。只有当虚引用被 GC 放入队列时,你才能得知其引用的对象已经变得虚可达,从而触发清理操作!

💡值得一提的是,引用对象与其队列之间的关系是单向的 。也就是说,引用对象可以注册到引用队列,但引用队列不会主动记录或维护哪些引用对象曾注册过(不持有强引用)。因此如果引用对象本身不可达,即使它曾注册到引用队列,GC 也不会保留它,更不会将其入队。如果使用引用对象的程序对监听 referent 的回收事件感兴趣,那么就必须显式持有引用对象的强引用,以避免引用对象被 GC 提前回收

GC Root是 GC 判断对象是否存活的起点,常见包括方法中的局部变量、static 修饰的静态变量、final static 修饰的常量等。如果某个对象无需通过其他引用对象链,即可由 GC Root 直接访问,则该对象被认为是强可达(Strongly Reachable)的。除了强可达之外,Java 还定义了其他几种更弱的可达性级别,用于支持更加灵活的内存管理策略。从最强到最弱依次为:

可达性层级 对应引用类型 说明
强可达 无(默认) 对象可通过 GC Root 直接访问(无需遍历任何引用对象)。
软可达 软引用 对象不可强可达,但可通过软引用访问。
弱可达 弱引用 对象不可强/软可达,但可通过弱引用访问。
虚可达 虚引用 对象不可强/软/弱可达,且也无法通过虚引用访问。
不可达 对象无法通过任何路径访问,可被直接回收。

2.1 Reference 原理解读

2.1.1 引用对象的 GC 清理机制

在翻阅 openjdk/jdk21 源码时,发现不同垃圾收集器实现了各自的引用处理器:Shenandoah GC 有shenandoahReferenceProcessor,ZGC 有zReferenceProcessor。本文选择聚焦于gc/shared/referenceProcessor进行核心原理分析,因为无论底层采用哪种 GC 实现,肯定要遵循官方类库 java/lang/ref 目录下java/lang/ref/package-info.java文件所声明的Package Specification

scss 复制代码
1. 顶层方法:ReferenceProcessor::process_discovered_references()
   ├─ 这是处理引用对象的入口
   ├─ 代码:
   │  void ReferenceProcessor::process_discovered_references(...) {
   │    process_soft_weak_final_refs(proxy_task, phase_times); // 处理软/弱/终结引用
   │    process_final_keep_alive(proxy_task, phase_times);     // 处理终结引用的keep-alive
   │    process_phantom_refs(proxy_task, phase_times);         // 处理虚引用
   │  }
   │
   ├─ 处理流程:
   │  1. 先处理软/弱/终结引用 (process_soft_weak_final_refs)
   │  2. 特殊处理终结引用的keep-alive (process_final_keep_alive)
   │  3. 最后处理虚引用 (process_phantom_refs)
   │
   ├─ 注意:这三个方法最终都会委托到底层的work方法

2. 软/弱/终结引用处理入口:process_soft_weak_final_refs()
   ├─ 任务核心实现:RefProcSoftWeakFinalPhaseTask::rp_work()
   │  ├─ 代码:
   │  │  class RefProcSoftWeakFinalPhaseTask: public RefProcTask {
   │  │  public:
   │  │    void rp_work(...) override {
   │  │      process_discovered_list(..., REF_SOFT, ...);
   │  │      process_discovered_list(..., REF_WEAK, ...);
   │  │      process_discovered_list(..., REF_FINAL, ...);
   │  │    }
   │  │  };
   │  │
   │  ├─ 处理逻辑(依次处理三种引用类型):
   │  │  1. 软引用(REF_SOFT)
   │  │  2. 弱引用(REF_WEAK)
   │  │  3. 终结引用(REF_FINAL)
   │  │
   │  └─ 注意:所有类型都调用 process_discovered_list()

3. 虚引用处理入口:process_phantom_refs()
   ├─ 任务核心实现:RefProcPhantomPhaseTask::rp_work()
   │  ├─ 代码:
   │  │  class RefProcPhantomPhaseTask: public RefProcTask {
   │  │  public:
   │  │    void rp_work(...) override {
   │  │      process_discovered_list(..., REF_PHANTOM, ...);
   │  │    }
   │  │  };
   │  │
   │  └─ 处理逻辑:
   │     专用于处理虚引用(REF_PHANTOM),同样调用process_discovered_list()

4. 引用类型分发器:RefProcTask::process_discovered_list()
   ├─ 核心功能:根据引用类型选择对应的发现列表
   ├─ 代码:
   │  void RefProcTask::process_discovered_list(...) {
   │    DiscoveredList* dl;
   │    switch (ref_type) {
   │      case ReferenceType::REF_SOFT:
   │        dl = _ref_processor._discoveredSoftRefs;
   │        break;
   │      case ReferenceType::REF_WEAK:
   │        dl = _ref_processor._discoveredWeakRefs;
   │        break;
   │      case ReferenceType::REF_FINAL:
   │        dl = _ref_processor._discoveredFinalRefs;
   │        break;
   │      case ReferenceType::REF_PHANTOM:
   │        dl = _ref_processor._discoveredPhantomRefs;
   │        break;
   │      default:
   │        ShouldNotReachHere();
   │    }
   │
   │    // 关键区别:终结引用特殊处理
   │    bool do_enqueue_and_clear = (ref_type != REF_FINAL);
   │    _ref_processor.process_discovered_list_work(..., do_enqueue_and_clear);
   │  }
   │
   ├─ 处理逻辑:
   │  1. 根据引用类型选择对应的(本地)发现列表
   │     - 软引用 → _discoveredSoftRefs
   │     - 弱引用 → _discoveredWeakRefs
   │     - 终结引用 → _discoveredFinalRefs
   │     - 虚引用 → _discoveredPhantomRefs
   │  2. 设置关键标志位:do_enqueue_and_clear
   │     - 非终结引用(软/弱/虚):true
   │     - 终结引用:false
   │  3. 委托给核心 process_discovered_list_work 方法

5. 核心处理逻辑1:ReferenceProcessor::process_discovered_list_work()
   ├─ 处理非终结引用(软/弱/虚)和终结引用的第一阶段
   ├─ 代码:
   │  size_t ReferenceProcessor::process_discovered_list_work(..., bool do_enqueue_and_clear) {
   │    // 创建迭代器遍历引用列表
   │    DiscoveredListIterator iter(refs_list, keep_alive, is_alive, enqueue);
   │    while (iter.has_next()) {
   │      iter.load_ptrs(DEBUG_ONLY(discovery_is_concurrent() /* allow_null_referent */));
   │      if (iter.referent() == nullptr) {
   │        // 情况1:referent 已被回收
   │        iter.remove();
   │        iter.move_to_next();
   │      } else if (iter.is_referent_alive()) {
   │        // 情况2:referent 仍然存活
   │        iter.remove();
   │        iter.make_referent_alive();
   │        iter.move_to_next();
   │      } else {
   │        // 情况3:referent 不再强可达
   │        if (do_enqueue_and_clear) {
   │          // 非终结引用处理:清空 referent 并入队
   │          iter.clear_referent();
   │          iter.enqueue();
   │        }
   │        // 终结引用在此阶段保留,仅前进迭代器
   │        iter.next();
   │      }
   │    }
   │    if (do_enqueue_and_clear) {
   │      iter.complete_enqueue();
   │      refs_list.clear();
   │    }
   │    return iter.removed();
   │  }
   │
   ├─ 处理逻辑:
   │  1. 遍历发现列表中的每个引用
   │  2. 三种情况处理:
   │     a. referent 为 null:直接移除引用
   │     b. referent 仍然存活:移除引用并保持 referent 存活
   │     c. referent 不可达:
   │        - 非终结引用:清空 referent 并入队
   │        - 终结引用:仅迭代前进(等待特殊处理)
   │  3. 清理:非终结引用完成入队后清空列表

6. 终结引用特殊处理:process_final_keep_alive()
   ├─ 调用链:process_final_keep_alive() → process_final_keep_alive_work()
   ├─ 代码:
   │  size_t ReferenceProcessor::process_final_keep_alive_work(...) {  
   │    DiscoveredListIterator iter(refs_list, keep_alive, nullptr, enqueue);
   │    while (iter.has_next()) {
   │      // 确保 referent 在终结处理期间存活
   │      iter.make_referent_alive();
   │      // 自循环 next 指针标记为非活跃
   │      java_lang_ref_Reference::set_next_raw(iter.obj(), iter.obj());
   │      // 关键入队操作
   │      iter.enqueue();
   │      iter.next();
   │    }
   │    iter.complete_enqueue();
   │    refs_list.clear();
   │    return iter.removed(); // 始终返回0
   │  }
   │
   ├─ 终结引用专属处理:
   │  1. 确保 referent 存活:为执行 finalize() 方法保持对象可达
   │  2. 自循环 next 指针:标记 FinalReference 为不活跃状态
   │  3. 入队操作:最终都通过 Universe::swap_reference_pending_list() 原子交换到全局 _reference_pending_list
   │  4. 清理:完成入队后清空本地列表,为下一批次处理做好准备

再强调一遍!在 JVM 的引用处理流程中,不同类型的引用有着截然不同的处理策略。核心差异主要体现在对referent字段的清除方式以及入队逻辑上。

软引用、弱引用与虚引用的统一处理

对于软引用、弱引用和虚引用,在 GC 判定其不再可达后,JVM 会执行以下两个关键操作:

  1. 清空 referent 字段:
cpp 复制代码
java_lang_ref_Reference::clear_referent_raw(_current_discovered)

这一步直接将 Reference 对象中的 referent 字段置为 null,其效果等价于 Java 层面调用 ref.clear(),从而断开对目标对象的引用链,使其成为真正的"不可达"对象。

  1. 加入全局 pending 队列:
cpp 复制代码
Universe::swap_reference_pending_list()

将该引用对象加入到 JVM 维护的全局 _reference_pending_list 中,等待后续由 ReferenceHandler 线程消费并最终入队至用户注册的 ReferenceQueue

_reference_pending_list 队列本质上是一个链表结构,引用对象间通过其内部字段 discovered 来链接!

yaml 复制代码
┌───────────────────┐    ┌───────────────────┐    ┌───────────────────┐
│ WeakReference1    │ ┌─>│ SoftReference2    │ ┌─>│ WeakReference3    │ ┌─> NULL
│ referent: obj1    │ │  │ referent: obj2    │ │  │ referent: obj3    │ │
│ discovered: ──────┼─┘  │ discovered: ──────┼─┘  │ discovered: ──────┼─┘
└───────────────────┘    └───────────────────┘    └───────────────────┘
终结引用的特殊处理

与上述三类引用不同的是,终结引用(FinalReference)不会被清除 referent 字段。相反,GC 在处理此类引用时会确保其所引用的对象在终结(finalization)期间保持存活状态。

此外,为了标记该对象已具备终结条件,JVM 会执行如下操作:

cpp 复制代码
java_lang_ref_Reference::set_next_raw(iter.obj(), iter.obj())

即将 next 字段指向自身,形成一种自循环 (self-loop)结构,作为该引用对象进入"非活跃"状态的标志。这一设计使得后续的 Finalizer 线程能够识别并安全地调度对象的 finalize() 方法。

总结对比表
引用类型 是否清除 referent 如何标记状态 加入 pending list 方式
软引用 ✅ 是 --- swap_reference_pending_list()
弱引用 ✅ 是 --- swap_reference_pending_list()
虚引用 ✅ 是 --- swap_reference_pending_list()
终结引用 ❌ 否 next 指向自身(自循环) swap_reference_pending_list()

2.1.2 Reference 与 ReferenceQueue 类的工作机制解析

ReferenceQueue 使用一个单向链表 来管理已入队的引用对象。链表的头部由ReferenceQueue.head指向,每个入队的引用对象通过其Reference.next字段指向链表中的下一个引用对象。这个链表是后进先出的(LIFO),即新入队的引用对象成为新的"头"

yaml 复制代码
ReferenceQueue
┌──────────────┐
│ head: ───────┼──┐
└──────────────┘  │
                  ∨
           ┌───────────────┐    ┌───────────────┐
           │ Reference 1   │    │ Reference 2   │
           │ next: ────────┼───>│ next: ────────┼──┐
           └───────────────┘    └───────────────┘  │
                                        ∧         │
                                        └──────────┘

enqueue0()方法实现了单向链表入队操作,而poll0()实现了从链表头部移除元素的操作。链表队尾元素将始终保持 self-loop 指向自己,链表头部元素出队后依然保持 self-loop 指向自己。与队尾节点设置 next = null 的传统链表相比,self-loop 可以在空队列和非空队列时使用统一的编程模型,同时这对 FinalReference 也有特殊意义!在 FinalReference 的设计中,有一个重要的状态:active 和 inactive。当一个 FinalReference 被创建时,它是 active 的。当 GC 发现某个对象只被 FinalReference 引用(即可以 finalize)时,它会将这个 FinalReference 标记为 inactive。标记的方式通常是将它的 next 字段指向自身(形成一个自环)。这样,后续 GC 就不会再处理这个 FinalReference 了,因为已经处理过了。

java 复制代码
public class ReferenceQueue<T> {
    private static class Null extends ReferenceQueue<Object> {
        // 调用父类构造函数,传入 dummy 参数 0,创建一个不带锁的特殊队列
        public Null() { super(0); }
        /**
         * 重写 enqueue 方法,始终返回 false
         * 这意味着这个特殊队列永远不会真正接受任何引用对象
         */
        @Override
        boolean enqueue(Reference<?> r) { return false; }
    }

    // 全局静态常量:表示"空队列"状态的特殊 ReferenceQueue 实例
    // 当 Reference 对象没有关联队列时,其 queue 字段指向此对象
    static final ReferenceQueue<Object> NULL = new Null();
    // 全局静态常量:表示"已入队"状态的特殊 ReferenceQueue 实例
    // 当 Reference 对象已经被加入队列时,其 queue 字段指向此对象
    static final ReferenceQueue<Object> ENQUEUED = new Null();
    // 队列头部,指向链表中的第一个 Reference 对象
    private volatile Reference<? extends T> head;
    // 可重入锁,用于保护队列操作的线程安全
    private final ReentrantLock lock;
    // 条件变量,用于在队列为空时阻塞等待线程
    private final Condition notEmpty;

    /**
     * 构造一个带锁和条件变量的正常队列
     */
    public ReferenceQueue() {
        this.lock = new ReentrantLock();
        this.notEmpty = lock.newCondition();
    }

    /**
     * 特殊构造函数,用于创建 NULL 和 ENQUEUED 这两种特殊队列
     * 这些特殊队列不需要锁机制,因为它们不会进行真正的队列操作
     */
    ReferenceQueue(int dummy) {
        this.lock = null;        // 特殊队列不需要锁
        this.notEmpty = null;    // 特殊队列不需要条件变量
    }

    /**
     * "入队"核心逻辑
     * @param r 要入队的 Reference 对象
     * @return 入队成功返回 true,失败返回 false
     */
    final boolean enqueue0(Reference<? extends T> r) { 
        ReferenceQueue<?> queue = r.queue;
        // 如果引用的队列状态是 NULL(未关联队列)或 ENQUEUED(已入队),
        // 则不能再次入队,返回 false
        if ((queue == NULL) || (queue == ENQUEUED)) {
            return false;
        }
        // 如果队列为空(head == null),新节点指向自己形成自环
        // 如果队列不为空,新节点指向原来的头节点
        r.next = (head == null) ? r : head;
        // 更新头指针,新加入的引用成为新的头节点
        head = r;
        // 更新该引用对象状态为"已入队"
        r.queue = ENQUEUED;
        signal();
        return true;
    }

    /**
     * "出队"核心逻辑
     * @return 队列头部的 Reference 对象,如果队列为空则返回 null
     */
    final Reference<? extends T> poll0() { 
        // 获取队列头部的引用
        Reference<? extends T> r = head;
        if (r != null) {
            // 在从链表移除之前更新 r.queue 状态为 NULL
            r.queue = NULL;
            // 获取下一个节点
            Reference<? extends T> rn = r.next;
            // 处理自环链表结构:
            // 如果 rn == r,说明这是链表中的最后一个节点(指向自己)
            // 此时应该将 head 设为 null;否则 head 指向下一个节点
            head = (rn == r) ? null : rn;
            // 将移除的节点设置为指向自己,而不是设为 null
            // 这样如果它是 FinalReference,可以保持非活跃状态
            r.next = r;
            return r;
        }
        // 队列为空,返回null
        return null;
    }
}

java.lang.ref.Reference 是一个抽象的密封类 (sealed class),这意味着只有在 permits 子句中指定的类 (PhantomReference, SoftReference, WeakReference, FinalReference) 才能直接继承它。这个设计限制了扩展性,以确保引用对象的行为和与GC的紧密协作能够得到严格控制。

一个 Reference 对象的状态由两个属性共同描述:生命周期状态(Active/Pending/Inactive)和队列状态(Registered/Enqueued/Dequeued/Unregistered)。生命周期状态 描述了 Reference 引用对象本身是否还在被 GC 特别关注以及是否等待后续处理。队列状态描述了 Reference 引用对象是否关联了一个引用队列 (ReferenceQueue) 以及它在队列中的状态。

scss 复制代码
一、对于已注册 (Registered) 的 Reference 对象:

[active/registered]
       |
       | GC
       v
[pending/registered]
       |
       | ReferenceHandler
       v
[inactive/enqueued]
       |
       | poll/remove
       v
[inactive/dequeued]

二、对于未注册 (Unregistered) 的 Reference 对象:

[active/unregistered]
       |
       | GC
       v
[pending/unregistered]
       |
       | ReferenceHandler
       v
[inactive/unregistered]

核心流程:

  1. 创建与注册:创建一个 Reference 对象,并指定要引用的对象(referent)以及可选的 ReferenceQueue。此时,Reference 对象通常处于[active/registered][active/unregistered]状态。
  2. GC 检测:GC 会检测 referent 的可达性。如果 referent 的可达性降低到相应引用类型定义的阈值(例如,对于 WeakReference,当 referent 变成弱可达时):
    • GC 会清除 Reference 对象内部的 referent 字段(FinalReference 除外)。
    • 如果此 Reference 对象关联了 ReferenceQueue,GC 会将此 Reference 对象本身加入一个内部的"待处理列表 "(即 JVM 维护的全局_reference_pending_list)。Reference 对象状态可能变为[pending/registered]
  3. ReferenceHandler 线程处理:一个专门的、高优先级的守护线程 ReferenceHandler 会周期性地检查这个"待处理列表"。
    • 它会将列表中的 Reference 对象取出。
    • 对于非 Cleaner 实例,它会调用这些 Reference 对象的 enqueueFromPending() 方法,该方法会尝试将 Reference 对象加入其关联的 ReferenceQueue。此时,Reference 对象状态变为[pending/enqueued](如果成功入队且之前是pending),或者直接变为[inactive/enqueued](如果是由 ReferenceHandler 线程直接处理)。
    • 对于 Cleaner 实例(Cleaner 是 PhantomReference 的一个特殊子类),会直接调用其 clean() 方法。
  4. 应用程序处理队列:应用程序可以轮询(poll())或阻塞等待(remove())从 ReferenceQueue 中取出已入队的 Reference 对象,根据取出的 Reference 对象执行相应的清理逻辑。取出后,Reference 对象通常变为[inactive/dequeued]状态。
  5. 最终回收:没有任何引用的 Reference 对象自身最终也会被GC回收。

关键字段分析:

java 复制代码
private T referent;         /* Treated specially by GC */
volatile ReferenceQueue<? super T> queue;
volatile Reference next;
private transient Reference<?> discovered;
  • referent: 持有对实际目标对象的引用。GC 会特殊对待这个字段。当条件满足时(例如,对象变为弱可达),GC 会自动将此字段设置为 null(FinalReference例外)。这是程序检查目标对象是否还存活的主要手段。
  • queue: 如果注册了队列,它指向该 ReferenceQueue 实例。如果已入队,它被设置为 ReferenceQueue.ENQUEUED; 如果已出队或未注册,它被设置为 ReferenceQueue.NULL。
  • next: 当 Reference 对象在 ReferenceQueue 中排队时,此字段用于将队列中的元素链接起来,形成一个单向链表。对于已出队或者队尾的 Reference,next 指向 this 自己。
  • discovered: 这个字段由 JVM 内部使用,开发者无法直接访问。discovered != null 意味着 GC 已经发现"referent 的可达性已经降低到相应引用类型定义的阈值"这一事实了。JVM 维护的全局_reference_pending_list这一链表就是通过 discovered 字段来链接 Reference 对象的!

ReferenceHandler 的角色:

ReferenceHandler 是一个由 JVM 启动的守护线程,负责将 _reference_pending_list 链表中的引用对象移动到对应的 ReferenceQueue 中。其核心代码如下:

java 复制代码
private static class ReferenceHandler extends Thread {
    ReferenceHandler(ThreadGroup g, String name) {
        super(g, null, name, 0, false);
    }
    public void run() {
        while (true) {
            processPendingReferences();
        }
    }
}

static {
    SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
        @Override
        public void startThreads() {
            ThreadGroup tg = Thread.currentThread().getThreadGroup();
            for (ThreadGroup tgn = tg;
                 tgn != null;
                 tg = tgn, tgn = tg.getParent());
            Reference.startReferenceHandlerThread(tg);
            Finalizer.startFinalizerThread(tg);
        }
    });
}

// Atomically get and clear (set to null) the VM's pending-Reference list.
private static native Reference<?> getAndClearReferencePendingList();
// Wait until the VM's pending-Reference list may be non-null.
private static native void waitForReferencePendingList();

private void enqueueFromPending() {
    var q = queue;
    if (q != ReferenceQueue.NULL) q.enqueue(this);
}

private static final Object processPendingLock = new Object();
private static boolean processPendingActive = false;

private static void processPendingReferences() {
    waitForReferencePendingList(); // 阻塞等待,直到可能有待处理引用
    Reference<?> pendingList;
    synchronized (processPendingLock) {
        pendingList = getAndClearReferencePendingList(); // 原子获取并清空 VM 的 pending 列表
        processPendingActive = true; // 标记正在处理
    }
    while (pendingList != null) { // 遍历从 VM 获取的 pending 列表
        Reference<?> ref = pendingList;
        pendingList = ref.discovered; // 移动到下一个
        ref.discovered = null; // 清除当前 ref 的 discovered 字段

        if (ref instanceof Cleaner) { // 特殊处理 Cleaner 实例
            ((Cleaner)ref).clean(); // 直接调用其 clean 方法
            synchronized (processPendingLock) {
                processPendingLock.notifyAll(); // 通知等待者处理有进展
            }
        } else {
            ref.enqueueFromPending(); // 其他类型的引用,尝试入队
        }
    }
    synchronized (processPendingLock) {
        processPendingActive = false; // 标记处理完毕
        processPendingLock.notifyAll(); // 通知等待者本轮处理完成
    }
}

静态初始化代码块用于启动 ReferenceHandler 线程和 Finalizer 线程。它们都在顶层线程组中作为守护线程启动,并设置了最高优先级。Finalizer 是一个用于执行对象 finalize() 方法的守护线程!

processPendingReferences() 方法是 ReferenceHandler 线程的核心逻辑所在!其 getAndClearReferencePendingList() 是一个native方法,原子地获取 _reference_pending_list 链表的头节点,这是 ReferenceHandler 获取待办任务的方式。

  • processPendingActive = true: 标记 ReferenceHandler 正在活跃地处理引用。
  • 遍历 pendingList: 每次取出一个 Reference 对象,然后将 pendingList 指向下一个,并清空 ref.discovered。
  • if (ref instanceof Cleaner): 如果是 Cleaner 实例,则直接调用其 clean() 方法。这是 Cleaner API实现的核心,确保清理动作在 ReferenceHandler 线程中执行。每个 Cleaner 执行完毕后立即 notifyAll() 唤醒通过 waitForReferenceProcessing() 等待清理(比如堆外内存)进展的组件(如:DirectByteBuffer)!

2.1.3 摒弃 Finalizer,拥抱 Cleaner API

FinalReference 并不是面向开发者的公开 API,而是 JVM 内部使用的特殊引用类型。当一个对象重写了finalize()方法时,JVM 会自动为该对象创建一个 FinalReference 实例,配合 Finalizer 线程在对象回收前执行 finalize() 方法以释放本地资源。这就是 Java 中的 Finalizer 机制!

在 Java 9 之前!Java 围绕对象生命周期这一主题提供了两种资源清理机制,分别是 Finalizer 和 PhantomReference。Finalizer 和 PhantomReference 的触发条件基本一致:都是当 GC 判定一个对象为可回收对象时。区别仅在于之后发生的事情。对于 Phantom Reference,referent 可能会被立即回收;而对于 Finalizer,实际的回收要等到 finalize() 执行完毕之后才会发生。在周志明《深入理解Java虚拟机》一书中建议大家忘掉 Finalizer 机制;自 Java 18 起,Finalizer 机制已被标记为 "deprecated for removal",这意味着它将在未来的某个 Java 版本中被彻底移除,具体可以看看 JEP 421 这一提案!

但是直接基于 PhantomReference 实现自动清理往往又比较复杂,于是 Java 9 引入了全新的 Cleaner APICleaner的底层实现依赖于 PhantomReference 和 ReferenceQueue,可以将其视为对 PhantomReference 常用模式的一种高级封装和标准化。

清理动作是一个 Runnable,它会在对象变为虚可达时至多被调用一次,除非它已经被显式清理过。请注意:清理动作绝对不能引用正在被注册的那个对象。如果引用了,该对象将永远无法变为虚可达,其清理动作也因此不会被自动调用。

清理动作仅在关联对象变为虚可达之后才被调用,因此,实现清理动作的那个对象(通常是 Runnable)不持有对被清理对象的引用至关重要。在本示例中,一个静态类封装了清理所需的状态和动作。不应使用"内部"类(无论是否匿名),因为它会隐式地包含对外部实例的引用,从而阻止外部实例变为虚可达。 是选择创建一个新的 Cleaner 还是共享一个现有的 Cleaner,取决于具体的用例。

Cleaner API 基础用法:

  1. 获取 Cleaner 实例:Cleaner.create() 方法返回一个使用守护线程执行清理操作的 Cleaner 实例,通常每个需要清理机制的库或模块只需要创建一个 Cleaner 实例。

  2. 定义清理动作:清理动作是一个Runnable对象,它包含了实际执行资源释放的代码。通常这个 Runnable 动作是一个静态内部类,不应使用成员内部类,因为它会隐式地包含对外部实例的引用,从而阻止外部实例变为虚可达,清理动作也永远不会执行。请注意:如果 Runnable.run() 方法抛出未检查异常,它会被 Cleaner 的后台线程捕获并通常会被忽略,但不会影响其他清理任务或 Cleaner 本身。尽量在 run() 方法内部处理所有异常。

csharp 复制代码
@Override
public void run() {
	Thread t = Thread.currentThread();
	InnocuousThread mlThread = (t instanceof InnocuousThread) ? (InnocuousThread) t : null;
	while (!phantomCleanableList.isListEmpty()) {
		if (mlThread != null) {
			// Clear the thread locals
			mlThread.eraseThreadLocals();
		}
		try {
			// Wait for a Ref, with a timeout to avoid getting hung
			// due to a race with clear/clean
			Cleanable ref = (Cleanable) queue.remove(60 * 1000L);
			if (ref != null) {
				ref.clean();
			}
		} catch (Throwable e) {
			// ignore exceptions from the cleanup action
			// (including interruption of cleanup thread)
		}
	}
}
  1. 注册清理动作:当创建需要被清理的对象时,使用 Cleaner.register(Object obj, Runnable action) 方法将其与清理动作关联起来。register() 方法返回一个 Cleaner.Cleanable 对象,你需要持有对这个 Cleanable 对象的强引用,否则,如果 Cleanable 对象本身被GC了,清理动作可能不会执行。通常,Cleanable 对象会作为被追踪对象的一个字段。

  2. 执行 Cleaner.Cleanable.clean() 方法: register() 方法返回一个 Cleaner.Cleanable 对象。这个对象有一个 clean() 方法。

    • 如果你显式调用 cleanable.clean(),清理动作会立即在当前线程执行,并且后续当对象变得不可达时,自动清理不会再次发生。
    • 如果不显式调用 cleanable.clean(),那么当注册的对象变得不可达时,Cleaner 的后台线程会自动执行该清理动作。

2.2 深入理解 WeakHashMap:从弱引用到自动清理的工程实践

WeakHashMap是少数原生标准库中真正基于弱引用设计的数据结构之一,它不仅揭示了"弱可达"语义在真实场景中的应用,更精妙地将引用对象入队监听失效清理强引用保活 等机制整合成一套运行高效、行为可控的自动清理容器。理解 WeakHashMap 的实现原理,为后续基于软引用和弱引用来构建更精细的内存管理语义具有很强的指导意义!

WeakHashMap 的核心设计目标,是在 key 被外部系统遗忘(无强引用)后自动将该 key/value 键值对儿从 map 中移除。为了实现这一目标,有三个问题必须要解决:

  1. 如何将 key 包装为 WeakReference ?
  2. 一旦 key 被外部系统遗忘(无强引用)后,key 就具备 GC 的条件了,因此在 WeakHashMap 内部一定不能再强引用 key 了。这一点该如何保证?
  3. 必须要避免 WeakReference 被 GC 提前回收,否则 WeakReference 无法入队,进而无法监听与消费 key 的回收事件,最终导致不知道何时应该将 key/value 键值对儿从 map 中移除。那么 WeakHashMap 内部必然要强引用 WeakReference 才行,而且在 key/value 键值对儿从 map 中移除后这一强引用关系也应该解除。如何实现?

2.2.1 key 是弱引用,注册到 ReferenceQueue

这是通过自定义的Entry<K, V>类实现的,它继承自 WeakReference,从而允许 JVM 在 GC 时自动清除 key(referent)并将引用对象加入 ReferenceQueue。当然,WeakHashMap 内部需要维护一个 ReferenceQueue,这样才能接收回收通知。

java 复制代码
public class WeakHashMap<K,V> extends AbstractMap<K,V> implements Map<K,V> {
    // 关键代码一:这里声明了一个引用队列       
    private final ReferenceQueue<Object> queue = new ReferenceQueue<>();

    // 关键代码二:Map 的桶中存储的 Entry,继承自 WeakReference
    private static class Entry<K,V> 
            extends WeakReference<Object> 
            implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;

        Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            // 关键代码三:将 key 作为 referent,并注册到上面的 queue
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
	}	
}

2.2.2 key 不被强引用,referent 可自然回收

在 Entry 的实现中,我们看到它只保留了通过 super(key, queue) 建立的弱引用,并未显式存储 key 的强引用副本。因此,当外部系统对某个 key 的强引用被取消时,这个 Entry 中的 referent 就立即降级为"弱可达",可以被 GC 回收!

java 复制代码
Entry(Object key, V value,
      ReferenceQueue<Object> queue,
      int hash, Entry<K,V> next) {
    super(key, queue);  // 仅做弱引用包装
    ...
}

2.2.3 Entry 被强引用保活,WeakReference 本身不会提前被 GC

WeakHashMap 使用数组table[]强引用所有Entry,而这些 Entry 是 WeakReference 的子类,因此本质上是对 WeakReference 的强引用。"桶(bucket)"就是内部 table[] 数组的每个槽位(slot),table.length 是桶的总数。每个桶是一条链表,其头节点 table[i] 指向第一个 Entry,Entry 之间通过 next 字段相连。这就确保了 WeakReference 自身绝不会在 key 清除前就被 GC。

java 复制代码
┌────────────────────────────────────┐
           key 被 GC 回收             
└────────────────────────────────────┘
                  ▼
┌────────────────────────────────────┐
            entry 入队                
└────────────────────────────────────┘
                  ▼
┌────────────────────────────────────┐
          执行 expunge 逻辑            
└────────────────────────────────────┘
                  ▼
┌────────────────────────────────────┐
     从桶链表中摘除 Entry 并置       
           value 为 null             
└────────────────────────────────────┘
                  ▼
┌────────────────────────────────────┐
        Entry 失去强引用             
└────────────────────────────────────┘
                  ▼
┌────────────────────────────────────┐
        Entry 被 GC 回收             
└────────────────────────────────────┘

public class WeakHashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V> {

    /**
     * 1. "桶"就是内部 table 数组的每个槽位(slot),table.length 是桶的总数;
     * 2. 每个桶里不是直接放一个单独的 Entry,而是一条链:头节点是 table[i],
     *    后续节点通过 Entry.next 指针串在一起;
     */
    Entry<K,V>[] table;
}

2.2.4 回收触发引用入队,WeakHashMap 定期清理

清理过程由 WeakHashMap 主动驱动,避免使用后台线程,节约资源。WeakHashMap 的每次 put/get/remove/size/... 都会先执行expungeStaleEntries(),从队列中取出这些"失效"的 Entry 并剔除,保障 WeakHashMap 视图与真实可达对象一致。e.value = null是关键一步:断开 value 的最后强引用。

java 复制代码
private void expungeStaleEntries() {
    for (Object x; (x = queue.poll()) != null; ) {
        synchronized (queue) {
            Entry<K,V> e = (Entry<K,V>) x;
            // 定位到对应的桶 index
            int i = indexFor(e.hash, table.length);
            // 在桶的链表中找到 e 并移除
            Entry<K,V> prev = table[i];
            Entry<K,V> p = prev;
            while (p != null) {
                Entry<K,V> next = p.next;
                if (p == e) {
                    if (prev == e)
                        table[i] = next;
                    else
                        prev.next = next;                     
                    // ---关键就在这里---
                    // 将 value 置为 null,断开对 value 的最后强引用,帮助其被回收
                    e.value = null; // Help GC
                    size--;
                    break;
                }
                prev = p;
                p = next;
            }
        }
    }
}

public V put(K key, V value) {
    expungeStaleEntries();
    // ...剩余 put 逻辑
}

public V get(Object key) {
    expungeStaleEntries();
    // ...剩余 get 逻辑
}

public V remove(Object key) {
    expungeStaleEntries();
    // ...剩余 remove 逻辑
}

// size(), isEmpty(), containsKey(), containsValue() 等也都会调用 expungeStaleEntries()

3. 总结:驾驭内存管理的艺术

在Java内存管理的世界里,内部类与引用体系是两大关键战场:

  1. 内部类陷阱

    成员内部类因隐式持有外部类强引用,易引发内存泄漏。优先选用静态内部类,避免非必要耦合。

  2. 引用体系精要

    • 弱引用打破强引用链,允许 GC 回收关键对象
    • Cleaner API 替代 Finalizer,提供安全可靠的资源清理机制
    • WeakHashMap 完美演绎弱引用实践:自动清理失效键值对
  3. 线程协作典范

    • ReferenceHandler 线程作为 JVM 与 Java 层的桥梁,负责将 GC 标记的引用对象入队至对应的 ReferenceQueue,供用户线程消费。
相关推荐
ai小鬼头36 分钟前
创业小公司如何低预算打造网站?熊哥的实用建站指南
前端·后端
菜还不练就废了1 小时前
7.19 Java基础 | 异常
java·开发语言
Andrew_Ryan1 小时前
Chisel 工具使用教程
后端
AntBlack1 小时前
体验了一把 paddleocr , 顺便撸了一个 桌面端 PDF识别工具
后端·python·计算机视觉
Xxtaoaooo1 小时前
手撕Spring底层系列之:注解驱动的魔力与实现内幕
java·开发语言·后端开发·spring框架·原理解析
街霸星星2 小时前
使用 vfox 高效配置 Java 开发环境:一份全面指南
java
用户1512905452202 小时前
C 语言教程
前端·后端
♛暮辞2 小时前
java程序远程写入字符串到hadoop伪分布式
java·hadoop·分布式
UestcXiye2 小时前
Rust Web 全栈开发(十):编写服务器端 Web 应用
前端·后端·mysql·rust·actix
用户1512905452202 小时前
基于YOLOv10算法的交通信号灯检测与识别
后端