多线程基础 ThreadLocal
多线程基础 ThreadLocal:线程隔离、线程级复用、上下文传递,避免加锁和层层传参,提升性能和可维护性。
多线程基础-ThreadLocal
一、ThreadLocal 是什么?
1.1 简述
ThreadLocal 是一种线程隔离机制,用来为每个线程保存一份独立的变量副本。
- 同一个
ThreadLocal对象 - 在不同线程中
- 取到的是 不同的值
1.2 ThreadLocal 解决了什么问题?
在多线程环境中,我们经常遇到:
| 问题 | 代价 |
|---|---|
| 共享变量 | 需要加锁,性能下降 |
| 每次 new 对象 | 安全但 GC 压力大 |
| 参数层层传递 | 侵入性强,可维护性差 |
ThreadLocal 的目标:
用「线程隔离」代替「加锁」, 用「线程级复用」代替「方法级创建」。
二、ThreadLocal 的核心好处
| 好处 | 说明 |
|---|---|
| 线程安全 | 每个线程一份数据 |
| 无锁 | 不需要 synchronized |
| 对业务无侵入 | 不用层层传参 |
| 对象可复用 | 生命周期提升到线程级 |
| 非常适合上下文数据 | 用户、TraceId、事务 |
三、ThreadLocal 的底层原理(简述)
- 每个
Thread内部维护一个ThreadLocalMap ThreadLocal作为 key- 真正的 value 存在 线程内部
Thread
└── ThreadLocalMap
├── ThreadLocalA -> valueA
└── ThreadLocalB -> valueB
⚠️ ThreadLocal 本身不存值,值是线程持有的
四、用法一:SimpleDateFormat(线程级复用,工具类)
4.1 问题背景(不用 ThreadLocal 的坏处)
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
❌ 问题:
-
SimpleDateFormat非线程安全 -
并发下可能:
-
格式错乱
-
时间错误
-
偶发异常(最难排查)
4.2 每次 new 的写法(安全但低效)
public static String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
缺点:
- 每次调用都创建对象
- 高并发下 GC 压力大
4.3 推荐方案:ThreadLocal + SimpleDateFormat
Demo 说明
- 使用线程池模拟 Tomcat
- 每个线程只创建一次
SimpleDateFormat - 后续请求直接复用
完整 Demo
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* ThreadLocalDateDemo
*
* @author suremotoo
* @date 2025/12/28 23:18
*/
public class ThreadLocalDateDemo {
private static final ThreadLocal<SimpleDateFormat> SDF =
ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 4; i++) {
pool.execute(() -> {
SimpleDateFormat sdf = SDF.get();
String time = sdf.format(new Date());
System.out.println(
Thread.currentThread().getName()
+ " -> " + time
+ " | identityHashCode="
+ System.identityHashCode(sdf)
);
});
}
pool.shutdown();
}
}
运行结果特征
- 同一个线程:
SimpleDateFormat的identityHashCode相同 - 不同线程:
identityHashCode不同
4.4 是否需要 remove?
| 场景 | 是否 remove |
|---|---|
| 工具类 / 线程级缓存 | ❌ 可不 remove |
| 请求级 / 用户级数据 | ✅ 必须 remove |
五、用法二:用户登录上下文(请求级 ThreadLocal)
场景: 用户登录后,希望在 多个 Service / 方法中直接获取用户信息, 而不是 controller → service → service 层层传参。
5.1 设计目标
- 用户信息只在 当前请求线程 中可见
- 请求结束后自动清理
- 不污染方法签名
5.2 “伪 Web”完整 Demo
说明:
- 用线程池模拟 Tomcat
- 每个任务模拟一次 HTTP 请求
- 模拟登录 → Service 调用 → 清理上下文
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* ThreadLocalUserDemo
*
* @author suremotoo
* @date 2025/12/28 23:32
*/
public class ThreadLocalUserDemo {
// ==== = 模拟登录用户 ==== =
static class LoginUser {
private final Long userId;
private final String username;
public LoginUser(Long userId, String username) {
this.userId = userId;
this.username = username;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
}
// ==== = 用户上下文 ==== =
static class UserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
public static void set(LoginUser user) {
USER_HOLDER.set(user);
}
public static LoginUser get() {
return USER_HOLDER.get();
}
public static void clear() {
USER_HOLDER.remove();
}
}
// ==== = 模拟 Service ==== =
static class OrderService {
public void createOrder() {
LoginUser user = UserContext.get();
System.out.println(Thread.currentThread().getName() + " 创建订单,用户=" + user.getUsername());
// 调用查询历史服务, 实际不用每次都 new 一个, 这里只是为了演示
new HistoryService().queryHistory();
}
}
static class HistoryService {
public void queryHistory() {
LoginUser user = UserContext.get();
System.out.println(Thread.currentThread().getName() + " 查询历史,用户=" + user.getUsername());
}
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
OrderService orderService = new OrderService();
for (int i = 0; i < 4; i++) {
final int userId = i;
pool.execute(() -> {
try {
// ==== = 模拟登录拦截器 ==== =
LoginUser user = new LoginUser((long) userId, "user-" + userId);
UserContext.set(user);
// ==== = 业务调用 ==== =
orderService.createOrder();
} finally {
// ==== = 请求结束必须清理 ==== =
UserContext.clear();
}
});
}
pool.shutdown();
}
}
5.3 不使用 ThreadLocal 的坏处
❌ 方法侵入:
createOrder(userId);
❌ 层层传参:
Controller → Service → Service → Dao
❌ 后期维护成本极高
六、ThreadLocal 使用注意事项(非常重要)
6.1 线程池 + ThreadLocal = 必须清理
| 场景 | 后果 |
|---|---|
| 不 remove | 用户数据串线程 |
| 不 remove | 内存泄漏 |
| 不 remove | 安全事故 |
比如如果仅仅是泄露几个简单的 User 对象或 DateFormat 对象,确实很难直接把现在的服务器内存撑爆(OOM)。
但是,实际生产环境中 ThreadLocal 导致 OOM 的根本原因,往往不是因为泄露的对象本身太大,而是因为 它拖住了一连串“庞然大物”无法回收。
我们需要分两个层面来解释:一个是 基础的内存泄露机制,一个是 导致 OOM 的另一个原因(ClassLoader 泄露)。
6.2 基础机制:为什么会泄漏?(弱引用 Key vs 强引用 Value)
首先,回顾一下 ThreadLocalMap 的内部结构。它的 Entry 是继承自 WeakReference 的。
- Key (ThreadLocal 对象):是 弱引用。
- Value (你的业务对象):是 强引用。
泄露过程:
- 当你的业务代码跑完,
ThreadLocal的引用(Key)在外部没有被使用了,GC 会把这个 Key 回收掉(因为它是弱引用)。 - 此时,ThreadLocalMap 里就出现了一行
Key = null的记录:[null, UserObject]。 - 问题来了:虽然 Key 没了,但是 Value (UserObject) 还是通过
Thread -> ThreadLocalMap -> Entry -> Value这条 强引用链 关联在当前线程上的。 - 只要这个线程不结束(Tomcat 线程池的线程通常是常驻的),这个 Value 就永远无法被 GC。
“就算泄露,也就 200 个线程泄露 200 个 User 对象,也就是几 MB 的事,怎么会 OOM?”
如果在同一个 ClassLoader 生命周期内,且对象很小,确实不容易 OOM。但是,Web 应用有一个致命的操作叫做:热部署(Redeploy/Reload)。
6.3 OOM :Classloader 泄露 (The ClassLoader Leak)
这是最常见、最严重的场景。
背景知识:
- Tomcat 的线程池属于 容器(Server),它的生命周期很长。
- 你的 Web 应用(War 包)属于 应用,它由
WebAppClassLoader加载。 - Java 中,对象持有类的引用,类持有加载它的 ClassLoader 的引用。
泄漏推演:
- 应用启动:
WebAppClassLoader A加载了你的应用。 - 请求处理:Tomcat 线程(比如
http-nio-1)处理请求,你在 ThreadLocal 里set了一个对象MyContext(这个类是由WebAppClassLoader A加载的)。 - 忘记清理:请求结束,你忘了调用
remove()。此时,Tomcat 线程(http-nio-1)持有MyContext的强引用。 - 应用重启/热部署:
- 你修改了代码,Tomcat 重新加载应用。
- Tomcat 会创建一个新的
WebAppClassLoader B来加载新代码。 - 旧的
WebAppClassLoader A应该被 GC 回收,连同它加载的所有类(Class)和静态变量(Static)。
- GC 失败(泄露发生):
- GC 想要回收
WebAppClassLoader A。 - 检查引用链:
- Tomcat 线程(活得好好的) -> ThreadLocalMap -> Entry -> Value (
MyContext对象) MyContext对象 ->MyContext.classMyContext.class->WebAppClassLoader A- 结果:因为 Tomcat 线程还抓着旧应用的一个小对象,导致 整个旧应用的 ClassLoader 无法被回收!
- 滚雪球效应:
- ClassLoader 无法回收,意味着它加载的 成千上万个 Class、所有的 Static 变量、元数据(Metaspace) 都无法释放。
- 如果你热部署了 5 次,内存里就存着 5 份完整的旧应用代码和静态数据。
- OOM 爆发:通常是
java.lang.OutOfMemoryError: Metaspace。
结论:
你以为只是泄露了一个 User 对象,实际上你通过这个对象,“绑架”了整个 Web 应用的历史版本。
6.4 另外一种情况:积少成多(Heap OOM)
如果不涉及热部署,仅仅是长时间运行,也可能 OOM,但这通常是因为 value 本身比较大。
场景:
假设你在 ThreadLocal 里放了一个 List<Data> 做缓存,每次请求往里加一点数据,但是逻辑有 Bug,只加不删,也不 remove。
- Tomcat 线程是复用的。
- 第 1 次请求:List size = 100
- 第 2 次请求(复用同一个线程):List size = 200
- ...
- 第 10000 次请求:List size = 1,000,000
虽然线程数有限(比如 200 个),但每个线程挂着的数据越来越大,最终占满堆内存(Heap),导致 java.lang.OutOfMemoryError: Java heap space。
6.5 总结
可能的大头原因:是 ThreadLocal 导致 ClassLoader 无法卸载。
- 引用链:
Thread->ThreadLocalMap->Value->Class->ClassLoader->整个应用的所有类和静态资源。 - 这会导致 Metaspace/PermGen 内存泄漏,这才是 Web 容器中 ThreadLocal 导致 OOM 的最主要原因。
所以,无论对象多小,try-finally { xxx.remove(); } 是铁律。
ThreadLocal 不适合的场景
- 线程间通信
- 生命周期不清晰的数据
- 大对象、不可控对象
- static 变量共享对象(可直接访问)
七、什么时候值得用 ThreadLocal?
| 条件 | 是否满足 |
|---|---|
| 线程会被复用 | ✅ |
| 对象会被多次使用 | ✅ |
| 非线程安全 | ✅ |
| 上下文型数据 | ✅ |
框架中的应用
Spring 框架是 ThreadLocal 的大户。它利用 ThreadLocal 实现了组件之间的解耦(不用层层传参),同时通过框架底层的切面(AOP)或 拦截器 严格管理了 set 和 remove 的生命周期,从而避免了内存泄漏。
比如: 时间工具 DateTimeContextHolder、请求上下文 RequestContextHolder、国际化 (LocaleContextHolder) 等等。
八、时间格式化建议
Java 8+ 时间 API 优先
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
- 线程安全
- 不可变
- 不需要 ThreadLocal
九、总结
ThreadLocal 的本质是:
用线程隔离保证线程安全, 用线程生命周期复用对象, 在不侵入业务代码的前提下传递上下文信息。