ThreadLocal 与 BlockingQueue:线程本地变量的内存泄漏原理,以及阻塞队列怎么选?

「Java 进阶之路」系列 Day09

写在前面

Day08 讲的 ConcurrentHashMapCopyOnWriteArrayList 都是"多个线程共享同一份数据,想办法安全地共享"。这篇的思路完全反过来------ThreadLocal 是"干脆别共享,每个线程自己留一份";BlockingQueue 则是生产者消费者模型的标准载体,前面 Day06 讲 Condition 时手写过一遍有界缓冲区,这次看看 JDK 现成的实现是怎么做的。


一、ThreadLocal:每个线程自己的一份变量

是什么ThreadLocal 让每个线程持有自己独立的变量副本,线程之间彼此隔离,读写自己的副本完全不需要加锁。

java 复制代码
private static final ThreadLocal<SimpleDateFormat> sdf =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 每个线程调用get拿到的都是自己专属的那一份SimpleDateFormat实例
String date = sdf.get().format(new Date());

SimpleDateFormat 本身不是线程安全的,多线程共享同一个实例会出问题;给每个线程一份自己的实例,就完全不存在共享数据的并发问题了------这是 ThreadLocal 最经典的应用之一。

底层数据结构

markdown 复制代码
Thread
  持有一个ThreadLocalMap叫做threadLocals
    内部是一个Entry数组
      每个Entry的key是ThreadLocal的弱引用
      每个Entry的value是强引用 存的是实际的值

关键点:ThreadLocalMap 是挂在 Thread 对象上的一个字段,生命周期和线程本身绑在一起;而 Entry 的 key 用的是弱引用 指向 ThreadLocal 对象,value 则是普通的强引用。


二、为什么会内存泄漏:弱引用 key 埋下的坑

flowchart TB A[Entry的key是ThreadLocal对象的弱引用] --> B[某次GC发生 外部没有别的强引用指着这个ThreadLocal了] B --> C[GC把这个ThreadLocal对象回收掉 Entry里的key变成null] C --> D[但Entry里的value依然是强引用 不会被GC回收] D --> E[value对应的对象持续占着内存 这就是内存泄漏]

问题的根源ThreadLocalMap 的 key 用弱引用是刻意设计的------这样一旦外部没有强引用指向某个 ThreadLocal 对象,GC 就能把它顺手回收掉,不需要手动清理。但 value 依然是强引用,key 被回收之后,Entry 变成了"key 是 null、value 还在"的僵尸条目,只要这个线程不结束,ThreadLocalMap 就一直攥着这个 value 不放。

线程池场景下问题会被放大 :线程池里的线程是长期存活、反复复用的,不会像普通线程那样执行完就销毁。如果每次提交任务都往 ThreadLocal 里塞东西却不清理,这些"僵尸 Entry"会随着任务执行次数不断堆积,最终可能导致内存溢出,或者更隐蔽的问题------线程被复用执行下一个任务时,如果忘记清理,上一个任务残留的 ThreadLocal 数据可能被下一个任务误读到,造成数据串位。

正确使用姿势:用完必须 remove

java 复制代码
ThreadLocal<List<String>> tl = new ThreadLocal<>();
try {
    tl.set(new ArrayList<>());
    // 使用tl.get()做业务逻辑
} finally {
    tl.remove();   // 用完必须remove 彻底清除这个Entry
}

remove() 会直接把对应的 EntryThreadLocalMap 里删掉,而不是仅仅寄希望于 key 被 GC 回收------这是唯一能保证不泄漏、也不会串数据的做法,尤其是在线程池场景下,finally 里的 remove() 几乎是必须品,不是可选项。

典型应用场景

场景 说明
数据库连接和事务 每个线程持有自己的Connection 保证同一个线程内的操作用的是同一个连接
用户上下文 Web请求里存放当前登录用户信息 避免一层层手动传参
非线程安全的工具类 SimpleDateFormat Random等 每个线程一份自己的实例

三、BlockingQueue:为阻塞而生的队列

核心语义

普通队列在满了或者空了的时候,要么抛异常,要么返回一个特殊值(比如 null);BlockingQueue 的特色是让线程直接阻塞等待,这天然就是生产者消费者模型要的效果。

四组方法,行为完全不同

操作 抛异常 返回特殊值 阻塞等待 限时等待
入队 add offer put offer加超时参数
出队 remove poll take poll加超时参数
查看 element peek 不适用 不适用

生产消费场景推荐直接用 put()/take()------满了/空了自动阻塞,不用自己写重试逻辑,也不用担心抛异常把线程搞挂。

常用实现速览

实现类 有界还是无界 底层结构 特点
ArrayBlockingQueue 有界 数组 环形结构 一把锁 读写共用
LinkedBlockingQueue 默认无界 可指定有界 链表 两把锁 读写分离
PriorityBlockingQueue 无界 堆 优先级队列 按优先级出队 而不是先进先出
SynchronousQueue 容量为0 不存储元素 生产者必须等到消费者来接手
DelayQueue 无界 元素要等到期才能被取出
LinkedTransferQueue 无界 链表 融合了SynchronousQueue和LinkedBlockingQueue的特点

四、ArrayBlockingQueue vs LinkedBlockingQueue:一把锁和两把锁的差异

ArrayBlockingQueue:一把锁走天下

java 复制代码
final Object[] items;
int takeIndex, putIndex, count;
final ReentrantLock lock = new ReentrantLock();
final Condition notEmpty = lock.newCondition();
final Condition notFull  = lock.newCondition();

