ThreadLocalMap里几十万个死Entry——排查了半天OOM,根因就一行finally没写

Java 并发编程(三):ConcurrentHashMap + ThreadLocal + 实战

ThreadLocal 内存泄漏的根因不是"忘了调 remove()"------是 ThreadLocalMap 的 Entry 用 WeakReference 做 key,GC 后 key 变 null,value 靠强引用永远活着。线程池线程不销毁→Entry 永不回收→几十万个"死 Entry"堆在内存里。排查我花了半天,修复就一行 finally { threadLocal.remove(); }。这篇文章把 CHM 演进和 ThreadLocal 泄漏穿成一条线------理解了前者你会选容器,理解了后者你不会再 OOM。

阅读约 13 分钟 | 系列第 8/17 篇


一、HashMap 为什么线程不安全?

HashMap 在并发场景下三大问题:

1. JDK 7 死循环(最经典)

扩容时 transfer() 使用头插法,多线程并发 resize 导致链表成环,get() 时死循环 CPU 100%。

单线程正常扩容(头插法)

less 复制代码
旧桶:A → B → C → null
迁移到新桶(头插法,每次插到最前面):
  Step1: 取 A → 新桶: A → null
  Step2: 取 B → 新桶: B → A → null
  Step3: 取 C → 新桶: C → B → A → null
结果:链表反转了。

两个线程同时扩容------死循环诞生

less 复制代码
初始:旧数组桶 = A → B → null

帧① 线程1 开始 transfer:
  e = A
  next = A.next = B    ← 只是读了一个局部变量,一行都还没写
  挂起!(局部变量 e=A, next=B)

帧② 线程2 完整跑完 transfer(头插法):
  ① 取 A,头插入新桶2 → 新桶2: A → null
  ② 取 B,头插入新桶2 → 新桶2: B → A → null
  结果:B.next = A, A.next = null

帧③ 线程1 被唤醒,继续执行(手里 e=A, next=B):

  第1轮(处理 A):
    A.next = 新桶[i](null)→ A 头插入 → 新桶1: A → null
    e = B  ← next 是之前存的局部变量

  第2轮(处理 B):
      next = B.next  ← 取到 A!线程2 已经将 B.next 修改为 A
    B.next = 新桶[i](A)→ B 头插入 → 新桶1: B → A → null
    e = A  ← next 是 A

  第3轮(再次处理 A):
    next = A.next  ← null(第1轮时 A.next 设了 null)
      A.next = 新桶[i](B)← A.next = B → 新桶1: A → B → A → ...
    e = null → 循环结束
    结果:A ⇄ B 形成环!get() 遍历到环上 → 死循环 CPU 100%

根因总结 :不是"线程1 改了一半被挂起",而是线程1 拿着过期引用恢复运行 ------线程2 已经修改了 B.next,线程1 并不知情,继续使用头插法将 A 插入到 B 前面,形成了 A → B → A 的环。

2. 数据覆盖丢失

put() 时两个线程同时计算到同一个桶,一个线程的 value 被另一个覆盖。put 不是原子操作------hash 碰撞时链表操作、size++ 都不是原子的。

3. size 不准确

size 字段不是 volatile,多线程 put 后 get size 可能不准确。

JDK 8 的改进:尾插法消除死循环(链表不反转),但数据覆盖和 size 不准依然存在。HashMap 从未为并发场景设计,并发场景必须使用 ConcurrentHashMap。


二、ConcurrentHashMap:从 Segment 到 CAS

核心设计理念:锁粒度不断细化------表级锁 → 分段锁 → 桶级锁

JDK 7:分段锁(Segment)

ini 复制代码
ConcurrentHashMap
  ├── Segment[0]  ← 继承 ReentrantLock
  │     ├── HashEntry → HashEntry → ... (链表)
  │     └── ...
  ├── Segment[1]
  └── Segment[N]  (默认 N=16)
  • get() :完全无锁。HashEntry 的 value 和 next 都是 volatile,用 UNSAFE.getObjectVolatile() 保证可见性
  • put() :先定位 Segment → segment.lock()(非阻塞 tryLock 自旋 64 次 → 阻塞 lock)→ 头插法插入链表
  • size():两次无锁计算所有 Segment count 之和,一致则返回;不一致则锁所有 Segment 重算

问题:Segment 数量固定(构造时指定),无法动态伸缩。太多→内存浪费,太少→锁竞争严重。

JDK 8:CAS + synchronized(桶级锁)

scss 复制代码
ConcurrentHashMap
  ├── Node[] table  ← 与 HashMap 相同的数组+链表/红黑树
  │     ├── Node (链表)
  │     └── TreeNode (红黑树,链表长度≥8且数组长度≥64时树化,节点数≤6时退化为链表)
  │
  └── 无锁操作:CAS 设置桶首节点
      有锁操作:synchronized (桶首节点)
  • get() :完全无锁。tabAt() 用 UNSAFE.getObjectVolatile 保证可见性。如果桶首节点 hash=MOVED(-1),说明正在扩容,用 ForwardingNode 到新数组查找
  • put():① 桶为空 → CAS 设置首节点 ② 桶首节点 hash=MOVED → 帮助扩容 ③ 否则 → synchronized(桶首节点),尾插法插入

