ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

InheritableThreadLocal原理与线程池失效解析

InheritableThreadLocal原理与线程池失效解析 1. 从一个诡异的线上问题说起去年帮一个团队排查过一个挺有意思的Bug一个请求进来主线程往ThreadLocal里塞了当前登录用户的信息然后业务逻辑里用线程池异步去查数据库、拼装数据结果异步线程里拿到的用户信息全是null。更诡异的是有时候又能拿到有时候拿到的是上一个请求的用户信息——这就不是简单的没传进去了而是数据串了。这个现象背后其实就两个知识点ThreadLocal的隔离边界是线程而线程池里的线程是复用的。你往主线程的ThreadLocal里放东西子线程天然看不到而线程池复用线程时如果上一个任务没清理干净下一个任务就会读到脏数据。那有没有一种机制能让子线程自动继承父线程的本地变量有就是InheritableThreadLocal。但它的名字很有迷惑性——继承两个字让很多人以为它能解决线程池的问题实际上它解决的是线程创建时的一次性传递跟线程池复用完全是两码事。这篇就把这个类从原理到坑点掰开揉碎讲清楚包括它到底怎么实现继承的、为什么在线程池里会失效、以及实际项目里该怎么选型。2. InheritableThreadLocal 到底继承了什么2.1 先看 ThreadLocal 的存储结构要理解继承得先知道ThreadLocal的值存在哪。很多人以为值存在ThreadLocal对象自己身上其实不是。每个Thread对象内部都有一个ThreadLocalMap字段key是ThreadLocal实例本身准确说是弱引用包装后的value才是你set进去的值。// Thread 类内部简化示意 public class Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals null; ThreadLocal.ThreadLocalMap inheritableThreadLocals null; }注意这里有两个字段threadLocals和inheritableThreadLocals。普通ThreadLocal的值进的是前者InheritableThreadLocal的值进的是后者。这个设计很关键——两套存储互不干扰一个线程可以同时拥有普通本地变量和可继承本地变量。ThreadLocal.get()的逻辑大致是拿到当前线程取它的threadLocals用自己当key去查。查不到就调用initialValue()初始化。InheritableThreadLocal重写了三个方法把操作对象从threadLocals换成了inheritableThreadLocalspublic class InheritableThreadLocalT extends ThreadLocalT { protected T childValue(T parentValue) { return parentValue; // 默认直接返回父值浅拷贝 } ThreadLocalMap getMap(Thread t) { return t.inheritableThreadLocals; } void createMap(Thread t, T firstValue) { t.inheritableThreadLocals new ThreadLocalMap(this, firstValue); } }所以从存储角度看InheritableThreadLocal就是换了个抽屉放东西的ThreadLocal。2.2 继承发生在 new Thread() 的那一刻真正实现继承的代码在Thread的构造函数里。当你new Thread()时构造链最终会走到Thread.init()里面有这么一段private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { // ... Thread parent currentThread(); // ... if (inheritThreadLocals parent.inheritableThreadLocals ! null) { this.inheritableThreadLocals ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); } }逻辑很直白创建子线程时如果父线程也就是执行new Thread()的那个线程的inheritableThreadLocals不为空就把父线程的整张Map复制一份给子线程。复制的过程会遍历父Map的每个Entry对每个值调用childValue()做转换默认实现是直接返回原值。这里有几个细节值得注意继承是拷贝不是引用。子线程拿到的是一个新的ThreadLocalMap里面装的是父线程值的副本对于对象类型是引用拷贝指向同一个对象。子线程后续set新值不会影响父线程。继承只发生在创建线程时。线程一旦创建完成父子之间的这条继承通道就断了。父线程之后再set新值子线程完全感知不到。默认的 childValue 是浅拷贝。如果父线程存的是一个可变对象比如User实例子线程和父线程拿到的是同一个对象引用。子线程改了对象的字段父线程也会看到变化。这是个很容易踩的坑后面会细说。2.3 一个最小可运行示例光说原理太干直接上代码验证一下public class InheritableDemo { private static final InheritableThreadLocalString CONTEXT new InheritableThreadLocal(); public static void main(String[] args) throws InterruptedException { CONTEXT.set(父线程的值); Thread child new Thread(() - { System.out.println(子线程读到: CONTEXT.get()); CONTEXT.set(子线程改过的值); System.out.println(子线程改后: CONTEXT.get()); }); child.start(); child.join(); System.out.println(父线程读到: CONTEXT.get()); } }输出结果子线程读到: 父线程的值 子线程改后: 子线程改过的值 父线程读到: 父线程的值可以看到子线程确实继承到了父线程的值而且子线程的修改没有反向污染父线程。这就是InheritableThreadLocal最核心的行为。对比一下如果把上面的InheritableThreadLocal换成普通ThreadLocal子线程读到的就是null。这个对比实验建议你自己跑一遍比看十遍文档都管用。3. 线程池场景下它为什么直接失效3.1 线程池的线程不是当场创建的InheritableThreadLocal的继承时机是new Thread()。而线程池的工作机制是核心线程在池子初始化或第一次提交任务时创建之后一直复用。也就是说任务提交时并不会创建新线程自然也就不会触发继承逻辑。看个例子就明白了public class PoolFailDemo { private static final InheritableThreadLocalString CONTEXT new InheritableThreadLocal(); private static final ExecutorService POOL Executors.newFixedThreadPool(2); public static void main(String[] args) throws InterruptedException { CONTEXT.set(请求A的用户); POOL.submit(() - System.out.println(任务1读到: CONTEXT.get())); Thread.sleep(100); CONTEXT.set(请求B的用户); POOL.submit(() - System.out.println(任务2读到: CONTEXT.get())); POOL.shutdown(); } }实际输出很可能是任务1读到: null 任务2读到: null为什么是null而不是请求A的用户因为线程池的线程是在submit之前就创建好的或者创建时机早于CONTEXT.set创建那一刻父线程的inheritableThreadLocals还是空的所以子线程继承了个寂寞。更坑的是另一种情况如果线程池的线程恰好是在你set之后创建的那这个线程会继承到当时的值然后一直带着这个值后续所有复用它的任务都会读到这个陈旧的值。这就是开头说的数据串了的根源。3.2 线程复用带来的脏数据问题假设线程池是懒加载的第一个任务提交时创建了线程此时父线程CONTEXT里是请求A。这个线程被创建后inheritableThreadLocals里就永久存了请求A。之后请求B、请求C提交任务用的还是这个线程读到的全是请求A。// 模拟先 set 再创建线程池 CONTEXT.set(请求A); ExecutorService pool Executors.newFixedThreadPool(1); pool.submit(() - System.out.println(A: CONTEXT.get())); // 请求A CONTEXT.set(请求B); pool.submit(() - System.out.println(B: CONTEXT.get())); // 还是请求A这个问题的本质是InheritableThreadLocal的值绑定在线程上而线程池的线程生命周期远长于单个任务。任务结束了值还在线程身上挂着下一个任务就遭殃了。注意不要试图用remove()在任务结束时清理来解决这个问题。因为继承只发生在线程创建时你remove之后下一个任务并不会重新继承父线程的新值——它只会读到null。清理只能防止脏数据不能实现每个任务拿到自己请求的上下文。3.3 那阿里 TransmittableThreadLocal 是怎么做的既然原生方案在线程池里不好使业界就有对应的增强方案。思路是不依赖线程创建时的继承而是在任务提交时把父线程的上下文快照下来在任务执行前回填到执行线程任务结束后再恢复现场。核心是包装Runnable/Callable// 伪代码展示思路 class TtlRunnable implements Runnable { private final Runnable actual; private final Object captured; // 提交时抓取的上下文快照 public void run() { Object backup 当前线程的上下文; // 备份执行线程原有值 回填(captured); // 用提交时的快照覆盖 try { actual.run(); } finally { 恢复(backup); // 还原避免污染线程池 } } }这套机制的关键在于快照-回填-恢复三步它把上下文的传递从线程创建时挪到了任务提交时从而绕开了线程池复用的坑。理解了这个思路你就算不用现成框架自己手写一个简易版也不难。4. childValue 重写与可变对象的深坑4.1 默认浅拷贝意味着什么前面提过childValue()默认直接返回父值。如果父线程存的是不可变对象String、Integer等没问题。但如果存的是可变对象父子线程共享同一个引用static class UserContext { String name; // getter/setter 省略 } private static final InheritableThreadLocalUserContext CTX new InheritableThreadLocal(); public static void main(String[] args) throws InterruptedException { UserContext ctx new UserContext(); ctx.setName(张三); CTX.set(ctx); Thread child new Thread(() - { CTX.get().setName(李四); // 改的是同一个对象 }); child.start(); child.join(); System.out.println(CTX.get().getName()); // 输出李四 }父线程明明没动名字却从张三变成了李四。因为父子俩的ThreadLocalMap里存的是同一个UserContext实例的引用。这种Bug特别隐蔽因为从ThreadLocal的语义看你会以为父子是隔离的。4.2 重写 childValue 做深拷贝要避免这个问题就得重写childValue()在继承时做一次拷贝private static final InheritableThreadLocalUserContext CTX new InheritableThreadLocal() { Override protected UserContext childValue(UserContext parentValue) { if (parentValue null) return null; UserContext copy new UserContext(); copy.setName(parentValue.getName()); return copy; } };这样父子各持一份互不影响。但要注意深拷贝是有成本的。如果上下文对象很大、继承很频繁拷贝开销不能忽略。而且如果对象里有嵌套引用你得保证每一层都拷到位漏一层就还是共享。4.3 什么时候该重写什么时候不该判断标准很简单子线程会不会修改这个对象。如果上下文是只读的比如一个请求ID、一个租户标识子线程只读不写那默认浅拷贝完全够用没必要重写。如果子线程可能修改对象状态或者对象本身是可变集合List、Map那就必须重写childValue做隔离。如果对象是不可变类型String、包装类、枚举浅拷贝和深拷贝没区别不用管。我个人的习惯是上下文对象尽量设计成不可变的。用final字段、不提供setter、集合用Collections.unmodifiableXxx包一层。这样从源头上就杜绝了共享可变状态的问题比事后重写childValue省心得多。5. 内存泄漏弱引用救不了的那部分5.1 ThreadLocalMap 的 key 是弱引用ThreadLocalMap的Entry设计很讲究key是ThreadLocal实例的弱引用value是强引用。static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); // key 弱引用 value v; // value 强引用 } }为什么这么设计如果key是强引用那么只要线程活着ThreadLocal对象就永远不被回收哪怕外部已经没有任何地方引用它了。用弱引用后当外部对ThreadLocal的强引用消失GC就能回收keyEntry变成(null, value)。但问题来了key被回收了value还在而且是被Entry强引用的。如果线程一直活着比如线程池的核心线程这个value就永远回收不掉这就是经典的内存泄漏。5.2 InheritableThreadLocal 让泄漏面更大普通ThreadLocal的泄漏只影响当前线程。InheritableThreadLocal更麻烦——它会被复制到所有子线程。父线程创建了10个子线程每个子线程的inheritableThreadLocals里都有一份Entry。父线程清理了自己的子线程的还在。在线程池场景下如果线程池的线程是通过new Thread()创建的比如自定义ThreadFactory那每个池内线程都可能持有一份继承来的上下文。这些线程长期存活泄漏的value就一直挂着。5.3 正确的清理姿势不管用哪种ThreadLocal核心原则都是谁set的谁负责remove且必须在finally里。CONTEXT.set(userInfo); try { // 业务逻辑 } finally { CONTEXT.remove(); // 必须放在 finally }几个容易忽略的点remove要放在最外层。如果业务逻辑里又嵌套了set要确保最终能清干净。线程池任务里remove要放在任务执行的finally而不是提交任务的线程里。因为值是在执行线程上提交线程remove没用。不要依赖GC自动清理。虽然ThreadLocalMap在get/set时会顺带清理key为null的Entry但那是碰运气式的清理不保证及时也不保证覆盖所有Entry。提示如果你用的是增强版的上下文传递方案快照-回填-恢复那套恢复步骤本身就起到了清理作用通常不需要额外remove。但前提是框架实现正确自己手写的话要格外小心。6. 选型决策三种场景对应三种方案6.1 场景对照表把前面讲的串起来实际项目里怎么选看这张表就够了场景线程创建方式推荐方案理由一次性创建子线程用完即弃new Thread()InheritableThreadLocal继承时机正好无复用问题线程池任务需要独立上下文线程池复用增强传递方案快照-回填-恢复绕开创建时机限制任务级隔离线程池但上下文全局只读线程池复用普通ThreadLocal 启动时初始化不需要传递直接全局配置父子线程都要写同一对象任意重写childValue做深拷贝避免共享可变状态6.2 为什么不能一招鲜很多人问既然增强方案能解决线程池问题那是不是所有场景都用它就行了不一定。增强方案有额外开销每次任务提交要抓快照每次执行要回填和恢复涉及对象拷贝和Map操作。对于高频短任务这个开销占比可能不小。而InheritableThreadLocal在一次性子线程场景下几乎零成本因为继承就发生在线程创建那一下。所以选型的核心是问自己三个问题线程是复用的吗复用就走增强方案不复用可以考虑原生继承。上下文需要跨任务隔离吗需要隔离就必须在任务边界做快照和恢复。上下文对象可变吗可变就得考虑深拷贝不可变就省事了。6.3 一个容易忽略的替代思路有时候我们其实不需要继承而是需要显式传递。比如把上下文作为参数一路传下去虽然代码啰嗦但最清晰、最不容易出错。很多团队一上来就上ThreadLocal结果调试时根本不知道值从哪来、被谁改了。我的经验是能用参数传的就别用ThreadLocal。只有当传递链路太长比如跨十几层调用、或者框架回调无法加参数时才考虑用ThreadLocal这类隐式传递机制。隐式传递是把双刃剑用好了省事用不好就是排查噩梦。7. 几个实测中踩出来的经验最后分享几个我在实际项目里踩过的坑都是文档里不会写的。第一个坑以为继承了就一劳永逸。有次写了个工具类在静态初始化块里set了配置然后指望所有子线程都能读到。结果线程池里的线程读不到因为线程池创建时机早于静态块执行。后来改成在每次任务提交时显式传递才解决。教训是继承的时机是线程创建不是值设置这两个时间点必须对齐。第二个坑子线程改了对象父线程跟着变。前面讲过的浅拷贝问题。当时上下文里放了个Map子线程往里塞了个临时数据结果父线程读到了脏数据。排查了半天才想到是共享引用。后来把上下文对象改成不可变问题根除。第三个坑忘记remove导致内存缓慢增长。一个长跑服务跑了一周后堆内存居高不下。dump下来发现大量ThreadLocalMap$Entryvalue是请求对象。原因是某段代码在set之后没remove而线程池线程长期存活。加上finally { remove(); }后内存曲线立刻平稳。第四个坑在子线程里remove父线程的值。这个错误比较低级但真有人犯在子线程里调CONTEXT.remove()以为能清掉父线程的。实际上remove只作用于当前线程的Map父线程的值纹丝不动。要清父线程的得在父线程里remove。第五个坑线程池的ThreadFactory没考虑继承。自定义ThreadFactory时如果在线程创建前没有正确设置父线程的上下文继承到的就是空值。而且不同线程创建时机不同继承到的值可能不一致导致行为随机。这种问题最难查因为时好时坏。说到底InheritableThreadLocal是个看起来简单、用起来有边界的工具。它的继承语义很清晰但边界条件线程创建时机、线程复用、可变对象、内存泄漏才是真正决定成败的地方。把这几条吃透你在选型和排错时就能少走很多弯路。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表