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复制private static final int HASH_INCREMENT = 0x61c88647;
private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
0x61c88647是黄金分割数衍生的魔数。ThreadLocal每new一个对象,都会在递增的序列上追加这个增量,生成一个新的哈希码。这个数字配合table.length & (len - 1)的取模方式,可以让哈希码在长度为2的幂的数组上分布得极其均匀,尽量少冲突。这也是为什么ThreadLocalMap初始容量是16且总是2的幂次方。
有经验的面试官经常问:"ThreadLocal的哈希冲突怎么解决?"答案不是HashMap的链表法,而是"线性探测法"(开放寻址的一种)。这个知识点很多人栽过,你如果没仔细看过源码,很容易在这一点上被绕进去。
3. 为什么要用ThreadLocal而不是一把锁
上一章讲了ThreadLocal的底层存储方式,这一章从使用者的视角,说说"什么时候该选ThreadLocal,什么时候不该选"。
3.1 选择ThreadLocal的三个典型理由
第一是不共享、无锁。公共变量之所以要加锁,是因为多个线程同时读写同一份数据。ThreadLocal让每个线程写自己的那一份,本质上消除了竞争,也就不需要锁,并发吞吐量不会被串行化拖垮。
第二是状态与线程绑定。有些场景状态本身就应该跟随线程走,比如事务状态、请求上下文、数据库连接。用参数传递会很啰嗦,用全局变量又会在并发下串数据,ThreadLocal刚好契合"状态跟线程走"的模型。
第三是代码侵入性低。在一个已有的大方法里,如果你想往深处传一个对象,可能要把方法签名改一长串,所有调用方都得受影响。用ThreadLocal,业务代码根本不需要感知参数链路,取的时候一个get就行。
3.2 不适合用ThreadLocal的场景
ThreadLocal不是万能药,以下场景我劝你慎用:
- 核心业务数据需要可靠的跨线程访问:如果任务分发给线程池、子线程,那ThreadLocal默认情况下传不过去,别指望ThreadLocal替你做跨线程参数传递。这一点我在第5章会细说。
- 值特别大,且每个线程都存一份:比如缓存一个10MB的对象到ThreadLocal,一个线程池有200个线程,那就是2GB的常驻内存,很危险。
- 使用方无法保证清理:谁set了,谁就负责remove。如果代码路径复杂、异常多、难以保证finally清理,那宁可不要用ThreadLocal,省得线上故障。
3.3 与ThreadLocal容易混淆的几种方案
这里列一个简单的对比,避免选型拍脑袋:
| 方案 | 隔离粒度 | 开销 | 典型使用场景 |
|---|---|---|---|
| ThreadLocal | 线程级,各存一份 | 每个线程一份内存 | 请求上下文、线程内状态 |
| synchronized | 进程级,多线程共享 | 竞争时阻塞、上下文切换 | 多个线程修改同一份共享状态 |
| 局部变量 | 方法栈帧 | 几乎为零 | 本来就不需要共享的数据 |
| 并发工具类(如ConcurrentHashMap) | 共享,但分了层 | 较小 | 用公共容器保存多份数据 |
记住一个判断口诀:数据是"属于当前运算过程的临时状态",用ThreadLocal;数据是"多个线程共同操作的持久资源",用锁或并发容器。很多人用反了,才会造出"ThreadLocal持有全站配置"这种反模式。
4. 源码实战:set、get、remove的执行全链路
看懂了底层结构,我们再顺着源码走一遍三个常用方法的完整路径。这不仅是面试加分项,更是你在排查问题时分析行为的关键。
4.1 set:从入口到Entry落位
以JDK 8的实现为例:
java复制public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
第一步拿到当前线程;第二步取出线程上的threadLocals;没有则先创建。如果map.set这一步展开,逻辑如下:
- 用
threadLocalHashCode & (len - 1)定位槽位i; - 从
i开始线性遍历:如果key相等,直接覆盖value;如果key == null,说明原来的ThreadLocal被GC掉了,此时用当前的新value替换这个过期Entry,并对附近做一次"启发式清理";如果直到遇到null槽位都没找到,把Entry放到这个null位置上; - 插入后判断
size >= threshold,触发rehash,必要时扩容。
这里有一个值得注意的优化:当发现过期Entry(key为null)时,代码会调用expungeStaleEntry(i)进行清理,还会顺势往后探测,把后面一连串的过期Entry都清掉。这是ThreadLocalMap自我保护的一种机制,也是为什么key被GC后value不一定立刻导致内存泄漏——只要后续有新的set或get访问到这条探测路径,就可能被清理。
但关键是"访问到才清理"。如果某个线程存完ThreadLocal后线程一直空闲着、再也没碰过这个Map,那些key为null的Entry就只能靠外部remove或其他操作触发清理,否则就一直挂着。
4.2 get:初始值从哪来
java复制public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
return (T) e.value;
}
}
return setInitialValue();
}
当map存在且找到Entry,直接返回value;如果找不到,说明这个线程还没往这个ThreadLocal里存过值,此时调用setInitialValue()。setInitialValue()会调用initialValue()——这个方法默认返回null,但如果子类重写,就可以为每个线程提供一个自定义初始值。
所以想给线程局部变量设置默认值,正确做法是重写initialValue():
java复制private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
Java 8以后也可以用ThreadLocal.withInitial:
java复制private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
两种写法效果一样,用静态字段保存同一个ThreadLocal实例,但value在每个线程里都是独立的。
4.3 remove:为什么源码里它长得最"简单"
java复制public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
ThreadLocalMap.remove(this)会定位到槽位并删除Entry,同时清理该位置前后的过期Entry。这个操作是防止内存泄漏的救命稻草。我见过很多人学完ThreadLocal,照样不写remove,问起来就是"最后一次用完不就没了吗",完全忽略了线程池线程复用的场景。
4.4 关于版本差异想说一句
我上面讲的是JDK 8的实现。到了JDK 11/17/21,ThreadLocalMap核心逻辑基本不变,但细节上做了一些微调,比如清理逻辑更精细、部分方法名有变化,你可以按自己JDK版本看源码。面试或排查问题时,把握"Thread持有ThreadLocalMap、Entry数组+开放寻址、key弱引用"这样一个主干,不同版本都能推导。
5. 四个真实场景:从请求上下文到事务状态传递
原理再好,落不了地也没用。接下来我挑四个高频业务场景,逐个说明ThreadLocal在里面扮演什么角色、怎么落地。
5.1 场景一:Web请求的链路追踪ID
这是一个分布式系统常见的需求:要给一次请求绑定一个traceId,日志里统一打印,方便把一次请求经过的多个服务串起来。
先定义一个持有类:
java复制public class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void setTraceId(String traceId) {
TRACE_ID.set(traceId);
}
public static String getTraceId() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}
在网关或过滤器里,请求进来时生成traceId并set进去,请求结束时remove,配合日志框架的Pattern变量,就能让每行日志自动带上当前线程的traceId。
java复制@Component
public class TraceFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
String traceId = UUID.randomUUID().toString().replace("-", "");
TraceContext.setTraceId(traceId);
try {
chain.doFilter(request, response);
} finally {
TraceContext.clear();
}
}
}
这里最关键的一点是finally中clear。Web容器线程复用时,如果不清除,下一次请求复用的线程会带着上一个请求的traceId,日志直接串味。
5.2 场景二:Spring事务的线程绑定
Spring的TransactionSynchronizationManager是ThreadLocal的重度使用者。它维护了这样几个ThreadLocal:
resources:存放当前线程绑定的资源(如数据库连接)与事务同步器;synchronizations:事务同步回调列表;currentTransactionName、actualTransactionActive等:记录当前事务状态。
每次开启事务时,Spring拿到一个数据库连接后,会把这个连接放进resources这个ThreadLocal里。后续同一线程内的多个DAO操作,都从ThreadLocal取同一个连接,从而保证在同一个数据库事务中执行。事务结束、连接归还后,Spring会清理这条ThreadLocal数据。
这个设计妙在:同一线程内,事务上下文天然可见;不同线程事务互相隔离。你不需要在多个Service方法之间反复传Connection,Spring帮你藏在了线程局部变量里。
如果你在项目里开发过自定义的"MyBatis多数据源切换"逻辑,多半也用过类似的ThreadLocal保存当前要切到的数据源key:
java复制public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setDataSource(String key) {
CONTEXT.set(key);
}
public static String getDataSource() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
AOP切面在方法执行前set,在finally中clear,就能实现数据源动态切换。
5.3 场景三:防止连接泄漏的数据库连接管理器
某些老项目里的数据库连接器,用ThreadLocal保存当前线程正在使用的Connection:
java复制public class ConnectionHolder {
private static final ThreadLocal<Connection> CONN = new ThreadLocal<>();
public static Connection getConnection() throws SQLException {
Connection conn = CONN.get();
if (conn == null || conn.isClosed()) {
conn = dataSource.getConnection();
CONN.set(conn);
}
return conn;
}
public static void closeQuietly() {
Connection conn = CONN.get();
if (conn != null) {
try { conn.close(); } catch (SQLException ignored) {}
CONN.remove();
}
}
}
这样在同一个线程中,多个方法可以安全复用同一个连接,而不需要到处传参;线程结束时统一关闭连接并清理ThreadLocal。注意,如果是结合数据库连接池使用,连接本身是池化复用的,ThreadLocal只是保存了"当前线程当前拿到的连接引用",跨线程复用时一定要清理,否则会拿着上一个线程的连接搞出大乱子。
5.4 场景四:用户信息的透传
常见于登录态处理。用户登录后,拦截器把用户对象放到ThreadLocal,业务代码随处可取:
java复制public class LoginUserHolder {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
public static void set(LoginUser user) {
HOLDER.set(user);
}
public static LoginUser get() {
LoginUser user = HOLDER.get();
if (user == null) {
throw new BusinessException("未获取到登录用户");
}
return user;
}
public static void remove() {
HOLDER.remove();
}
}
解析Token、查询用户信息、校验权限这些工作全部放在拦截器,业务方法不需要在参数列表里带上LoginUser,代码干净很多。
6. ThreadLocal的三个大坑:内存泄漏、线程池复用与异步场景
上面讲的都是"正常用法"。这一章专门聊坑,每一个都是生产环境真实炸过的雷。
6.1 坑一:内存泄漏,弱引用为什么没能兜底
前面已经铺垫过:Entry的key是弱引用,value是强引用。ThreadLocal外部引用没了之后,key被回收,value却因为Entry还挂在ThreadLocalMap里而无法释放。
什么情况下会出问题?
- 线程存活时间很长(线程池中的核心线程,生命周期可能伴随整个JVM);
- 每次任务都new一个ThreadLocal对象,用完就丢,不remove;
- 这个"用完就丢"的ThreadLocal的value比较大或比较多。
线程池核心线程数20,任务每来一次就往一个局部ThreadLocal存一个几MB的对象,循环1000次,那就是20GB的潜在大战。虽然JDK的set、get会顺手清理部分过期Entry,但没人敢指望这种"顺手清理"兜底。
正确的红线:set过,必须在finally里remove。
java复制try {
ThreadLocal<byte[]> holder = new ThreadLocal<>();
holder.set(new byte[1024 * 1024]);
// 业务逻辑
} finally {
holder.remove();
}
6.2 坑二:线程池复用导致的数据串味
线程池里的线程是循环使用的。任务A通过ThreadLocal存了数据,任务B复用了这条线程,如果A结束时没有清除,B一进方法get(),拿到的是A的数据。
最典型的场景就是前文那个TraceFilter,如果忘记在finally里clear(),那么同一个线程处理的多个请求会共享同一个traceId,日志全混在一起,链路追踪等于报废。
更隐蔽的变体是"局部ThreadLocal"配线程池:
java复制public void process(List<Task> tasks) {
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("hello");
executor.execute(() -> {
// 子线程里 get 不到父线程的 tl
String value = tl.get(); // null
});
}
主线程里set的值,子线程默认是拿不到的,因为Task的threadLocals是空的。这里牵扯到另一类问题,细看下一节。
6.3 坑三:子线程拿不到值,InheritableThreadLocal有局限
ThreadLocal的数据跟当前线程绑定,子线程启动时,不会自动看到父线程的ThreadLocal值。也就是说,你在线程里set了用户信息,然后丢给new Thread或线程池去异步处理,子线程里get()返回null。
JDK提供了一个InheritableThreadLocal:
java复制private static final InheritableThreadLocal<String> USER = new InheritableThreadLocal<>();
它的原理是:在创建子线程时,把父线程的inheritableThreadLocals复制一份给子线程。所以子线程创建的那一刻,能看到父线程已set的值。
但它有个致命的局限:只在子线程创建时复制一次。如果父线程在子线程创建之后又改了值,子线程里的值不会跟着变。而且线程池更麻烦——线程池里的工作线程早已创建,每次复用并不会重新复制父线程的最新值,可能导致陈旧数据一直复用。
跨线程传递的方案业界已经有成熟答案,比如阿里开源的TransmittableThreadLocal(TTL),它可以在线程池提交任务时,把父线程上下文快照传递到执行线程,并在执行完成后恢复原上下文。如果你的项目在写全链路追踪、异步任务透传用户信息,直接上TTL省心得多。
6.4 一个容易忽略的坑:重写initialValue时的线程安全问题
有人自定义ThreadLocal:
java复制private static final ThreadLocal<MyService> SERVICE =
ThreadLocal.withInitial(MyService::new);
这个初始化每个线程执行一次,一般没问题。但如果你在initialValue()里操作了共享的静态资源,比如读取一个正在被其他线程修改的全局配置,那这个初始化方法本身可能有线程安全问题。记住initialValue()是在get()那条线程的上下文中执行的,不是提前准备好的单例,别在初始化逻辑里做假设。
6.5 千万不要用ThreadLocal传"共享"数据
ThreadLocal适合"每个线程独立存一份"的状态,有人把它当成"免参数传递"神器,在线程A里set一个可变对象,然后通过某个共享容器把对象引用传给线程B去读。这种用法已经彻底背离了线程隔离的含义,存进去的依然是同一个对象引用,多线程改起来照样数据竞争。如果你需要跨线程共享并修改,那就好好用锁或并发容器。
7. 生产环境使用规范与问题排查手段
最后这部分,是我在项目里沉淀下来的"使用清单"和"排查套路"。
7.1 使用Servlet规范还是用过滤器的清理时机
如果你用Spring Boot,可以用HandlerInterceptor:
preHandle里setafterCompletion里remove
这是比较干净的清理时机,因为afterCompletion一定会执行,比postHandle更稳。如果是普通Servlet Filter,就写在finally里。
7.2 统一封装,别在业务代码里裸用ThreadLocal
我强烈建议不要在业务代码直接new ThreadLocal塞值,而是要封装成Context类,对外只暴露业务语义方法。原因有三:
- 统一管理初始值、清理逻辑;
- 业务层不用关心底层的set/remove;
- 以后如果要替换成TTL,改动只在Context类内部。
推荐模板:
java复制public class RequestContext {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
public static void put(String key, Object value) {
if (HOLDER.get() == null) {
HOLDER.set(new HashMap<>());
}
HOLDER.get().put(key, value);
}
public static Object get(String key) {
return HOLDER.get() == null ? null : HOLDER.get().get(key);
}
public static void clear() {
HOLDER.remove();
}
}
用Map而非单一字段的好处是扩展性强,一个Context能存多个维度的上下文数据。当然要注意,Map里如果放了对象引用,clear时Map本身被remove,后续GC会处理,不需要手动遍历置null。
7.3 排查ThreadLocal相关故障的三个手段
第一,看线程栈能否复现。如果怀疑线程池串数据,用jstack把线程栈dump下来,观察线程名、当前执行栈,对比是否来自同一池内线程。
第二,堆内存分析找可疑Entry。如果怀疑内存泄漏,用jmap -histo看有没有大量ThreadLocalMap$Entry实例,或者用MAT、VisualVM对heap dump做分析,搜"ThreadLocalMap"相关对象,就能看到哪些线程的Map里挂着大量过期Entry。
第三,在关键set/remove处加日志。如果是在业务中间件里排查,可以先临时在这个Context类的set/get/remove里加上线程名和调用栈日志,对比谁set了没remove。虽然打日志有性能开销,但生产事故面前,短暂开启打点是高效的手段。
7.4 能不用就别用,用了就要尽责
这句不是劝退,而是务实的态度。ThreadLocal是很顺手的工具,但它把"线程状态"隐藏了起来,让代码变得不那么显式——你调用一个方法时,根本看不出它内部读了一个ThreadLocal。这种隐式传参,带来方便,也带来理解和排查成本。
我的实际使用原则是:
- 跨层传递的只读上下文(traceId、用户ID、租户ID、语言环境)优先用ThreadLocal;
- 可变业务数据、大对象尽量不用;
- 能显式传参的场景,优先显式传参;
- 凡使用必须成对出现set和remove,且remove必须放在finally。
8. 踩过几次坑之后的一点个人体会
写到这里,ThreadLocal的底层结构、典型场景和避坑要点基本都说完了。总结起来就是一句话:ThreadLocal解决的是"每个线程一份状态"的问题,不是"多个线程共享状态"的问题。把这个方向摆正,大部分误用都不会发生。
我刚开始用ThreadLocal的时候,也犯过"set了不remove"的错,那时线上任务线程池复用一个业务TraceContext,导致一串请求的订单号全部串在一起,查了整整一个下午。后来在代码里立了一条规矩:所有使用ThreadLocal的入口,必须有对应的finally清理,否则不允许提交。这条规矩帮团队挡掉了后续很多问题。
再分享一个排查小技巧:如果你在代码评审里看到new ThreadLocal的写法,第一反应不是"这代码对不对",而是问三个问题:数据是归属于线程还是共享的?谁负责清理?如果这个线程被复用,会有什么后果?三问下来,基本能筛掉绝大多数隐患。
ThreadLocal这个类很小,源码也不复杂,但想用好它,需要你对线程模型、引用类型、容器设计、框架用法都有一定理解。希望这篇文章能把你的知识补得再全一点,以后再遇到线程上下文传递、请求追踪、事务绑定这类需求时,心里不慌。