多线程协同扩容(最核心的设计)

markdown 复制代码
① transferIndex:用 CAS 控制步长分配,每个线程过来领一段(transferIndex - stride)
② 步长最小 16,保证每个线程至少迁移 16 个桶
③ ForwardingNode:旧桶迁移完成后放入,hash=MOVED
   - 读请求碰到 ForwardingNode → 自动转发到新数组
   - 写请求碰到 ForwardingNode → 先协助扩容,扩容完再在新数组写
④ 这是真正的**无阻塞扩容**------读写都不阻塞

JDK 8 size() 的无锁化:LongAdder 分段计数

JDK 7 的 size() 计算过程------两次无锁遍历所有 Segment 的 count 之和,一致则返回;不一致则锁所有 Segment 重算------在高并发下是严重的性能瓶颈。JDK 8 彻底重构为无锁方案:

scss 复制代码
CounterCell[] 数组
  ├── Cell[0] ← Thread-1 CAS 递增自己的槽位
  ├── Cell[1] ← Thread-2 CAS 递增自己的槽位
  ├── ...
  └── Cell[N]
  
size() = baseCount + sum(CounterCell[].value)  ← 完全无锁

每个线程通过 ThreadLocalRandom.getProbe() 生成哈希值映射到 CounterCell[] 的某个槽位,在槽位上 CAS 递增------不再有全局竞争点。这是 LongAdder 的核心思想,也是 JDK 8 引入 @sun.misc.Contended 注解消除伪共享的场景之一。

CHM 实战场景:本地缓存

JDK 7 JDK 8
数据结构 Segment + HashEntry Node\[\] + 链表/红黑树
锁机制 ReentrantLock (Segment) synchronized (桶首节点)
锁粒度 Segment 级别 桶级别
put() lock() 阻塞 空桶 CAS 无锁 + 冲突 synchronized
扩容 Segment 级别,单线程 多线程协同扩容
size() 可能锁全表 完全无锁(LongAdder 分段计数)

三、ThreadLocal:内存泄漏与正确用法

为什么会内存泄漏?

ThreadLocalMap 的 Entry 是 WeakReference<ThreadLocal<?>>------key(ThreadLocal 对象)是弱引用,value 是强引用。泄漏的本质相同(value 无法被 GC 回收),但具体触发路径分两种模式:

模式一(经典泄漏------key 被 GC 回收)

ini 复制代码
Thread → ThreadLocalMap → Entry[] → Entry(key, value)
                                      │       │
                              弱引用 ThreadLocal  强引用 Value
                              
GC 回收 ThreadLocal 后 → key = null → value 无法访问
但 value 还在 ThreadLocalMap 中 → 内存泄漏(除非线程结束)

ThreadLocal 对象本身被 GC 回收后,Entry 的 key 变为 null,value 无法被访问也无法被回收------这是弱引用 key 的设计缺陷:key 被回收了,但 value 的引用通路仍然存在(Thread → ThreadLocalMap → Entry → value),只是 key=null 导致无法通过正常 API 定位到该 value。

模式二(static final 下的 value 累积------key 永不回收)

当 ThreadLocal 定义为 static final 时,key 始终被静态变量强引用,永不被 GC------因此不会产生 key=null 的孤立 Entry。但在线程池场景中,线程被复用,生命周期很长(甚至与应用同寿),ThreadLocal 中的 value 始终可达,形成事实上的内存占用。此时 remove() 是唯一可靠的清理手段------static final 只消除了"key 被意外回收"这一种失效模式,并未解决 value 累积问题。

总结 :模式一的根因是弱引用 key 被回收后 value 残留不可达,模式二的根因是线程复用导致 value 生命周期被拉长。两种模式的共同结论:remove() 是唯一可靠的修复方式,且必须在 finally 块中调用。

正确使用姿势

  1. 每次用完调用 remove()------这是唯一正确的做法
  2. ThreadLocal 定义为 static final------消除"key 被意外回收"的风险,但 value 累积仍然依赖 remove() 清理
  3. 线程池场景尤其需要关注------线程复用,ThreadLocal 不 remove,上次请求的数据残留影响下次请求
  4. InheritableThreadLocal:子线程可继承父线程的值(fork 时复制),配合线程池可能用到过期的父线程值
java 复制代码
// ✅ 正确姿势
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();

public void handleRequest() {
    try {
        USER_CONTEXT.set(new UserContext(userId));
        // 业务处理...
    } finally {
            USER_CONTEXT.remove();  // 必须在 finally 中清理
    }
}

ThreadLocal OOM 排查链路

当线上应用出现 OOM,怀疑是 ThreadLocal 导致时,排查步骤如下:

