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

11 阅读8分钟

「Java 进阶之路」系列 Day09

写在前面

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


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

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

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

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

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

底层数据结构

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

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 的特色是让线程直接阻塞等待,这天然就是生产者消费者模型要的效果。

四组方法,行为完全不同

操作抛异常返回特殊值阻塞等待限时等待
入队addofferputoffer加超时参数
出队removepolltakepoll加超时参数
查看elementpeek不适用不适用

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

常用实现速览

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

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

ArrayBlockingQueue:一把锁走天下

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:读写分离的两把锁

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 单独维护,避免跨这两把锁去做统计带来的额外竞争。

全面对比

对比项ArrayBlockingQueueLinkedBlockingQueue
底层结构数组 提前分配好内存链表 动态分配节点
是否有界必须指定容量可选 默认无界
锁的数量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 种拒绝策略又是怎么触发的。