之前写的可能比较多、粒度比较粗,知识反复的过程,下面咱们结合真实场景再来一篇;写正文之前先来点基础知识:
TraceContext.set("trace-123");
System.out.println(TraceContext.get()); // 为什么能拿到?
这两行代码在同一个线程里执行。要理解它,必须看清 ThreadLocal 的底层存储结构。
ThreadLocal 的底层结构
每个线程(Thread)内部都有一个 Map,叫做 ThreadLocalMap:
Thread(线程对象)
└── threadLocals(ThreadLocalMap,本质是一个数组)
├── Entry[0]: key=TRACE_ID, value="trace-123"
├── Entry[1]: key=USER_ID, value="user-A"
└── Entry[2]: null(空位)
- 这个 Map 是线程私有的,每个线程都有自己独立的一份
- Map 的 key 是 ThreadLocal 实例本身(比如
TRACE_ID这个 static 变量) - Map 的 value 是你 set 进去的值
set() 到底做了什么?
public class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String traceId) {
TRACE_ID.set(traceId);
}
}
执行 TraceContext.set("trace-123") 时,底层发生的事:
// ThreadLocal.set() 的简化逻辑
public void set(T value) {
// 1. 获取当前线程
Thread currentThread = Thread.currentThread();
// 2. 获取当前线程的 threadLocals Map
ThreadLocalMap map = getMap(currentThread); // 就是 currentThread.threadLocals
// 3. 如果 Map 已存在,直接 put;不存在就创建
if (map != null) {
map.set(this, value); // this 就是 TRACE_ID 这个实例
} else {
createMap(currentThread, value);
}
}
当前线程(main线程)
└── threadLocals(ThreadLocalMap)
└── Entry: key=TRACE_ID, value="trace-123"
get() 到底做了什么?
紧接着执行 TraceContext.get():
// ThreadLocal.get() 的简化逻辑
public T get() {
// 1. 获取当前线程
Thread currentThread = Thread.currentThread();
// 2. 获取当前线程的 threadLocals Map
ThreadLocalMap map = getMap(currentThread);
// 3. 如果 Map 存在,用 TRACE_ID 作为 key 去查找
if (map != null) {
Entry e = map.getEntry(TRACE_ID); // 用 TRACE_ID 找
if (e != null) {
return (T)e.value; // 找到,返回 "trace-123"
}
}
return null;
}
- 拿到当前线程的 Map
- 用
TRACE_ID这个 key 去 Map 里找 - 找到了,返回对应的 value
"trace-123"
为什么同一个线程能拿到?
因为 set() 和 get() 操作的是同一个线程的同一个 Map:
时间线:
T1: set("trace-123") → 往 main线程的 Map 里存 {TRACE_ID: "trace-123"}
T2: get() → 从 main线程的 Map 里取 TRACE_ID 对应的值 → "trace-123"
就像你在自己的笔记本上写了一行字,然后自己翻回去看,当然能看到。
为什么异步线程拿不到?
TraceContext.set("trace-123");
executorService.submit(() -> {
System.out.println(TraceContext.get()); // null ❌
});
因为异步线程是另一个线程,它有自己独立的 Map:
main线程的 Map: {TRACE_ID: "trace-123"}
异步线程的 Map: {}(空的,从来没有 set 过)
get() 去异步线程自己的 Map 里找 TRACE_ID,找不到,返回 null。
就像你在自己的笔记本上写了字,让你同事去他自己的笔记本上找,当然找不到。
┌─────────────────────────────────────────────────────────────┐
│ ThreadLocal 本质:每个线程有一个独立的 Map │
│ │
│ main线程 异步线程(线程池里的 worker-1) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ threadLocals │ │ threadLocals │ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │TRACE_ID:"123"│ │ │ │ │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │
│ └─────────────────┘ └─────────────────┘ │
│ ↑ set/get 操作这里 ↑ 空的,所以 get 返回 null │
└─────────────────────────────────────────────────────────────------┘
场景:微服务链路追踪(TraceID 传递)
说一下咱们项目中最经典的生产场景。用户请求进来,生成一个 traceId,后续所有日志、数据库操作、远程调用都要带上这个 traceId,方便排查问题。
出问题的代码(用 InheritableThreadLocal)
// 上下文持有类
public class TraceContext {
private static final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>();
public static void set(String traceId) { TRACE_ID.set(traceId); }
public static String get() { return TRACE_ID.get(); }
public static void clear() { TRACE_ID.remove(); }
}
// 拦截器:请求进来时生成 traceId
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = UUID.randomUUID().toString();
TraceContext.set(traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TraceContext.clear(); // 请求结束时清理
}
}
// 业务代码:异步处理订单
@Service
public class OrderService {
@Autowired
private ExecutorService executorService; // 线程池
public void createOrder(Order order) {
// 主线程能拿到 traceId
System.out.println("主线程 traceId: " + TraceContext.get());
// 异步保存订单 拿不到 traceId!
executorService.submit(() -> {
System.out.println("异步线程 traceId: " + TraceContext.get()); // 输出 null
saveToDatabase(order);
});
}
}
问题现象 :异步线程打印的 traceId 是 null,日志链路断了,线上排查问题根本对不上。
原因:线程池的线程是提前创建好的,ITL 的"创建时拷贝"机制根本没触发。
更隐蔽的坑:脏数据污染
即使你不用异步,线程池复用也会导致上一个请求的数据被下一个请求读到。
// 模拟线程池复用导致的数据污染
public class DirtyDataDemo {
private static final InheritableThreadLocal<String> USER_ID = new InheritableThreadLocal<>();
private static final ExecutorService pool = Executors.newFixedThreadPool(1); // 只有1个线程
public static void main(String[] args) throws Exception {
// 请求1:用户A
USER_ID.set("user-A");
pool.submit(() -> {
System.out.println("任务1读到用户: " + USER_ID.get()); // user-A ✅
// 忘记调用 USER_ID.remove()
}).get();
// 请求2:用户B(复用了同一个线程)
USER_ID.set("user-B");
pool.submit(() -> {
System.out.println("任务2读到用户: " + USER_ID.get()); // 可能还是 user-A ❌
}).get();
}
}
线上真实后果 :用户A登录了,用户B用同一个线程处理请求,结果读到了用户A的身份信息------越权访问
修复方案:TransmittableThreadLocal 实战
第一步:替换为 TTL
// 只需要改这一行:InheritableThreadLocal → TransmittableThreadLocal
public class TraceContext {
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();
public static void set(String traceId) { TRACE_ID.set(traceId); }
public static String get() { return TRACE_ID.get(); }
public static void clear() { TRACE_ID.remove(); }
}
第二步:包装线程池(关键!)
@Configuration
public class ThreadPoolConfig {
@Bean
public ExecutorService ttlExecutorService() {
// 用 TTL 包装原生线程池
return TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(10)
);
}
}
第三步:业务代码(无需任何改动)
@Service
public class OrderService {
@Autowired
private ExecutorService ttlExecutorService; // 注入包装后的线程池
public void createOrder(Order order) {
System.out.println("主线程 traceId: " + TraceContext.get()); // trace-123
ttlExecutorService.submit(() -> {
// 现在能正确拿到 traceId 了!✅
System.out.println("异步线程 traceId: " + TraceContext.get()); // trace-123
saveToDatabase(order);
});
}
}
更复杂的场景:CompletableFuture 链式调用
实际生产中经常用 CompletableFuture 做异步编排,TTL 同样支持:
@Service
public class OrderService {
public void createOrder(Order order) {
String traceId = TraceContext.get(); // trace-123
CompletableFuture
.supplyAsync(TtlSupplier.get(() -> {
// 第一步:校验订单
System.out.println("校验线程 traceId: " + TraceContext.get()); // trace-123 ✅
return validate(order);
}), ttlExecutorService)
.thenApplyAsync(TtlFunction.get(result -> {
// 第二步:扣减库存
System.out.println("库存线程 traceId: " + TraceContext.get()); // trace-123 ✅
return deductStock(result);
}), ttlExecutorService)
.thenAcceptAsync(TtlConsumer.get(result -> {
// 第三步:发送通知
System.out.println("通知线程 traceId: " + TraceContext.get()); // trace-123 ✅
sendNotification(result);
}), ttlExecutorService);
}
}
注意:CompletableFuture 的每个异步阶段都要用 TtlSupplier.get()、TtlFunction.get()、TtlConsumer.get() 包装,否则链路会断。
生产环境必须遵守的 5 条铁律
铁律一:finally 中必须 remove()
public void handleRequest() {
try {
TraceContext.set(UUID.randomUUID().toString());
// 业务逻辑...
} finally {
TraceContext.clear(); // 必须清理,否则线程复用时数据残留
}
}
铁律二:ThreadLocal 必须声明为 static final
// ✅ 正确
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();
// ❌ 错误:局部变量,方法结束就没了,但值还残留在 ThreadLocalMap 里
public void method() {
TransmittableThreadLocal<String> local = new TransmittableThreadLocal<>();
}
铁律三:不要存大对象
// ❌ 错误:存了一个 10MB 的字节数组
private static final TransmittableThreadLocal<byte[]> LARGE_DATA = new TransmittableThreadLocal<>();
LARGE_DATA.set(new byte[10 * 1024 * 1024]);
// ✅ 正确:只存必要的标识符
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();
铁律四:嵌套线程池也要包装
ExecutorService outer = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(5));
outer.submit(() -> {
// 内层线程池也必须包装,否则内层拿不到上下文
ExecutorService inner = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(5));
inner.submit(() -> {
System.out.println(TraceContext.get()); // 能拿到 ✅
});
});
铁律五:Agent 方式实现零侵入(推荐大型项目)
如果项目已经上线,不想改每一处线程池代码,可以用 Java Agent 方式,启动时加一个 JVM 参数就搞定:
java -javaagent:/path/to/transmittable-thread-local-2.14.2.jar -jar your-app.jar
加了 Agent 之后,所有线程池自动被包装,业务代码完全不用改。
一句话总结
| 问题 | 表现 | 解决方案 |
|---|---|---|
| 异步线程拿不到 traceId | 日志链路断裂,线上无法排查 | ITL → TTL + 包装线程池 |
| 线程复用读到上一个请求的数据 | 用户A看到用户B的数据(越权) | finally 中必须 remove() |
| CompletableFuture 链路中断 | 中间某个阶段拿不到上下文 | 每个阶段用 TtlXXX.get() 包装 |
| 老项目改造成本高 | 到处都要改线程池代码 | 加 -javaagent 参数,零侵入 |