底层是固定容量的数组,takeIndexputIndex 像指针一样循环移动,构成一个环形缓冲区。读和写共用同一把 ReentrantLock------这意味着即使是"生产者在放数据、消费者在取数据"这种理论上不冲突的操作,也要抢同一把锁,并发度相对较低。

LinkedBlockingQueue:读写分离的两把锁

java 复制代码
final AtomicInteger count = new AtomicInteger();
final ReentrantLock takeLock = new ReentrantLock();
final Condition notEmpty = takeLock.newCondition();
final ReentrantLock putLock = new ReentrantLock();
final Condition notFull  = putLock.newCondition();

底层是链表,默认无界(也可以在构造时指定容量)。关键设计是 takeLockputLock 两把独立的锁 ,生产者和消费者分别持有各自的锁,互不干扰,可以真正并发执行。至于元素总数,用一个 AtomicInteger 单独维护,避免跨这两把锁去做统计带来的额外竞争。

全面对比

对比项 ArrayBlockingQueue LinkedBlockingQueue
底层结构 数组 提前分配好内存 链表 动态分配节点
是否有界 必须指定容量 可选 默认无界
锁的数量 1把 读写共用 2把 读写分离
并发度 较低 较高
内存特点 固定 没有额外GC压力 动态分配节点 有一定GC压力
适合场景 容量固定 追求低延迟 高吞吐 容量可以有弹性

一句话总结 :容量固定、追求稳定延迟的场景用 ArrayBlockingQueue;追求更高并发吞吐、能接受一点动态内存分配开销的场景用 LinkedBlockingQueue------这也是很多线程池默认选 LinkedBlockingQueue 作为任务队列的原因(下一篇讲线程池会再次遇到它)。


五、面试追问

Q1:ThreadLocal 为什么会导致内存泄漏,根本原因是什么?

ThreadLocalMapEntry 的 key 是指向 ThreadLocal 对象的弱引用,value 是强引用。当外部不再有强引用指向某个 ThreadLocal 对象时,GC 会把这个对象回收掉,key 变成 null,但 value 依然被 Entry 强引用着,不会被回收,导致这块内存一直占用不释放。线程池场景下线程长期存活,这类僵尸 Entry 会不断堆积,问题被进一步放大。

Q2:为什么 ThreadLocalMap 的 key 要设计成弱引用,而不是直接用强引用?

如果 key 也是强引用,只要线程不结束,ThreadLocal 对象就永远不会被回收,即使外部代码已经不再使用它。用弱引用能让 GC 在没有其他强引用的情况下主动回收掉 ThreadLocal 对象本身,这是为了减轻内存泄漏问题而做的折中设计------但这个设计只能保证key不泄漏,管不了value,所以还是需要开发者手动调用remove。

Q3:使用 ThreadLocal 之后为什么一定要在 finally 里调用 remove?

因为 key 是弱引用,GC 只能保证 ThreadLocal 对象本身被回收,但对应的 value 依然会被 Entry 强引用着,不会自动清理。尤其是在线程池场景下,线程会被反复复用执行不同任务,如果不主动remove,不仅会造成内存泄漏,还可能导致上一个任务残留的数据被下一个任务误读到,造成数据串位问题。remove() 是唯一能彻底清除这个Entry、避免这两个问题的做法。

Q4:BlockingQueue 的 put/take 和 add/remove 有什么区别?

add/remove 属于"抛异常"这一组,队列满了或空了会直接抛出异常;put/take 属于"阻塞等待"这一组,队列满了/空了时线程会阻塞挂起,等到有空间或者有数据时自动被唤醒继续执行,不需要自己写重试或者异常处理逻辑,天然适合生产者消费者模型,这也是官方推荐在这种场景下优先使用的方法。

Q5:ArrayBlockingQueue 和 LinkedBlockingQueue 最核心的区别是什么,分别适合什么场景?

最核心的区别是锁的数量:ArrayBlockingQueue 底层是数组,读写共用一把锁,并发度较低;LinkedBlockingQueue 底层是链表,生产者和消费者分别持有独立的 putLocktakeLock,可以真正并发执行,吞吐更高,但节点动态分配会带来一定的GC压力。容量固定、追求低延迟稳定性选 ArrayBlockingQueue;需要更高并发吞吐、能接受动态内存分配开销选 LinkedBlockingQueue


下一篇预告

Day10 开始讲线程池------ThreadPoolExecutor 的 7 大核心参数分别是干什么的,任务队列满了之后 4 种拒绝策略又是怎么触发的。

相关推荐
用户3126874877201 小时前
Spring Bean 生命周期到底经历了什么?从实例化到销毁的全链路拆解
java·spring boot
橘色的喵1 小时前
一块 RISC-V MCU 是怎么启动的: 内存保护、中断与从 Flash 直接执行
后端
长栎1 小时前
JDBC 用了 30 年的 Bridge 模式,多数人以为它只是策略模式换了层皮
后端
Flittly1 小时前
【我的手搓轮子日记】(2)handmade-ioc 手搓 IOC 容器
java·spring boot·spring
阿昌喜欢吃黄桃1 小时前
Dubbo服务重启时注册中心下线不及时问题排查与修复记录
java·rpc·dubbo·问题排查
Ai拆代码的曹操1 小时前
排查 3 小时,问题竟在 Dubbo 路由规则:一个 force 的坑
后端
无责任此方_修行中1 小时前
换个版本号就能升级?可没那简单:pnpm 11 升级踩坑记
javascript·后端·npm
SelectDB1 小时前
洋钱罐基于 SelectDB 实现 Hive 数据湖透明加速:查询 P95 从 300 秒降至 20 秒的完整实践
后端
confiself2 小时前
树形思考研究进展
java·大数据·微服务