「Java 进阶之路」系列 Day09
写在前面
Day08 讲的 ConcurrentHashMap、CopyOnWriteArrayList 都是"多个线程共享同一份数据,想办法安全地共享"。这篇的思路完全反过来——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() 会直接把对应的 Entry 从 ThreadLocalMap 里删掉,而不是仅仅寄希望于 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:一把锁走天下
final Object[] items;
int takeIndex, putIndex, count;
final ReentrantLock lock = new ReentrantLock();
final Condition notEmpty = lock.newCondition();
final Condition notFull = lock.newCondition();
底层是固定容量的数组,takeIndex 和 putIndex 像指针一样循环移动,构成一个环形缓冲区。读和写共用同一把 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();
底层是链表,默认无界(也可以在构造时指定容量)。关键设计是 takeLock 和 putLock 两把独立的锁,生产者和消费者分别持有各自的锁,互不干扰,可以真正并发执行。至于元素总数,用一个 AtomicInteger 单独维护,避免跨这两把锁去做统计带来的额外竞争。
全面对比
| 对比项 | ArrayBlockingQueue | LinkedBlockingQueue |
|---|---|---|
| 底层结构 | 数组 提前分配好内存 | 链表 动态分配节点 |
| 是否有界 | 必须指定容量 | 可选 默认无界 |
| 锁的数量 | 1把 读写共用 | 2把 读写分离 |
| 并发度 | 较低 | 较高 |
| 内存特点 | 固定 没有额外GC压力 | 动态分配节点 有一定GC压力 |
| 适合场景 | 容量固定 追求低延迟 | 高吞吐 容量可以有弹性 |
一句话总结:容量固定、追求稳定延迟的场景用 ArrayBlockingQueue;追求更高并发吞吐、能接受一点动态内存分配开销的场景用 LinkedBlockingQueue——这也是很多线程池默认选 LinkedBlockingQueue 作为任务队列的原因(下一篇讲线程池会再次遇到它)。
五、面试追问
Q1:ThreadLocal 为什么会导致内存泄漏,根本原因是什么?
ThreadLocalMap 里 Entry 的 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 底层是链表,生产者和消费者分别持有独立的 putLock 和 takeLock,可以真正并发执行,吞吐更高,但节点动态分配会带来一定的GC压力。容量固定、追求低延迟稳定性选 ArrayBlockingQueue;需要更高并发吞吐、能接受动态内存分配开销选 LinkedBlockingQueue。
下一篇预告
Day10 开始讲线程池——ThreadPoolExecutor 的 7 大核心参数分别是干什么的,任务队列满了之后 4 种拒绝策略又是怎么触发的。