知识点 12:并发编程 —— ThreadLocal 线程本地变量
1. 是什么?它解决了什么问题?
ThreadLocal 是 Java 提供的一个非常独特的解决线程安全问题的工具,它提供了一种全新的思路:不共享,即安全。
它的核心思想是:为每一个使用该变量的线程都提供一个独立的、私有的变量副本。每个线程都只能读写自己的副本,而不能访问其他线程的副本。这样,每个线程操作的都是自己的“私有”变量,自然就不存在线程安全问题。
ThreadLocal 解决了什么问题?
它提供了一种“以空间换时间”的方式来保证线程安全,避免了传统锁机制(如 synchronized)带来的性能开销和复杂性。在以下场景中非常有用:
- 管理线程不安全的工具类实例:例如
SimpleDateFormat或Random等不是线程安全的类。通过ThreadLocal,每个线程可以持有自己的实例,避免了频繁创建对象或对共享实例加锁。 - 传递上下文信息:在复杂的业务调用链中,用来传递贯穿整个请求的上下文信息,例如用户身份信息、事务 ID、数据库连接等。这样就避免了将这些信息作为方法参数层层传递,使代码更简洁、可读性更高。
- 避免共享资源的竞争:当多个线程需要访问同一个资源,但又不希望它们相互影响时,
ThreadLocal可以为每个线程提供独立的资源副本。
生活比喻:ThreadLocal 就像是去银行办理业务时,银行发给你的专属储物柜的钥匙。
ThreadLocal变量:就是那个储物柜本身。它对所有来银行的人(线程)都是可见的。set(value):你(当前线程)用你的钥匙,把你自己的私人物品(比如User对象)存入你的专属储物柜里。get():你随时可以用你的钥匙,从你的储物柜里取出你自己的物品。你无法打开别人的储物柜,别人也无法打开你的。- 线程销毁:当你离开银行(线程销毁)时,你的储物柜和里面的东西理论上就应该被清理掉。
2. 实现原理是怎样的?
很多人误以为 ThreadLocal 内部有一个 Map<Thread, Object> 来存储每个线程的值。但实际上,它的设计更巧妙。
核心思想:每个 Thread 对象内部都维护着一个 ThreadLocalMap 类型的成员变量,这个 Map 才是真正存储线程本地数据的地方。
Thread与ThreadLocalMap:- 每个线程
Thread内部都有一个成员变量,名为threadLocals,它的类型是ThreadLocal.ThreadLocalMap。这个ThreadLocalMap是懒加载的,只在线程第一次使用ThreadLocal时创建。
- 每个线程
ThreadLocalMap的结构:- 这是一个
ThreadLocal的内部静态类,类似于一个简化的HashMap,它内部维护着一个Entry[]数组。 - 每个
Entry对象都继承自WeakReference<ThreadLocal<?>>。这意味着Entry的 Key 是ThreadLocal对象本身,并且是一个弱引用。 Entry的 Value 才是我们想要存储的线程本地变量值,并且是一个强引用。
- 这是一个
set(value) 和 get() 的核心流程:
-
set(value)流程:- 获取当前线程
Thread.currentThread()。 - 从当前线程对象中,获取它的成员变量
threadLocals(即ThreadLocalMap)。 - 如果这个
map不存在,就为当前线程创建一个新的ThreadLocalMap。 - 将当前的
ThreadLocal对象作为 Key(被WeakReference包装),要存的value作为 Value,存入这个ThreadLocalMap中。
- 获取当前线程
-
get()流程:- 获取当前线程。
- 获取该线程的
threadLocalsmap。 - 如果
map存在,就以当前的ThreadLocal对象为 Key,从map中取出对应的Value并返回。 - 如果
map或对应的Entry不存在,则会进行初始化并返回null(或者initialValue()方法的返回值)。
总结:数据是存储在线程自己内部的 ThreadLocalMap 里的,而不是 ThreadLocal 对象里。ThreadLocal 对象本身仅仅是一个“索引”或者“钥匙”,用于从当前线程的 ThreadLocalMap 中找到并操作对应的线程本地值。
3. 内存泄漏问题及如何避免
这是 ThreadLocal 最重要的考点,也是使用时最容易出错的地方。
问题根源:ThreadLocalMap 中的 Key 是弱引用,而 Value 是强引用。
- 弱引用的 Key (
ThreadLocal对象 ):当外部(例如业务代码)不再有任何强引用指向一个ThreadLocal对象时,这个ThreadLocal对象就会在下次垃圾回收时被回收。此时,ThreadLocalMap中对应的Entry的 Key 就会变成null。 - 强引用的 Value:然而,这个 Key 为
null的Entry中存储的Value(即我们存入的数据),仍然是一个强引用,它不会被自动回收。
内存泄漏的发生:
如果线程是一个长生命周期的线程(例如线程池中的线程),它会一直存在,那么它的 ThreadLocalMap 也会一直存在。此时,即使 ThreadLocal 对象被回收,但 Key 变为 null 的 Entry 中的 Value 却无法被回收,导致这部分内存一直被占用,从而引发内存泄漏。
ThreadLocalMap 内部的补偿清理机制
ThreadLocalMap的设计者预见了 Key 被回收后 Value 无法被访问的问题,并内置了一些补偿性的清理机制。
- 清理时机:当调用
ThreadLocal的get(),set(),remove()方法时,ThreadLocalMap会顺便检查并清理那些key已经为null的“脏”Entry。 - 工作方式:
- 在
get()或set()操作过程中,当遍历Entry数组时,如果发现某个Entry的key为null,它会触发一次启发式清理(expungeStaleEntry),清除这个Entry及其附近的其他“脏”Entry。 remove()方法在移除当前ThreadLocal对应的Entry后,也会触发一次清理。
- 在
结论:ThreadLocalMap确实有被动的、补偿性的清理机制。但是,我们绝对不能依赖它来避免内存泄露,因为这些清理操作的触发时机是不确定的。如果一个“脏”Entry一直不被get, set等操作触及,它就可能永远得不到清理。
如何避免内存泄漏?
为了彻底避免内存泄漏,最佳实践是:在每次使用完 ThreadLocal 后,务必在 finally 块中调用 threadLocal.remove() 方法。
remove()方法的作用:它会从当前线程的ThreadLocalMap中移除对应的Entry(包括 Key 和 Value)。这样,Key 为null的Entry及其对应的 Value 都会被清除,从而允许垃圾收集器正常回收这部分内存。- 最佳实践场景:特别是在线程池环境中,
ThreadLocal的remove()操作是强制性的,必须在任务结束时清理。 - Spring 框架的处理:在 Spring Framework 中,对于请求范围(
requestscope)或会话范围(sessionscope)的 Bean,以及事务管理等场景,Spring 内部会妥善处理ThreadLocal的remove()操作(通常在请求结束或事务提交/回滚时),因此开发者通常无需手动干预。但在自定义的非 Spring 管理的线程或ThreadLocal使用中,手动remove()是强制性的。
// 示例:如何正确使用 ThreadLocal 并避免内存泄漏
ThreadLocal<String> userThreadLocal = new ThreadLocal<>();
try {userThreadLocal.set("some_user_context");// ... 执行业务逻辑 ...
} finally {// 务必在 finally 块中调用 remove(),确保在任何情况下都能清理userThreadLocal.remove();
}
内存泄露的危害场景分析
-
危害小的场景:
- 线程生命周期短:当一个线程执行完任务就销毁时,它所持有的
ThreadLocalMap也会随之被销毁。即使在运行期间产生了“脏”Entry,随着线程的死亡,整个ThreadLocalMap(包括里面的强引用value)都会被垃圾回收。 - 例子:为每个任务创建一个新线程,执行完就结束,这种情况下即使忘记
remove(),影响也有限。
- 线程生命周期短:当一个线程执行完任务就销毁时,它所持有的
-
危害大的场景:
- 线程池(Thread Pool):这是
ThreadLocal内存泄露最典型、最危险的场景! - 原因:线程池中的线程是被复用的,它们的生命周期非常长,几乎和应用程序一样长。当一个任务结束时,处理该任务的线程并不会销毁,而是被归还给线程池,等待下一个任务。
- 后果:如果在任务代码中使用了
ThreadLocal但忘记remove(),那么这个ThreadLocal的value就会一直被这个存活的、被复用的线程所持有,永远无法被回收。随着越来越多的任务被这个线程执行,ThreadLocalMap中积累的“脏”Entry会越来越多,最终导致OOM(内存溢出)。
- 线程池(Thread Pool):这是
4. 核心代码示例:数据库连接管理
ThreadLocal 的一个经典应用场景,就是在同一个线程的多次数据库操作中,共享同一个 Connection 对象,避免频繁创建和关闭连接。
package com.study.concurrency;import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;class ConnectionManager {// 1. 创建一个 ThreadLocal 对象来存储 Connectionprivate static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();public static Connection getConnection() {// 2. 先从当前线程的 ThreadLocal 中获取连接Connection conn = connectionHolder.get();try {// 如果没有连接,或者连接已关闭if (conn == null || (conn.isClosed() && conn != null)) { // 增加 conn != null 避免 NPE// 3. 创建一个新的连接// 注意:这里只是示例,实际生产环境应使用连接池conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "user", "password");// 4. 将新连接存入当前线程的 ThreadLocalconnectionHolder.set(conn);System.out.println(Thread.currentThread().getName() + ": 创建了一个新的数据库连接。");}} catch (SQLException e) {e.printStackTrace();}return conn;}public static void closeConnection() {Connection conn = connectionHolder.get();try {if (conn != null && !conn.isClosed()) {conn.close();System.out.println(Thread.currentThread().getName() + ": 关闭了数据库连接。");}} catch (SQLException e) {e.printStackTrace();} finally {// 5. 务必调用 remove(),防止内存泄漏connectionHolder.remove();System.out.println(Thread.currentThread().getName() + ": 清理了 ThreadLocal。");}}
}public class ThreadLocalDemo {public static void main(String[] args) throws InterruptedException {Runnable task = () -> {System.out.println(Thread.currentThread().getName() + ": 开始执行任务。");// 在同一个线程中,第一次获取连接会创建新的Connection conn1 = ConnectionManager.getConnection();// 第二次获取,会直接复用第一次的连接Connection conn2 = ConnectionManager.getConnection();System.out.println(Thread.currentThread().getName() + ": conn1 和 conn2 是同一个对象吗? " + (conn1 == conn2));// 模拟业务操作try {Thread.sleep((long) (Math.random() * 100));} catch (InterruptedException e) {Thread.currentThread().interrupt();}// 线程结束前,关闭连接并清理 ThreadLocalConnectionManager.closeConnection();System.out.println(Thread.currentThread().getName() + ": 任务执行结束。");};// 两个线程会各自创建和使用自己的连接,互不干扰Thread t1 = new Thread(task, "线程A");Thread t2 = new Thread(task, "线程B");