一、从 MyBatis-Plus 的一个诡异问题说起
在一次面试中,面试官问了一个关于 MyBatis-Plus 的问题:
"在同一个事务中,用相同条件查询,为什么会读到不同的数据?"
除了常规的事务传播机制,更深层的答案是 MyBatis 的一级缓存 和 Spring 的事务同步管理器。
Spring 使用 ThreadLocal 来存储 SqlSession。如果 ThreadLocal 中的数据在传递过程中"丢"了,或者使用了错误的 SqlSession,就会导致缓存失效或数据错乱。这引出了我们今天的主角:ThreadLocal 及其家族。
二、ThreadLocal:线程的"私人保险柜"
1. 核心原理
ThreadLocal 的全称是 Thread Local Variable(线程局部变量) 。它并不是在线程之间共享变量,而是为每个线程提供一个变量的独立副本。
误区纠正 :ThreadLocal 本身不存储值,它只是作为 Thread 类中 ThreadLocalMap 的 Key。
java
public class Thread implements Runnable {
// ThreadLocal 的值存在这里
ThreadLocal.ThreadLocalMap threadLocals = null;
// 用于 InheritableThreadLocal
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}
当我们调用 set(T value) 时,实际上是把值放进了当前线程(Thread.currentThread())的 threadLocals 这个 Map 里。
2. 内存结构图
下图展示了 ThreadLocal 在 JVM 内存中的存储结构(左半部分),以及三种 ThreadLocal 的能力对比(右半部分):

核心要点:
Stack(栈) 中只保存
ThreadLocal引用变量Heap(堆) 中的
Thread对象内部持有ThreadLocalMap
Entry的 Key 是WeakReference<ThreadLocal>(弱引用)
Entry的 Value 是强引用
3. 内存泄漏:为什么必须 remove()?
这是面试的高频考点。ThreadLocalMap 的 Entry 对 Key 的引用是 弱引用(WeakReference) ,但对 Value 的引用是 强引用。
-
Key 被回收 :当
ThreadLocal实例被置为null后,GC 会将 Key(弱引用)回收,变成null -
Value 残留:由于 Value 是强引用,且线程(比如线程池的核心线程)一直存活,导致 Value 永远无法被回收
解决方案 :在 finally 块中调用 remove()。
java
try {
threadLocal.set(obj);
// ... biz logic
} finally {
threadLocal.remove(); // 切断 Value 的强引用
}
三、InheritableThreadLocal:父子线程的"传家宝"
1. 痛点:ThreadLocal 无法在子线程获取
java
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("父线程的值");
new Thread(() -> {
System.out.println(tl.get()); // 输出:null
}).start();
2. 原理:创建子线程时的"拷贝"
InheritableThreadLocal (ITL) 重写了 childValue、getMap、createMap 方法,使得在创建子线程时,JVM 会将父线程的 inheritableThreadLocals 复制给子线程。
源码逻辑(简化):
java
// Thread 构造方法
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc) {
// 关键步骤:复制父线程的 inheritableThreadLocals
if (parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
3. 致命缺陷:线程池环境失效
ITL 的复制发生在 new Thread() 的一瞬间。但在 线程池 中,线程是复用的,任务提交时并没有创建新线程,因此无法获取父线程的上下文。
java
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);
itl.set("主线程值");
pool.execute(() -> {
// 输出 null!因为线程池里的线程早就创建好了,不是新 new 出来的
System.out.println(itl.get());
});
四、TransmittableThreadLocal:线程池的"通行证"
为了解决线程池环境下的上下文传递问题,阿里巴巴开源了 transmittable-thread-local(TTL)。
1. 核心思想:时机转移
既然线程创建时复制不行,TTL 选择在 任务被提交时(Capture) 和 **任务被执行前(Replay)** 这两个时机做手脚。
2. TTL 执行时序图
下图完整展示了 TTL 在任务提交和执行过程中的四个阶段:

3. 工作流程详解
-
捕获(Capture) :当把
Runnable提交给线程池时,捕获当前线程(父线程)的 TTL 值 -
包装(Wrap) :TTL 将
Runnable包装成TtlRunnable -
回放(Replay) :线程池里的线程执行
TtlRunnable.run()之前,将捕获的值设置到当前线程(子线程)中 -
恢复(Restore):任务执行完毕后,清除刚才设置的值,防止影响下一个任务
4. 代码示例
java
// 1. 使用 TTL
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();
ttl.set("Main-Value");
ExecutorService pool = Executors.newFixedThreadPool(1);
// 2. 包装任务(关键步骤)
Runnable task = () -> System.out.println(ttl.get());
Runnable ttlTask = TtlRunnable.get(task);
// 3. 提交执行
pool.execute(ttlTask); // 输出:Main-Value
5. 应用场景
-
全链路追踪 :SkyWalking、Zipkin 传递
TraceId -
Session 传递:在 Web 层获取用户 Session,传递给异步线程处理
-
Spring 事务上下文:在异步方法中保持事务一致性
五、三种 ThreadLocal 对比总结
| 特性 | ThreadLocal (TL) | InheritableThreadLocal (ITL) | TransmittableThreadLocal (TTL) |
|---|---|---|---|
| 作用 | 线程内部数据隔离 | 父子线程间数据传递 | 线程池/复杂线程模型下数据传递 |
| 存储位置 | threadLocals |
inheritableThreadLocals |
包装了 ITL,并配合 TtlRunnable |
| 复制时机 | 无复制 | 创建子线程时 (new Thread()) |
任务提交时 (execute()) |
| 线程池支持 | x 不支持 (脏数据) | x 不支持 (复用导致失效) | ok 完美支持 |
| 典型场景 | 事务上下文、SimpleDateFormat | 单线程创建子线程 | **微服务链路追踪 (SkyWalking)** |
六、结合 Spring 与 MyBatis-Plus 的实战思考
回到文章开头的问题,为什么 MyBatis-Plus 会读到不同数据?
-
ThreadLocal 绑定 :Spring 使用
TransactionSynchronizationManager(内部使用 ThreadLocal)绑定了SqlSession -
一级缓存 :MyBatis 的一级缓存是
SqlSession级别的。如果ThreadLocal中的SqlSession被意外清理(例如错误的手动管理 Session),缓存就会失效 -
异步陷阱 :如果在事务中使用了
@Async或线程池,由于默认的ThreadLocal无法传递,子线程会开启一个全新的事务和 SqlSession,导致读到的数据与主线程不一致
最佳实践 :在涉及事务的异步操作中,如果需要上下文一致,请使用 TransmittableThreadLocal 配合特定的事务传播机制,或者避免在事务方法中开启异步线程。
七、结语
-
ThreadLocal 是基础,用于线程隔离,切记用完
remove() -
InheritableThreadLocal 是补充,解决了简单的父子线程传递,但在现代线程池应用中基本失效
-
TransmittableThreadLocal 是终极方案,通过装饰
Runnable实现了线程池环境下的上下文穿透,是微服务架构下的必备知识
希望这篇文章能帮你彻底理清这三者的关系。如果觉得有用,欢迎点赞、收藏、关注!
参考资料
-
1 《Java 并发编程实战》- Brian Goetz 等
-
2 阿里巴巴
transmittable-thread-localGitHub Wiki -
3 Spring Framework
TransactionSynchronizationManager源码 -
4 MyBatis
SqlSessionManager源码分析