前言:那是一个月黑风高的夜晚…
记得那是一个上线前的深夜,大家都在紧张地进行最后的集成测试。突然,测试环境的某个核心服务开始疯狂报警,错误日志刷得飞起 🚀。
错误信息很诡异,大概意思是用户A的操作数据莫名其妙地串到了用户B的请求里。这可是个大事故啊!所有人都被叫了起来,包括正在梦里撸猫的我 😴。
我们几个老鸟围着日志和代码,排查了半天。数据隔离问题?事务问题?缓存问题?各种猜测满天飞。
最后,目光聚焦在了一个不起眼的工具类上,里面用到了 ThreadLocal
来传递用户信息。代码看起来没啥毛病,用户信息 set
进去,后续链路也能 get
到。但问题是,我们的服务是基于Tomcat线程池的,线程是会被复用的啊!
经过一番紧张的Debug和分析,真相大白:开发这个工具类的同学,在请求处理结束时,忘了调用 ThreadLocal
的 remove()
方法!😱 这就导致了线程被回收到池中,下一次请求复用这个线程时,竟然取到了上一个请求残留的用户信息!
所有人恍然大悟,问题解决,虚惊一场。
这次经历让我意识到,ThreadLocal
这个看似简单的工具,很多人可能只是“会用”,但并没有真正“用对”。它的坑,踩下去也是挺疼的。
所以,我决定写两篇文章,跟大家彻底聊聊 ThreadLocal
。
这一篇,我们先聚焦“怎么用”,由浅入深,把它的使用场景和注意事项掰扯清楚。至于它背后的原理,为什么会内存泄漏,ThreadLocalMap
是个啥?咱们留到下一篇《原理篇》再细说,先卖个关子 😉。
耐心看完,你一定有所收获。
正文
ThreadLocal是个啥
简单来说,ThreadLocal
提供了一种线程(Thread)级别的局部(Local)变量。
它最大的特点是:为每个使用该变量的线程都提供一个独立的变量副本。
啥意思呢?
就是说,你创建了一个 ThreadLocal
变量,比如 threadLocalUser
,然后线程A通过 threadLocalUser.set("User A")
设置了值,线程B通过 threadLocalUser.set("User B")
设置了值。那么,在线程A内部,任何时候通过 threadLocalUser.get()
获取到的都是 “User A”,而在线程B内部,获取到的永远是 “User B”。
它们俩互不干扰,就像每个线程都有自己的“小金库”💰,存取都在自己的空间里。
这玩意儿主要用来解决什么问题呢?
-
线程安全问题
当多个线程需要共享某个非线程安全的对象时,一种常见的做法是加锁(
synchronized
或Lock
)。但加锁会带来性能开销和死锁风险。
ThreadLocal
提供了一种“空间换时间”的思路,给每个线程一个独立副本,避免了线程间的竞争,自然也就线程安全了,而且通常比锁更快。 -
线程上下文传递
在一个请求处理链路中(比如Web应用),很多时候我们需要在不同的方法、不同的类之间传递一些公共信息(比如当前登录用户、事务ID、Trace ID等)。
如果一层层通过方法参数传递,代码会变得非常臃肿难看。
ThreadLocal
可以把这些信息存起来,链路中的任何地方都能方便地获取,代码更优雅。✨
核心API(三板斧)
ThreadLocal
的核心API非常简单,记住这三个就差不多了:
-
void set(T value)
: 将当前线程的此线程局部变量的副本设置为指定值。 -
T get()
: 返回当前线程的此线程局部变量的副本中的值。如果这是线程第一次调用该方法,则会通过调用initialValue()
方法来初始化值(除非之前调用过set
)。 -
void remove()
: (敲黑板,划重点!🚨) 移除此线程局部变量的当前线程值。
我们来看个简单的例子:
public class ThreadLocalDemo {
// 1. 创建一个 ThreadLocal 变量
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public static void main(String[] args) {
// 线程 A
new Thread(() -> {
String userName = "酷炫张三";
// 2. 设置值
userContext.set(userName);
System.out.println("Thread A set user: " + userName);
try {
// 模拟业务处理
Thread.sleep(100);
processUserData(); // 在其他方法中获取
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 4. (关键!)移除值
System.out.println("Thread A removing user: " + userContext.get());
userContext.remove();
}
}, "Thread-A").start();
// 线程 B
new Thread(() -> {
String userName = "低调李四";
// 2. 设置值
userContext.set(userName);
System.out.println("Thread B set user: " + userName);
try {
// 模拟业务处理
Thread.sleep(50);
processUserData(); // 在其他方法中获取
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 4. (关键!)移除值
System.out.println("Thread B removing user: " + userContext.get());
userContext.remove();
}
}, "Thread-B").start();
}
private static void processUserData() {
// 3. 获取值
String currentUser = userContext.get();
System.out.println(Thread.currentThread().getName() + " processing data for user: " + currentUser);
// ... 其他业务逻辑 ...
}
}
运行这段代码,你会看到线程A和线程B各自打印自己的用户信息,互不干扰。✅
是不是使用起来很简单?
别忘了 remove()
别忘了 remove()
!别忘了 remove()
!别忘了 remove()
!
重要的事情说三遍!为啥 remove()
这么重要?
还记得开头那个月黑风高的故事吗?问题就出在忘了 remove()
。
在现代Java应用中,尤其是Web服务器(如Tomcat)和各种框架(如Spring),大量使用线程池来处理请求和任务。
而线程池里的线程是会被复用的。
看一下这个流程:
- 请求A来了,线程T1处理它,在
ThreadLocal
里设置了用户A的信息。 - 请求A处理完了,但是!没有调用
remove()
。 - 线程T1被还回线程池。此时,T1内部的
ThreadLocalMap
(这是ThreadLocal
存数据的地方,原理篇细讲)还持有用户A信息的引用。 - 过了一会儿,请求B来了,线程池又把线程T1分配给了请求B。
- 请求B的处理逻辑中,尝试从
ThreadLocal
获取用户信息get()
。糟糕!它取到了上次请求A留下的用户A的信息! 🤯 数据就串了! - 更严重的是,如果
ThreadLocal
存的是比较大的对象,并且不断有新的请求进来,线程不断被复用且不remove()
,这些“残留”的对象会一直存在于线程的ThreadLocalMap
中,无法被GC回收,最终可能导致内存泄漏 ,把你的服务器内存撑爆!💥
所以,最佳实践是: 在使用 ThreadLocal
的代码块(通常是 try-finally
结构)的 finally
中,一定、必须、务必调用 remove()
方法,确保线程执行完毕后清理掉 ThreadLocal
变量。
比如下面这个示例:
ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
public void handleRequest(Request req) {
UserInfo userInfo = getUserInfoFromRequest(req);
userInfoThreadLocal.set(userInfo);
try {
// ... 执行业务逻辑,中间可能会调用N多方法 ...
serviceA();
serviceB();
// ... 这些方法内部可以通过 userInfoThreadLocal.get() 获取用户信息 ...
} finally {
// 无论业务逻辑是否异常,都要清理!
userInfoThreadLocal.remove(); // <-- 千万别忘了这一步!
System.out.println("ThreadLocal for user " + userInfo.getId() + " removed.");
}
}
initialValue()
和 withInitial()
有时候,我们希望 ThreadLocal
在第一次 get()
并且没有 set()
过的时候,能返回一个默认值,而不是 null
。可以通过重写 initialValue()
方法或者使用 ThreadLocal.withInitial()
工厂方法来实现。
方式一:重写 initialValue()
private static final ThreadLocal<Integer> counter = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
System.out.println(Thread.currentThread().getName() + " initializing counter to 0");
return 0; // 初始值为 0
}
};
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " initial get: " + counter.get()); // 首次get,会调用initialValue
counter.set(counter.get() + 1);
System.out.println(Thread.currentThread().getName() + " after increment: " + counter.get());
counter.remove();
}, "Thread-C").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " initial get: " + counter.get()); // 另一个线程首次get,也会调用initialValue
counter.set(counter.get() + 5);
System.out.println(Thread.currentThread().getName() + " after increment: " + counter.get());
counter.remove();
}, "Thread-D").start();
}
方式二:使用 withInitial()
👍 这种方式更简洁,推荐使用!
private static final ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> {
System.out.println(Thread.currentThread().getName() + " initializing counter to 0 via withInitial");
return 0; // 使用 Lambda 表达式提供初始值
});
注意:
-
initialValue()
或withInitial()
提供的初始值,只会在当前线程第一次调用get()
且没有调用过set()
时被设置。 - 如果调用过
set()
,再调用get()
就会返回set
的值。 - 如果调用了
remove()
之后再调用get()
,则会重新触发初始化逻辑。
InheritableThreadLocal
父子线程传递
还有一个 ThreadLocal
的亲戚叫 InheritableThreadLocal
。
它的特殊之处在于:当父线程创建一个子线程时,子线程会自动继承父线程中 InheritableThreadLocal
变量的值。
直接看代码:
// 使用 InheritableThreadLocal
private static final ThreadLocal<String> inheritableContext = new InheritableThreadLocal<>();
public static void main(String[] args) {
inheritableContext.set("Value from Main Thread");
System.out.println("Main thread value: " + inheritableContext.get());
new Thread(() -> {
// 子线程可以获取到父线程设置的值
System.out.println("Child thread inherited value: " + inheritableContext.get());
// 子线程修改值,不影响父线程
inheritableContext.set("Value modified by Child Thread");
System.out.println("Child thread modified value: " + inheritableContext.get());
// 同样需要 remove
inheritableContext.remove();
}).start();
// 等待子线程执行完毕
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
// 父线程的值不受子线程修改影响
System.out.println("Main thread value after child finished: " + inheritableContext.get());
inheritableContext.remove();
}
注意点:
- 继承是创建子线程时发生的,是值的浅拷贝(对于引用类型,父子线程共享同一个对象引用)。
- 子线程创建后,父子线程各自修改
InheritableThreadLocal
的值互不影响。 -
线程池场景下的坑:
InheritableThreadLocal
在线程池中使用时要特别小心!因为线程复用,父线程设置的值可能会被“意外”带到后续不相关的任务中。如果父任务创建了子任务并提交到线程池,这种继承关系可能会导致混乱和内存泄漏。阿里巴巴的transmittable-thread-local
(TTL) 库就是为了解决这个问题而生的,感兴趣可以去了解下。
所以除非你非常明确知道需要父子线程传递数据,并且清楚其潜在风险,否则优先使用普通的 ThreadLocal
,并不简易直接使用 InheritableThreadLocal
一些常见的使用场景
-
Web 应用中的用户身份传递
在 Filter 或 Interceptor 中获取用户信息,
set
到ThreadLocal
,后续 Controller、Service 层都可以方便地get
到。请求结束时在 Filter 或 Interceptor 的
finally
块中remove
。 -
事务管理
Spring 框架广泛使用了
ThreadLocal
来管理事务状态(TransactionSynchronizationManager
)。每个线程持有自己的事务信息(是否开启事务、隔离级别、是否只读等)。
-
日志链路追踪 (Trace ID)
在分布式系统中,为了追踪一个请求的完整调用链路,通常会生成一个全局唯一的 Trace ID。
这个 Trace ID 可以放在
ThreadLocal
中,随着请求在服务内部的线程调用栈中传递,打印日志时带上它,方便串联日志。
结尾
好了,关于 ThreadLocal
的使用篇就聊到这里。我们从一个真实的“踩坑”故事出发,了解了 ThreadLocal
是什么,为什么用它,怎么用它。
也了解了它的核心API (set
, get
, remove
)和重要事项(必须remove()
)等。
希望通过这篇“使用篇”,你能对 ThreadLocal
的正确用法有一个更清晰、更深入的认识。下次再遇到需要它解决问题的场景时,能胸有成竹,用得明明白白,避免重蹈我们的覆辙。
当然,仅仅知道怎么用还不够“酷” 😎。
想知道 ThreadLocal
底层是怎么为每个线程维护独立副本的吗?ThreadLocalMap
到底长啥样?为什么 remove()
如此关键,不 remove
就一定会内存泄漏吗?弱引用(WeakReference)在其中扮演了什么角色?
别急,这些问题的答案,我们将在下一篇 《你真的会用ThreadLocal吗——原理篇》 中为你揭晓!敬请期待! 😉