xml 复制代码
① top -Hp <pid> → 确认是堆内存还是堆外内存耗尽
② jmap -histo:live <pid> | head -20 → 查看大对象数量和类型
   如果某个 Value 类(如 UserContext)的实例数 ≈ 线程池线程数 × N
   → ThreadLocal 未清理嫌疑极大
③ jstack <pid> → 查看线程池线程状态
   如果大量线程处于 WAITING(等待队列取任务)且 ThreadLocalMap 膨胀
   → 确认诊断:线程复用 → value 累积 → 内存泄漏
④ MAT Dominator Tree → 找到 ThreadLocalMap$Entry → 确认 value 被 Thread 引用链持有
⑤ 修复:finally 块中 remove() + 代码审查全部 ThreadLocal 使用点

核心认知 :ThreadLocal 的 OOM 不会像 HashMap 死循环那样立刻 CPU 100% 告警------它是缓慢的内存泄漏,堆使用率呈锯齿状上升,GC 频率逐渐增高但每次回收量递减。jstat -gcutil <pid> 1000 持续观察,FGCT(Full GC 次数)不断上升而 OU(Old 区使用率)降不下来------这是 ThreadLocal 泄漏的典型特征。


核心要点回顾

HashMap 并发问题有三个:JDK 7 扩容使用头插法,两个线程同时 resize 时可能形成 A↔B 的环形链表,导致 get() 遍历到环上死循环 CPU 100%------根因不是"线程 1 改了一半被挂起",而是"线程 1 拿着线程 2 已修改过的过期引用(B.next 从 null 变为 A)继续头插,将 A 插到 B 前面形成 A→B→A 环";数据覆盖------两个线程同时 put 到同一桶位,后写入的覆盖先写入的;size 不准确------size 字段不是 volatile。JDK 8 改用尾插法消除了死循环,但数据覆盖和 size 不准依然存在,HashMap 从未为并发场景设计。

ConcurrentHashMap 的演进 ------从 JDK 7 的 Segment 分段锁(默认 16 个 Segment,继承 ReentrantLock,put 时锁整个 Segment,get 完全无锁依赖 volatile 保证可见性)到 JDK 8 的 CAS+synchronized 桶级锁(桶为空时 CAS 设置首节点,桶非空时 synchronized 锁定桶首节点,get 完全无锁)。JDK 8 最核心的设计是多线程协同扩容:transferIndex 通过 CAS 分配迁移步长(每个线程至少领 16 个桶),ForwardingNode(hash=MOVED=-1)标识已迁移完成的桶------读请求碰到自动转发到新数组、写请求碰到先协助扩容再在新数组写入,实现真正的无阻塞扩容。

ThreadLocal 内存泄漏的根因在于 ThreadLocalMap 的 Entry 设计------key 为 WeakReference<ThreadLocal<?>>(弱引用),value 为强引用。GC 回收 ThreadLocal 对象后 key 变为 null,但 value 的引用通路(Thread → ThreadLocalMap → Entry → value)依然存在且无法通过正常 API 定位。线程池场景下线程长期存活,value 永不被回收,形成事实上的内存泄漏。static final 的 ThreadLocal 消除了"key 被意外回收"的风险,但线程复用导致的 value 累积仍需 remove() 清理。唯一可靠的修复方式是在 finally 块中调用 remove()。


一行 finally { threadLocal.remove(); } 能避免的事,别等到 OOM 再排查。CHM 从 16 个 Segment 到每个桶 CAS+synchronized 的演进,理解了就打通了整个 Java 并发容器的设计思路。收藏这篇并发三部曲收官篇。
下一篇 :《Spring框架核心:@Transactional加了没回滚?6种失效场景对照清单》 系列合集掘金Java合集

相关推荐
2601_963870172 小时前
【计算机毕业设计】基于Spring Boot的社区老年大学课程报名与学习系统的设计与实现
java·spring boot·学习
llwszx2 小时前
【Java/Go后端手撸原生Agent(第七篇):Token预算管理 + 滑动窗口上下文裁剪】
java·后端·python·agent开发·上下文工程·上下文裁剪·滑动窗口裁剪
weixin_BYSJ19873 小时前
springboot3家政平台小程序--附源码00904
java·javascript·spring boot·python·django·flask·php
梦幻通灵3 小时前
Java中finally失效的几种情况【持续更新】
java·开发语言
月落归舟3 小时前
SpringMVC 多种响应返回方式
java·开发语言
吃饱了得干活3 小时前
JVM垃圾回收:从新生代到ZGC,从理论到调优
java·jvm·后端
长不胖的路人甲3 小时前
二叉排序树(BST)Java 完整实现 + 删除思路详解
java·开发语言·算法
大模型码小白3 小时前
Java 部署:Jenkins Pipeline 构建 Java 项目(自动化)
java·人工智能·python·机器学习
长不胖的路人甲3 小时前
可达性分析法(根搜索算法)完整详解
java·jvm·算法