1. 从一段真实Bug讲起:并发下SimpleDateFormat为什么改不动
我接手过一个老项目,线上有个数据导出功能,时不时会把日期格式化成乱码,甚至直接抛NumberFormatException。当时第一反应是数据库时间字段有脏数据,查了半天数据没问题,后来把日志翻了又翻,发现一个很有意思的规律——报错的请求总是集中在同一时间段,而且线程名几乎都来自同一个业务线程池。
代码缩到最后,问题出在一个所有人都觉得"这么写没问题"的写法上:
java复制public class DateUtils {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String format(Date date) {
return SDF.format(date);
}
}
SimpleDateFormat被定义成了static final,所有请求线程共用同一个实例。你可以先记住这个结论:SimpleDateFormat内部维护着一个Calendar对象,format()要先setTime再格式化,多线程并发操作同一个实例时,A线程刚写入的时间可能被B线程覆盖。于是A拿到的就是B的日期,或者状态早就乱套了,直接抛异常。
这时候有人会想:加个 synchronized不就完了?能加,但代价是并发量直接退化成串行,导出高峰期本来每秒几十个请求,加锁后全堵在格式化这一步,性能没法看。还有人会说:每次new SimpleDateFormat不就行了?行,但高并发下频繁创建对象的开销、GC压力,都不是最优解。
这个问题的标准答案,就是ThreadLocal<SimpleDateFormat>:每个线程持有自己独立的SimpleDateFormat实例,线程之间各用各的,既不共享也不加锁,既安全又有性能。
很多讲ThreadLocal的文章从这里开场,我也一样。但今天这篇文章不想只讲"怎么用",我想把后面这四件事讲透:
- ThreadLocal到底是什么、它是怎么做到"线程隔离"的;
- 源码层面set、get、remove背后做了哪些设计取舍;
- 请求链路、事务绑定、连接复用这些真实场景里到底怎么用它;
- 以及那些容易翻车的细节:内存泄漏、线程池复用、子线程拿不到值。
如果你已经会用ThreadLocal,这篇文章能帮你把底层原理和各类坑串起来;如果你刚接触,建议从头到尾读一遍,信息量足够。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ThreadLocal的底层设计:数据并不存在ThreadLocal对象里
很多人第一次看ThreadLocal的set方法会愣一下:为什么我调threadLocal.set(value),传入的参数是value,但源码里完全看不到往"ThreadLocal自己"里面存东西?
原因很简单:ThreadLocal本身只是一个门牌号,真正保存数据的容器,在哪个线程里,数据就存在哪个线程身上。
2.1 "当前线程"是整个机制的核心
Java的Thread类内部有两个字段:
java复制public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}
默认都是null,只有当线程第一次调用ThreadLocal.set()或get()时,才会在当前线程对象上初始化一个ThreadLocalMap。换句话说,每个线程自己带了一个专属的小仓库,你往ThreadLocal里放东西,实际上是把东西放进了"当前线程的仓库"里。
这样做的好处非常直观:数据归属线程,天然隔离,不需要加锁。线程A往自己仓库放一份,线程B往自己仓库放一份,两者存储位置不同,怎么并发都不会互相干扰,这就是"线程隔离"四个字的本质。
2.2 ThreadLocalMap和HashMap到底是不是一回事
很多资料会把ThreadLocalMap类比成HashMap,这容易把人带偏。它俩虽然都是"键值对容器",但设计差异很大:
| 对比项 | HashMap | ThreadLocalMap |
|---|---|---|
| 数据结构 | 数组 + 链表/红黑树 | 仅数组,冲突用开放寻址法 |
| key的引用类型 | 强引用 | 弱引用(Entry继承WeakReference) |
| 扩容策略 | 节点数 > 容量×负载因子 | 数组大小超过阈值时rehash |
| 主要用途 | 通用键值存储 | 存储线程局部变量 |
所以不要把ThreadLocalMap当成一个"有特殊key的HashMap",它是一套完全独立、为线程局部存储场景专门设计的紧凑型Map。
2.3 key是弱引用,value是强引用
ThreadLocalMap内部使用Entry[]数组存储数据,每个Entry继承了WeakReference<ThreadLocal<?>>:
java复制static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
这里有个非常关键的细节:key(也就是ThreadLocal对象本身)是弱引用,value(线程变量值)是强引用。
为什么要这样设计?假如key是强引用,那么只要线程还活着、线程的ThreadLocalMap还被引用着,ThreadLocal对象就永远无法回收。业务代码里ThreadLocal经常被定义成静态变量,生命周期跟类一样长,这没问题;但如果我们在线程内临时new一个ThreadLocal,用完就丢,强引用会导致它一直挂在ThreadLocalMap里,占着内存不释放。用弱引用后,只要外部不再强引用这个ThreadLocal对象,GC就能把它回收掉,key变成null。
但弱引用也带来一个经典问题:key被回收了,value还在啊。Entry里key == null,value却依然被Entry强引用着,如果线程不结束、这段Entry不被清理,value就永远躺在内存里。这正是ThreadLocal内存泄漏的根源,后面第4章我会专门展开。
2.4 哈希冲突的解决思路
ThreadLocalMap没有链表结构,它采用开放寻址法:存入时,如果计算出来的槽位被占了,就往后找下一个空位;读取时,从计算出的槽位开始逐个往后探,直到找到目标key或遇到null槽位。
计算索引的方式也很有讲究:
java复制pri
