资讯中心

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

📅 2026/8/25 1:31:17
ThreadLocal 与 BlockingQueue:线程本地变量的内存泄漏原理,以及阻塞队列怎么选?
「Java 进阶之路」系列 Day09写在前面Day08 讲的ConcurrentHashMap、CopyOnWriteArrayList都是多个线程共享同一份数据想办法安全地共享。这篇的思路完全反过来——ThreadLocal是干脆别共享每个线程自己留一份BlockingQueue则是生产者消费者模型的标准载体前面 Day06 讲Condition时手写过一遍有界缓冲区这次看看 JDK 现成的实现是怎么做的。一、ThreadLocal每个线程自己的一份变量是什么ThreadLocal让每个线程持有自己独立的变量副本线程之间彼此隔离读写自己的副本完全不需要加锁。privatestaticfinalThreadLocalSimpleDateFormatsdfThreadLocal.withInitial(()-newSimpleDateFormat(yyyy-MM-dd));// 每个线程调用get拿到的都是自己专属的那一份SimpleDateFormat实例Stringdatesdf.get().format(newDate());SimpleDateFormat本身不是线程安全的多线程共享同一个实例会出问题给每个线程一份自己的实例就完全不存在共享数据的并发问题了——这是ThreadLocal最经典的应用之一。底层数据结构Thread 持有一个ThreadLocalMap叫做threadLocals 内部是一个Entry数组 每个Entry的key是ThreadLocal的弱引用 每个Entry的value是强引用 存的是实际的值关键点ThreadLocalMap是挂在Thread对象上的一个字段生命周期和线程本身绑在一起而Entry的 key 用的是弱引用指向ThreadLocal对象value 则是普通的强引用。二、为什么会内存泄漏弱引用 key 埋下的坑Entry的key是ThreadLocal对象的弱引用某次GC发生 外部没有别的强引用指着这个ThreadLocal了GC把这个ThreadLocal对象回收掉 Entry里的key变成null但Entry里的value依然是强引用 不会被GC回收value对应的对象持续占着内存 这就是内存泄漏问题的根源ThreadLocalMap的 key 用弱引用是刻意设计的——这样一旦外部没有强引用指向某个ThreadLocal对象GC 就能把它顺手回收掉不需要手动清理。但 value 依然是强引用key 被回收之后Entry变成了key 是 null、value 还在的僵尸条目只要这个线程不结束ThreadLocalMap就一直攥着这个 value 不放。线程池场景下问题会被放大线程池里的线程是长期存活、反复复用的不会像普通线程那样执行完就销毁。如果每次提交任务都往ThreadLocal里塞东西却不清理这些僵尸 Entry会随着任务执行次数不断堆积最终可能导致内存溢出或者更隐蔽的问题——线程被复用执行下一个任务时如果忘记清理上一个任务残留的ThreadLocal数据可能被下一个任务误读到造成数据串位。正确使用姿势用完必须 removeThreadLocalListStringtlnewThreadLocal();try{tl.set(newArrayList());// 使用tl.get()做业务逻辑}finally{tl.remove();// 用完必须remove 彻底清除这个Entry}remove()会直接把对应的Entry从ThreadLocalMap里删掉而不是仅仅寄希望于 key 被 GC 回收——这是唯一能保证不泄漏、也不会串数据的做法尤其是在线程池场景下finally里的remove()几乎是必须品不是可选项。典型应用场景场景说明数据库连接和事务每个线程持有自己的Connection 保证同一个线程内的操作用的是同一个连接用户上下文Web请求里存放当前登录用户信息 避免一层层手动传参非线程安全的工具类SimpleDateFormat Random等 每个线程一份自己的实例三、BlockingQueue为阻塞而生的队列核心语义普通队列在满了或者空了的时候要么抛异常要么返回一个特殊值比如 nullBlockingQueue的特色是让线程直接阻塞等待这天然就是生产者消费者模型要的效果。四组方法行为完全不同操作抛异常返回特殊值阻塞等待限时等待入队addofferputoffer加超时参数出队removepolltakepoll加超时参数查看elementpeek不适用不适用生产消费场景推荐直接用put()/take()——满了/空了自动阻塞不用自己写重试逻辑也不用担心抛异常把线程搞挂。常用实现速览实现类有界还是无界底层结构特点ArrayBlockingQueue有界数组 环形结构一把锁 读写共用LinkedBlockingQueue默认无界 可指定有界链表两把锁 读写分离PriorityBlockingQueue无界堆 优先级队列按优先级出队 而不是先进先出SynchronousQueue容量为0无不存储元素 生产者必须等到消费者来接手DelayQueue无界堆元素要等到期才能被取出LinkedTransferQueue无界链表融合了SynchronousQueue和LinkedBlockingQueue的特点四、ArrayBlockingQueue vs LinkedBlockingQueue一把锁和两把锁的差异ArrayBlockingQueue一把锁走天下finalObject[]items;inttakeIndex,putIndex,count;finalReentrantLocklocknewReentrantLock();finalConditionnotEmptylock.newCondition();finalConditionnotFulllock.newCondition();底层是固定容量的数组takeIndex和putIndex像指针一样循环移动构成一个环形缓冲区。读和写共用同一把ReentrantLock——这意味着即使是生产者在放数据、消费者在取数据这种理论上不冲突的操作也要抢同一把锁并发度相对较低。LinkedBlockingQueue读写分离的两把锁finalAtomicIntegercountnewAtomicInteger();finalReentrantLocktakeLocknewReentrantLock();finalConditionnotEmptytakeLock.newCondition();finalReentrantLockputLocknewReentrantLock();finalConditionnotFullputLock.newCondition();底层是链表默认无界也可以在构造时指定容量。关键设计是takeLock和putLock两把独立的锁生产者和消费者分别持有各自的锁互不干扰可以真正并发执行。至于元素总数用一个AtomicInteger单独维护避免跨这两把锁去做统计带来的额外竞争。全面对比对比项ArrayBlockingQueueLinkedBlockingQueue底层结构数组 提前分配好内存链表 动态分配节点是否有界必须指定容量可选 默认无界锁的数量1把 读写共用2把 读写分离并发度较低较高内存特点固定 没有额外GC压力动态分配节点 有一定GC压力适合场景容量固定 追求低延迟高吞吐 容量可以有弹性一句话总结容量固定、追求稳定延迟的场景用ArrayBlockingQueue追求更高并发吞吐、能接受一点动态内存分配开销的场景用LinkedBlockingQueue——这也是很多线程池默认选LinkedBlockingQueue作为任务队列的原因下一篇讲线程池会再次遇到它。五、面试追问Q1ThreadLocal 为什么会导致内存泄漏根本原因是什么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、避免这两个问题的做法。Q4BlockingQueue 的 put/take 和 add/remove 有什么区别add/remove属于抛异常这一组队列满了或空了会直接抛出异常put/take属于阻塞等待这一组队列满了/空了时线程会阻塞挂起等到有空间或者有数据时自动被唤醒继续执行不需要自己写重试或者异常处理逻辑天然适合生产者消费者模型这也是官方推荐在这种场景下优先使用的方法。Q5ArrayBlockingQueue 和 LinkedBlockingQueue 最核心的区别是什么分别适合什么场景最核心的区别是锁的数量ArrayBlockingQueue底层是数组读写共用一把锁并发度较低LinkedBlockingQueue底层是链表生产者和消费者分别持有独立的putLock和takeLock可以真正并发执行吞吐更高但节点动态分配会带来一定的GC压力。容量固定、追求低延迟稳定性选ArrayBlockingQueue需要更高并发吞吐、能接受动态内存分配开销选LinkedBlockingQueue。下一篇预告Day10 开始讲线程池——ThreadPoolExecutor的 7 大核心参数分别是干什么的任务队列满了之后 4 种拒绝策略又是怎么触发的。