深挖 CompletableFuture.waitingGet:JDK 1.8.221 的内存隐患与 1.8.441 的修复

核心问题:在 JDK 1.8.221 中,高并发调用 CompletableFuture.get() 会导致 Signaller 节点在 completion stack 上残留,引发内存持续增长。本文从源码层面剖析成因,并给出版本对比与实践建议。


一、waitingGet 是什么

CompletableFuture 尚未完成时,调用 get() 会进入 waitingGet 方法:

// JDK 8 源码路径:java.util.concurrent.CompletableFuture
public T get() throws InterruptedException, ExecutionException {
    Object r;
    return reportGet((r = result) == null ? waitingGet(true) : r);
}

waitingGet 的核心流程:

private Object waitingGet(boolean interruptible) {
    Signaller q = null;
    boolean queued = false;
    int spins = ...;
    Object r;
    while ((r = result) == null) {
        if (spins > 0) {
            // 自旋等待
        } else if (q == null) {
            q = new Signaller(interruptible, 0L, 0L);  // ① 创建 Signaller
        } else if (!queued) {
            queued = tryPushStack(q);                   // ② 压入 completion stack
        } else {
            ForkJoinPool.managedBlock(q);               // ③ 挂起等待
        }
    }
    // Future 完成后的清理
    if (q != null) {
        q.thread = null;                               // ④ 清除线程引用
        if (q.next != null)
            cleanStack();                              // ⑤ 有条件地清理链表
    }
    return r;
}

Signaller 是一个内部类,持有当前调用线程的强引用

static final class Signaller extends Completion {
    volatile Thread thread;   // 强引用调用线程

    Signaller(boolean interruptible, long nanos, long deadline) {
        this.thread = Thread.currentThread();
    }
}

二、JDK 1.8.221 的问题:清理条件不完备

2.1 cleanStack() 的调用条件

注意第 ⑤ 步:

if (q.next != null)
    cleanStack();

只有当 q.next != null(即 Signaller 后面还有其他节点)时,才会执行 cleanStack()

如果 q 是 completion stack 上的唯一节点,或者是栈顶节点next 为 null,则 cleanStack() 不会被调用。此时:

  • q.thread 虽然被置 null(Thread 引用断开)
  • q 节点本身仍然挂在 CompletableFuturestack 字段上
  • 只要 CompletableFuture 对象还被引用,q 就不会被 GC

2.2 高并发下的叠加效应

在线程池中,多个线程同时 get() 同一个 Future 是常见场景:

线程 A → 创建 Signaller-A → 压入 stack(stack: A)
线程 B → 创建 Signaller-B → 压入 stack(stack: B → A)
线程 C → 创建 Signaller-C → 压入 stack(stack: C → B → A)

Future 完成,postComplete() 依次唤醒各线程
  → 每个 Signaller 的 tryFire() 被调用
  → thread 字段置 null
  → 但节点是否被从 stack 上摘除,取决于 cleanStack() 是否被正确触发

在特定竞态条件下,部分 Signaller 节点会残留在已完成的 CompletableFuture 的 stack 链上

2.3 内存影响

影响 说明
Signaller 节点泄漏 完成后的 Future 仍持有 Signaller 链表,节点无法被 GC
Old 区缓慢增长 Signaller 对象从 Young 区晋升到 Old 区后长期驻留
GC 压力上升 更多对象存活 → Minor GC 频率上升 → 偶发 Mixed GC
堆内存碎片化 大量小对象长期驻留 Old 区,加剧碎片

三、通过 Heap Dump 验证

在 JDK 1.8.221 的服务中,如果存在这个问题,Heap Dump 中可以看到:

Class Name                                          | Instances | Shallow Heap
-----------------------------------------------------------------
java.util.concurrent.CompletableFuture$Signaller   |   42,318  |  1,352,576
java.util.concurrent.CompletableFuture             |   38,104  |  1,219,328

大量 Signaller 实例,且数量与 CompletableFuture 实例数不成比例(正常情况下应接近 1:1),是泄漏的典型特征。

使用 MAT(Eclipse Memory Analyzer)查看 Signaller 的 Retained Heap,会发现引用链:

CompletableFuture.stack
  └── Signaller (next → Signaller → ...)
        └── thread = null   ← thread 已被清除,但节点本身仍可达

四、JDK 1.8.441 的修复

JDK 8 在后续 patch 版本中对 waitingGet 的清理逻辑进行了加固,核心改动方向:

4.1 cleanStack() 调用无条件化

修复后,退出 waitingGet无论 q.next 是否为 null,都会尝试清理

// 修复后(行为示意)
if (q != null) {
    q.thread = null;
    cleanStack();    // 不再有 if (q.next != null) 的条件限制
}

4.2 postComplete() 遍历时主动摘除节点

修复后,postComplete() 在遍历 completion stack 并触发各节点时,会主动将已处理的节点从链表中摘除,而不是依赖调用方的事后清理。

4.3 修复效果

指标 JDK 1.8.221 JDK 1.8.441
已完成 Future 的 stack 字段 可能残留 Signaller 链 完成后 stack 清空
Signaller 实例数(稳定态) 持续增长 与活跃 Future 数量匹配
Old 区增长趋势 缓慢但持续上升 平稳,随 GC 正常回落

五、缓解方案(无法立即升级时)

5.1 避免同步 get(),改用回调链

根本上减少 waitingGet 被调用的次数:

// ❌ 触发 waitingGet,有泄漏风险
String result = future.get();

// ✅ 全程异步,不进入 waitingGet
future.thenAccept(result -> process(result));

// ✅ 组合多个 Future 时用 allOf + join 替代逐个 get
CompletableFuture.allOf(f1, f2, f3)
    .thenRun(() -> {
        f1.join();
        f2.join();
    });

5.2 使用超时版 get()

// 带超时的 get() 走不同的代码路径,清理逻辑更完整
String result = future.get(3, TimeUnit.SECONDS);

5.3 定期监控堆内 Signaller 数量

通过 JMX 或 Actuator 定期抓取,建立告警基线:

# 查看 Signaller 实例数(需开启 JMX)
jmap -histo <pid> | grep Signaller

若 Signaller 数量持续增长而不回落,说明泄漏正在发生。


六、升级检查清单

升级到 JDK 1.8.441 后,建议同步完成以下检查:

# 1. 确认版本
java -version
# 输出应包含:build 1.8.0_441-xxx

# 2. 保留必要的 GC 参数(和版本无关,建议保留)
-XX:+UseG1GC
-XX:+ParallelRefProcEnabled        # 并行引用处理,防御性配置
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps

# 3. 升级后观察 Old 区趋势(连续 24h)
# 预期:Old 区不再持续单调增长

七、总结

问题 成因 影响 解决方案
Signaller 节点残留 cleanStack() 调用条件不完备 Old 区缓慢增长,GC 压力上升 升级 JDK 1.8.441
高并发下加剧 每次 get() 都创建 Signaller 节点数随 QPS 线性积累 改用异步回调链
问题不易察觉 内存增长缓慢,无明显 OOM 长期运行后才暴露 监控 Signaller 实例数

CompletableFuture 是 Java 并发编程的核心工具,但其内部实现的细节缺陷在高并发、长时间运行的服务中会被逐渐放大。升级至 JDK 1.8.441 是最直接有效的修复手段,结合业务侧减少同步 get() 调用,可以从根本上消除这一内存隐患。


说明:本文分析基于 JDK 8 源码中 waitingGet 的清理逻辑,以及多个 patch 版本间的行为变化对比。如需定位线上问题,建议结合 jmap -histo 监控 Signaller 实例数作为判断依据。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