从一次线上事故说起吧。
那年我们有个订单服务,用户量一上来,日志里就频发时间解析异常。同一个时间字符串,有时候解析成功,有时候直接抛ParseException,甚至出现过订单表中的create_time被写成了完全不相干的另一个时间。定位到最后,责任人就是SimpleDateFormat——一个我们为了“省事”,定义成static的工具类。
后来我把那个static改成了ThreadLocal.withInitial(() -> new SimpleDateFormat(...)),问题当场消失。那是我第一次意识到:多线程环境下的状态隔离,不是靠加锁就能优雅解决的,很多场景更需要“线程私有”的存储空间。这个机制就是ThreadLocal。
这篇是这个系列的第一篇,我会先把它“是什么、为什么需要、内部怎么实现的、有哪些坑、框架里怎么用”讲透。内容偏底层和原理,但我会尽量用大白话加代码,确保刚开始接触并发编程的人也能跟上。
1. 一个并发日期解析Bug,引出线程隔离的刚需
1.1 SimpleDateFormat为什么会在高并发下错乱
SimpleDateFormat内部维护了一个Calendar对象,parse()和format()都会先对Calendar做clear(),然后写入当前要解析/格式化的时间字段,最后再读取。多线程同时操作同一个实例时,A线程刚刚写入的字段,可能被B线程的clear()给冲掉,于是A线程读到的就是脏数据。
用代码模拟一下就非常直观:
public class DateFormatDemo { private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(8); CountDownLatch latch = new CountDownLatch(1); for (int i = 0; i < 20; i++) { final String source = "2024-06-01 10:00:0" + (i % 10); pool.submit(() -> { try { latch.await(); Date date = SDF.parse(source); String result = SDF.format(date); if (!source.equals(result)) { System.out.println("脏数据: " + source + " -> " + result); } } catch (InterruptedException | ParseException e) { e.printStackTrace(); } }); } latch.countDown(); pool.shutdown(); } }运行之后,控制台里会出现大量“脏数据”输出。问题本质不是SimpleDateFormat,而是多个线程共享了同一个可写状态。
1.2 加锁能解决问题,但代价不划算
解决方案很多,最直接的是加锁:
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private static synchronized Date parse(String source) throws ParseException { return SDF.parse(source); }线程安全是保证了,但高并发下锁竞争会让吞吐量直线下降。SimpleDateFormat本身是一个轻量对象,为了一次格式化去抢一把全局锁,性价比太低。更关键的是,加锁会把并发请求串行化,这跟多线程的初衷是相悖的。
更好的思路其实是:既然每个线程都只需要自己的那份状态,那就一人发一个SimpleDateFormat实例,各用各的,互不干扰。ThreadLocal干的正是这件事。
1.3 参数透传也很痛苦:状态到底该放哪
除了线程安全,开发中还有一个更常见的痛点——上下文传递。
一个请求从Controller到Service再到Dao,如果中间要传递userId、traceId、租户ID等一堆和业务逻辑无关的信息,最粗暴的写法是给每个方法加参数。结果就是方法签名被污染得没法看,而且一旦中间某个方法忘了传,链路就断了。
这也是ThreadLocal最典型的应用场景之一:把“和当前线程绑定的状态”放在一个线程私有容器里,业务代码任何位置都能直接取,不需要从参数里层层透传。
所以ThreadLocal本质上是Java提供的“线程局部变量”机制:每个线程都能通过同一个ThreadLocal对象,访问到属于自己的那份变量副本。它解决的是共享可变状态的隔离问题,而不是替代锁去保证“多个线程协作访问同一个数据”。
2. 把ThreadLocal的四个核心API吃透,新手坑先避掉一半
2.1 ThreadLocal.withInitial:初始化方式的进化
Java 8之后,最推荐的创建方式是withInitial:
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));相比老式写法:
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<SimpleDateFormat>() { @Override protected SimpleDateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); } };withInitial用Supplier接口替代了匿名内部类,代码更简洁,而且语义非常清楚:“当某个线程第一次调用get()且还没有值时,用这个工厂方法创建一个默认值”。
理解initialValue的触发时机很重要。它是懒加载的,只有线程第一次get()时才会执行。如果写的是有副作用的初始化逻辑,那这个副作用也只会发生在每个线程的第一次get()上。
2.2 get/set/remove:用法与返回值易错点
ThreadLocal对外最核心的就是三个方法:
set(T value):把值绑定到当前线程。get():取出当前线程绑定值,如果还没有值,会调用initialValue计算默认值。remove():清除当前线程绑定值。
有一个容易忽略的点:get()的返回值是当前线程维度的,不是全局的。同一个ThreadLocal对象,线程A里set("AAA"),线程B里get()得到的是B自己的值,或者默认值,绝不会读到A的“AAA”。
另外,get()还有一点坑:如果当前线程从未set过,也没有重写initialValue,那么get()返回的是null,而不是抛异常。
private static final ThreadLocal<String> TL = new ThreadLocal<>(); public static void main(String[] args) { System.out.println(TL.get()); // null }所以如果你期望get()永远返回非空,一定要在初始化时给一个默认值,否则每个取用点都要做空判断。
2.3 关键误区:ThreadLocal存的是线程私有引用,不是对象副本
这是我见过最多的理解偏差。ThreadLocal并不会像Cloneable那样复制对象,它保存的是你set进去的那个对象的引用。换句话说,如果多个线程set的是同一个对象,那这个对象的内部状态依然是共享的。
比如:
private static final ThreadLocal<List<String>> TL = ThreadLocal.withInitial(ArrayList::new);withInitial是每个线程各自new一个ArrayList,所以线程之间互不干扰。但如果你写的是:
private static final List<String> SHARED = new ArrayList<>(); private static final ThreadLocal<List<String>> TL = ThreadLocal.withInitial(() -> SHARED);那所有线程拿到的其实是同一个SHARED实例,ThreadLocal在这套写法里完全失去了隔离意义。
这是非常隐蔽的坑。凡是准备放进ThreadLocal的对象,要么在set时保证是当前线程专属的新实例,要么初始化时通过Supplier创建一个全新对象。千万别把外部共享的可变对象直接塞进去。
2.4 标准使用模板:一定要配合finally remove
ThreadLocal用完之后,必须调用remove()清理。最稳妥的写法是配合try-finally:
public void handleRequest(User user) { UserContext.set(user); try { // 业务处理,任何地方都可以通过 UserContext.get() 取到当前用户 doSomething(); } finally { UserContext.clear(); // 内部调用 remove() } }如果你用的是Web框架的拦截器或过滤器,清理动作应该放在afterCompletion或finally块里。这个不是洁癖,是防止两类严重问题:
- 线程池里线程复用,上一个请求的数据串到下一个请求;
- 线程长期存活,
ThreadLocalMap里的值一直无法被回收,埋下内存泄漏隐患。
第4章会详细展开内存泄漏这条链路。这里先记住一句话:有set没remove,等于给系统埋雷。
3. 探进ThreadLocalMap内部:弱引用、黄金分割数与线性探测
3.1 Thread、ThreadLocalMap和ThreadLocal的三角关系
先看一张逻辑关系图(文字版):
Thread 对象 └── threadLocals(字段类型为 ThreadLocal.ThreadLocalMap) └── Entry[] table ├── Entry: key=ThreadLocal实例(弱引用), value=线程私有值 ├── Entry: key=ThreadLocal实例(弱引用), value=线程私有值 └── ...关键点在于:ThreadLocalMap并不是存在ThreadLocal对象里的,而是存在每一个Thread对象内部。每个线程都有自己的threadLocals,默认是null,只有当线程第一次调用ThreadLocal.set()或者get()时,才会初始化这个Map。
所以从存储角度看:一个ThreadLocal实例可以被多个线程共享引用,但每个线程往它里面塞的值,只是存放在自己线程的ThreadLocalMap中。
这就像办公楼的公共储物柜:柜门钥匙(ThreadLocal对象)所有人都拿同一把,但每个员工的工位底下其实有自己的抽屉,钥匙只是用来找到自己的那个抽屉而已。
ThreadLocalMap有几个关键参数:
| 参数 | 值 | 说明 |
|---|---|---|
| 初始容量 | 16 | 与HashMap默认容量一致 |
| 负载因子 | 2/3 | 扩容阈值按len * 2 / 3计算 |
| 索引计算 | key.threadLocalHashCode & (len - 1) | 利用位运算代替取模 |
| 冲突处理 | 开放寻址法(线性探测) | 与HashMap的链地址法不同 |
3.2 Entry为什么继承WeakReference
ThreadLocalMap中的每一个节点是Entry,它的定义长这样:
static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }Entry继承了WeakReference,意味着key(也就是ThreadLocal实例)被弱引用持有。这背后的动机要从生命周期说:
- 如果
ThreadLocalMap的key是强引用,那么只要线程存活,ThreadLocal实例就永远不会被回收。 - 如果key是弱引用,当外部不再持有
ThreadLocal的强引用时,GC可以把ThreadLocal实例回收掉,Entry的key就变成了null。
此时,即便value还在,ThreadLocalMap也已经能识别出这是一个“过期条目”(stale entry),后续的set/get/remove操作有机会把它清理掉。
设计者在这里做了一个非常核心的权衡:如果把key设计成强引用,内存泄漏会更严重;设计成弱引用,虽然不能完全避免泄漏,但给了系统自我清理的机会。
3.3 0x61c88647这个神秘数字为什么管用
你在ThreadLocal源码里会看到这样的定义:
private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647;0x61c88647是一个有数学背景的数字。它其实是与黄金分割比相关的哈希增量。ThreadLocal每次创建新实例时,nextHashCode都会加上这个固定增量,生成一个散列值。
为什么要这么做?因为ThreadLocalMap用的是开放寻址法,一旦哈希冲突,就要进行线性探测,也就是往后逐个找空位。如果哈希值分布不均匀,冲突会非常严重,get和set的性能会退化到近乎遍历。
0x61c88647这个增量可以保证生成的哈希值在2^n大小的数组上分布足够均匀。简单理解:它让不同ThreadLocal实例在Map里的位置尽量散开,减少线性探测次数。
这个数字不是拍脑袋取的,它来自2^32 * (√5 - 1) / 2的整数部分,也就是黄金分割数在小数域上的近似表达。Java里ThreadLocal和ConcurrentHashMap(早期分段锁设计时)都用过类似的散列思路。
3.4 开放寻址法与扩容时机
ThreadLocalMap在插入元素时,如果计算出来的索引位置已经有Entry,会向后线性探测,直到找到一个空位。
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len - 1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }nextIndex就是(i + 1) & (len - 1),在数组上环形向后移动。当sz >= threshold(threshold为len * 2 / 3)时,触发rehash()。rehash会先全面清理一次过期Entry,如果清理后容量仍然紧张,就进行扩容,容量翻倍。
这里和HashMap一个很大的不同是:HashMap用链表或红黑树解决冲突,ThreadLocalMap只用线性探测。线性探测对哈希质量要求更高,这也是为什么它需要0x61c88647这种经过精心设计的哈希增量。
4. 内存泄漏真相:弱引用只挡了一半,另一半靠remove
4.1 内存泄漏的完整链条
虽然Entry的key是弱引用,但value是强引用。内存泄漏的完整链路是这样的:
- 线程池中有一个长期存活的线程
T。 - 线程
T调用threadLocal.set(value),内部在T.threadLocals里新增了一个Entry,key指向ThreadLocal实例,value指向实际数据。 - 业务代码执行完毕,把
ThreadLocal对象的外部强引用置为null,或者承载ThreadLocal的对象被回收。 - 此时
ThreadLocal实例只剩Entry.key这条弱引用,GC在下一次垃圾回收时把ThreadLocal实例回收。 Entry.key变为null,但Entry.value仍然被Entry强引用,而Entry又存活在ThreadLocalMap里。- 线程
T一直存活,ThreadLocalMap一直存活,那个key=null的Entry和它的value就永远无法被回收。
从业务角度说,最直观的表现就是:你在ThreadLocal里放了一个超大对象,线程池里的核心线程一直不退,这个对象就一直被钩住,堆越来越大,最后OOM。
4.2 ThreadLocalMap自带的“过期数据清理机制”
为了缓解上面这个问题,ThreadLocalMap内部做了一些“顺手清理”的设计。比如:
get()时,如果探测过程中遇到key == null的Entry,调用expungeStaleEntry清理;set()时,如果遇到过期Entry,调用replaceStaleEntry替换并清理;cleanSomeSlots()会从某个位置扫描一定范围内的Entry,清理过期数据;rehash()时会全面扫描清理一次。
但这套机制有一个致命缺点:清理都是“被动触发”的,且只发生在set/get/remove被调用时,而且不保证能清理到所有过期Entry。如果你在线程里set完之后再也不碰这个ThreadLocal,也没有再次调用get/set/remove,那这些过期Entry就会一直留在Map里。
所以官方文档和所有成熟框架都统一建议:用完必须remove()。remove()不仅会删除当前ThreadLocal对应的Entry,还会expungeStaleEntry清理当前位置附近的过期条目,是这几种方法中“最彻底”的一次清理。
4.3 线程池是内存泄漏放大器
在普通业务线程里,线程执行完毕就销毁,ThreadLocalMap随之回收,泄漏问题不严重。但线程池里的核心线程是常驻的,生命周期和整个JVM一样长,这就把内存泄漏的窗口拉到了无限长。
线程池里最容易踩的坑组合是:
- 在任务里向
ThreadLocal塞数据; - 任务结束时没有
remove; - 线程池复用线程处理下一个任务;
- 下一个任务里
get()到了上一个任务残留的数据。
这个问题表面上表现为数据错乱,但如果你塞的是一个引用链庞大的对象,比如一个包含请求体、用户信息、数据库连接的上下文对象,那内存压力会持续累积。
4.4 线上问题排查:怎么确认泄漏源就是ThreadLocal
有次排查线上内存占用异常,jmap -dump出来的堆转储里发现了一个业务对象UserContext的实例数高达几十万。
排查思路是:
- 用
jmap -histo:live <pid>看存活对象Top列表,确认UserContext实例数量异常; - 用MAT打开堆转储文件,
Dominator Tree里看UserContext是被谁引用的; - 顺着引用链一路往下,最终会走到
java.lang.Thread→ThreadLocal.ThreadLocalMap→Entry[]→Entry.value; - 再根据线程栈确认是哪个线程池的线程,回去翻代码,果然发现某个拦截器
set了上下文,但finally里漏了remove。
这里有一个判断技巧:如果堆里的业务对象实例数是线程池核心线程数的数倍甚至几十倍,那大概率是线程复用过程中反复set新对象、又没有及时清理导致的。正常一个线程在当前业务时刻最多持有一份当前请求的数据,不会积累这么多历史对象。
| 排查工具 | 用法 | 结论 |
|---|---|---|
jmap -histo:live | 看实例数和占用内存 | 发现数量异常的业务对象 |
| MAT / JProfiler | 分析引用链 | 定位到ThreadLocalMap的Entry |
jstack | 定位线程状态 | 确认是哪个线程池的线程 |
| 代码审查 | 搜索get/set/remove | 找到清理遗漏点 |
5. ThreadLocal在大型框架里的应用,就是最佳教科书
5.1 Spring的RequestContextHolder与事务同步器
Spring里有一个非常典型的ThreadLocal应用:RequestContextHolder。它的核心内部就是一个静态的ThreadLocal容器,存放当前线程绑定的ServletRequestAttributes,让业务代码在任意层级都能通过RequestContextHolder.getRequestAttributes()拿到当前请求的HttpServletRequest和HttpServletResponse。
这背后依赖的是DispatcherServlet在处理请求前把请求属性绑定到当前线程,请求结束后再解绑。
另一个更底层的应用是TransactionSynchronizationManager,Spring声明式事务就是靠它在当前线程里绑定数据源连接和事务状态。一个业务方法经过多次Dao调用时,每次DataSourceUtils.getConnection()都会先去ThreadLocal里找当前线程是否已经绑定过连接,如果没有,就新建一个放进去,这样整个事务内部复用的是同一个数据库连接。
如果没有ThreadLocal,事务边界就非常难做——你不可能在每个Dao方法里都传一个“当前事务的连接”作为参数。
5.2 链路追踪:traceId如何跨线程传递
分布式链路追踪(比如SkyWalking、Micrometer Tracing、Logback的MDC)也是ThreadLocal的重度用户。
以日志为例,Logback的MDC.put("traceId", "xxx")之后,所有同线程内的日志输出都会自动带上这个traceId。其实现本质是内部维护了一个ThreadLocal形式的Map。你在一次请求的入口处生成一个traceId,放进MDC,之后这个线程处理链路里的所有日志都能关联起来。
要在HTTP调用链上把traceId传递到下游服务,一般是通过RestTemplate或Feign的拦截器,在构造请求头时从MDC取出traceId,设置到HttpHeaders里,下游服务再从请求头里解析并设置到自己的MDC。
5.3 一个用户上下文工具类的生产级写法
我项目里的用户上下文一般长这样:
public class UserContext { private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>(); public static void set(UserInfo user) { HOLDER.set(user); } public static UserInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }配合拦截器:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { UserInfo user = parseToken(request); UserContext.set(user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里有几个重要细节:
set必须放在preHandle里,因为preHandle先于Controller执行;clear必须放在afterCompletion里,而不是postHandle,因为postHandle之后View渲染阶段可能还要用;afterCompletion是finally层面的回调,即使业务抛异常也会执行。
5.4 日期格式化与JDK内部的ThreadLocal用法
日期格式化工具类是ThreadLocal最常见的入门案例:
public class DateUtils { private static final ThreadLocal<SimpleDateFormat> FORMATTER = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public static String format(Date date) { return FORMATTER.get().format(date); } }值得一提的是,JDK内部其实也大量使用了ThreadLocal。比较典型的包括Random类里的ThreadLocalRandom,它就是为每个线程维护独立的随机数种子,避免多线程争用同一个AtomicLong种子。还有java.text.DateFormat的sharedInstance相关逻辑也用到过ThreadLocal来做线程局部缓存。
这些案例说明一个问题:ThreadLocal不是玩具,它已经渗透到了JDK和主流框架的核心部位。理解它,就是理解这些框架内部运作机制的基础。
6. 线程池场景的传递困局:InheritableThreadLocal为什么不够用
6.1 线程池复用引发的“串号事故”
前面已经说过线程池里不remove会导致数据串号。但就算你每次都remove了,还有一个问题:如何把父线程的上下文传递给子线程?
比如一个请求进来,入口处设置了traceId,然后你通过ExecutorService提交了一个异步任务。异步任务在别的线程里执行,怎么拿到这个traceId?
初看有个现成方案:InheritableThreadLocal。它是ThreadLocal的子类,在创建新线程时,会把父线程的InheritableThreadLocal值拷贝给子线程。
private static final InheritableThreadLocal<String> TL = new InheritableThreadLocal<>(); public static void main(String[] args) { TL.set("parent-value"); new Thread(() -> System.out.println(TL.get())).start(); // 输出 parent-value }但问题来了:这个拷贝只发生在new Thread()的时候。如果你用的是线程池,核心线程在池初始化时就已经创建好了,后续提交任务给这些线程,并不会重新拷贝父线程的值。
6.2 InheritableThreadLocal的生效边界
用代码验证一下:
private static final InheritableThreadLocal<String> TL = new InheritableThreadLocal<>(); private static final ExecutorService POOL = Executors.newFixedThreadPool(1); public static void main(String[] args) { TL.set("task-A"); POOL.submit(() -> System.out.println("A: " + TL.get())); TL.set("task-B"); POOL.submit(() -> System.out.println("B: " + TL.get())); POOL.shutdown(); }执行结果很可能是两条日志都是task-A(或者都是null,取决于核心线程启动时机)。因为线程池里的线程在第一次提交任务时才被创建,此时TL的值是task-A,拷贝进去之后,后续的task-B再也不会覆盖它。
所以InheritableThreadLocal的适用范围非常窄:只适用于“创建新线程”且“创建时一次性拷贝”的场景,对线程池复用几乎无效。
6.3 生产常用的传递方案:装饰器模式与TTL思路
要解决线程池场景的上下文传递,生产环境一般走两条路。
一条是装饰器模式。提交任务时,先把当前线程的上下文快照出来,再包一层Runnable,在run()里先恢复上下文,执行完毕后再清掉:
public class ContextPropagator { public static Runnable wrap(Runnable task) { Map<String, String> snapshot = capture(); // 从ThreadLocal取出快照 return () -> { Map<String, String> backup = capture(); set(snapshot); try { task.run(); } finally { restore(backup); } }; } }然后统一走POOL.submit(ContextPropagator.wrap(task))。思路不复杂,但要保证所有提交入口都做包装,漏一个就会出问题。
另一条是直接用现成类库,比如阿里开源的TransmittableThreadLocal(TTL)。它通过增强ExecutorService的提交逻辑,自动完成上下文的捕获、回填和恢复,对业务代码侵入极小,是生产级链路追踪场景的常用方案。
这些内容涉及源码级改造,细节非常多,我会在系列第二篇里单独展开。这一篇先把ThreadLocal本身的核心机制和应用姿势讲清楚,后面再深入异步传递和框架封装。
6.4 系列首个边界的约定
既然标题写了“(一)”,先把边界划清楚。这篇覆盖的是:
ThreadLocal能解决什么问题,不能解决什么问题;- 核心API的正确使用姿势;
ThreadLocalMap的底层结构:弱引用、黄金分割哈希、线性探测;- 内存泄漏的成因与排查思路;
- 主流框架里的典型用法;
- 线程池场景传递问题的引出。
后续篇章会重点讲TransmittableThreadLocal的源码实现、自定义上下文传递组件的封装、跨进程traceId透传,以及怎么给现有框架打补丁式地接入上下文自动清理。这些都是从原理到工程落地的深水区,一篇塞不完。
如果你看完这篇,能形成一个判断:ThreadLocal不是“多线程安全的万能仙丹”,而是一个“线程私有状态的存放点”,用得好是神器,用不好是内存泄漏的温床——那这个第一更的目的就达到了。
我自己的习惯是,在项目里会定一条硬性规定:所有往ThreadLocal里set数据的地方,必须在同一个逻辑边界内remove。Web请求就在拦截器/过滤器里统一清理,异步任务就在包装Runnable的finally里清理。没有这条红线,线上的内存问题迟早会找上门。