一、从 MyBatis-Plus 的一个诡异问题说起在一次面试中面试官问了一个关于 MyBatis-Plus 的问题在同一个事务中用相同条件查询为什么会读到不同的数据除了常规的事务传播机制更深层的答案是MyBatis 的一级缓存​ 和Spring 的事务同步管理器。Spring 使用ThreadLocal来存储SqlSession。如果ThreadLocal中的数据在传递过程中丢了或者使用了错误的SqlSession就会导致缓存失效或数据错乱。这引出了我们今天的主角ThreadLocal 及其家族。二、ThreadLocal线程的私人保险柜1. 核心原理ThreadLocal的全称是Thread Local Variable线程局部变量。它并不是在线程之间共享变量而是为每个线程提供一个变量的独立副本。误区纠正ThreadLocal本身不存储值它只是作为Thread类中ThreadLocalMap的Key。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对象内部持有ThreadLocalMapEntry的Key​ 是WeakReferenceThreadLocal弱引用Entry的Value​ 是强引用3. 内存泄漏为什么必须remove()这是面试的高频考点。ThreadLocalMap的Entry对 Key 的引用是弱引用WeakReference但对 Value 的引用是强引用。Key 被回收当ThreadLocal实例被置为null后GC 会将 Key弱引用回收变成nullValue 残留由于 Value 是强引用且线程比如线程池的核心线程一直存活导致 Value 永远无法被回收解决方案在finally块中调用remove()。try { threadLocal.set(obj); // ... biz logic } finally { threadLocal.remove(); // 切断 Value 的强引用 }三、InheritableThreadLocal父子线程的传家宝1. 痛点ThreadLocal 无法在子线程获取ThreadLocalString tl new ThreadLocal(); tl.set(父线程的值); new Thread(() - { System.out.println(tl.get()); // 输出null }).start();2. 原理创建子线程时的拷贝InheritableThreadLocal(ITL) 重写了childValue、getMap、createMap方法使得在创建子线程时JVM 会将父线程的inheritableThreadLocals复制给子线程。源码逻辑简化// 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()的一瞬间。但在线程池​ 中线程是复用的任务提交时并没有创建新线程因此无法获取父线程的上下文。InheritableThreadLocalString itl new InheritableThreadLocal(); ExecutorService pool Executors.newFixedThreadPool(1); itl.set(主线程值); pool.execute(() - { // 输出 null因为线程池里的线程早就创建好了不是新 new 出来的 System.out.println(itl.get()); });四、TransmittableThreadLocal线程池的通行证为了解决线程池环境下的上下文传递问题阿里巴巴开源了transmittable-thread-localTTL。1. 核心思想时机转移既然线程创建时复制不行TTL 选择在任务被提交时Capture​ 和任务被执行前Replay​ 这两个时机做手脚。2. TTL 执行时序图下图完整展示了 TTL 在任务提交和执行过程中的四个阶段3. 工作流程详解捕获Capture当把Runnable提交给线程池时捕获当前线程父线程的 TTL 值包装WrapTTL 将Runnable包装成TtlRunnable回放Replay线程池里的线程执行TtlRunnable.run()之前将捕获的值设置到当前线程子线程中恢复Restore任务执行完毕后清除刚才设置的值防止影响下一个任务4. 代码示例// 1. 使用 TTL TransmittableThreadLocalString 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-Value5. 应用场景全链路追踪SkyWalking、Zipkin 传递TraceIdSession 传递在 Web 层获取用户 Session传递给异步线程处理Spring 事务上下文在异步方法中保持事务一致性五、三种 ThreadLocal 对比总结特性ThreadLocal (TL)InheritableThreadLocal (ITL)TransmittableThreadLocal (TTL)作用​线程内部数据隔离父子线程间数据传递线程池/复杂线程模型下数据传递存储位置​threadLocalsinheritableThreadLocals包装了 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 FrameworkTransactionSynchronizationManager源码[4] MyBatisSqlSessionManager源码分析