ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

面试中 ThreadLocal 能问的,都在这了:2万字详解

面试中 ThreadLocal 能问的,都在这了:2万字详解 一、从一个经典面试题说起在 Java 后端面试中ThreadLocal 几乎是必考知识点。面试官经常会用下面这几个问题来试探候选人对并发编程和 JVM 内存模型的理解深度ThreadLocal 有什么用线程之间数据是隔离的吗ThreadLocal 的底层实现原理是什么数据到底存在哪里为什么 ThreadLocalMap 的 key 要设计成弱引用ThreadLocal 到底会不会造成内存泄漏为什么说它是一把双刃剑InheritableThreadLocal 和普通 ThreadLocal 有什么区别使用线程池时上一轮任务残留的 ThreadLocal 数据为什么会污染下一轮任务你知道 Netty 的 FastThreadLocal 为什么要重写一套实现吗这些问题看似零散实际上都围绕着同一个内核ThreadLocal 通过“以线程为维度存储局部变量”的方式在不需要加锁的前提下实现了线程间的数据隔离。真正搞懂它不仅需要理解用法还需要深入到 Thread、ThreadLocalMap、引用类型和垃圾回收这些底层知识。本文尝试把面试中关于 ThreadLocal 的高频考点全部串起来从使用场景、API 用法到源码级实现原理、内存泄漏分析、父子线程数据传递、线程池脏数据问题再到 Netty 的优化方案最后附上高频面试题汇总。全文较长建议先收藏再按章节循序渐进阅读。二、ThreadLocal 是什么解决什么问题2.1 问题的来源共享变量与线程安全在多线程环境下如果多个线程同时读写同一个普通成员变量就可能产生数据竞争导致程序结果不可预期。解决线程安全问题的常见手段有两个方向加锁通过 synchronized、ReentrantLock 等机制让同一时刻只有一个线程访问共享资源。缺点是需要处理锁的粒度、死锁、竞争开销等问题。隔离干脆不让线程共享同一个变量而是给每个线程一份自己的拷贝。各线程操作各自的数据互不干扰从而从根本上避免竞争。ThreadLocal 走的就是第二条路线。它提供的不是“共享变量”而是“线程私有变量”。同一个 ThreadLocal 对象在不同线程里读取到的是各自独立的值彼此完全隔离。2.2 ThreadLocal 的直觉理解可以把 ThreadLocal 理解成一个“随身口袋”。每个线程身上都有一个口袋口袋里装着一份只属于自己的数据。你用同一个 ThreadLocal 对象作为钥匙在自己的线程里去取口袋里那份数据时拿到的永远是自己线程的数据不会拿到别的线程的。public class ThreadLocalDemo { private static final ThreadLocalString holder new ThreadLocal(); public static void main(String[] args) { Thread t1 new Thread(() -gt; { holder.set(线程1的数据); System.out.println(Thread.currentThread().getName() 读到 holder.get()); }, t1); Thread t2 new Thread(() -amp;gt; { holder.set(线程2的数据); System.out.println(Thread.currentThread().getName() 读到 holder.get()); }, t2); t1.start(); t2.start(); } }运行后会发现t1 永远读到“线程1的数据”t2 永远读到“线程2的数据”。同一个 holder 对象在不同线程中的值互不影响这就是线程隔离的效果。2.3 ThreadLocal 的典型特点线程私有每个线程拥有独立的变量副本天然线程安全无需加锁。读写方便通过 get、set、remove 三个核心方法即可完成存取。生命周期跟随线程只要线程还活着且没有调用 removeThreadLocal 中的数据就一直存在。使用不当有坑可能造成内存泄漏也可能在线程池场景下产生脏数据。三、核心 API 与基本使用3.1 四个常用方法ThreadLocal 对外暴露的核心方法非常少概括起来就是下面几个方法作用T get()获取当前线程在本地变量中的副本值。如果从未设置过会触发 initialValue 初始化。void set(T value)为当前线程设置本地变量副本。void remove()移除当前线程的本地变量副本防止内存泄漏。T initialValue()返回初始值默认返回 null可重写惰性初始化仅第一次 get 时调用。3.2 基本使用示例一个非常典型的例子是 SimpleDateFormat。它是线程不安全的如果多个线程共享同一个实例在解析日期时可能抛异常或产生错误结果。传统做法是每次使用时 new 一个但频繁创建代价较高更好的做法是用 ThreadLocal 给每个线程一份独立实例。public class DateFormatUtils { private static final ThreadLocalSimpleDateFormat FORMATTER ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public static String format(Date date) { return FORMATTER.get().format(date); } }这里用到的是 Java 8 提供的静态工厂方法withInitial它等价于匿名内部类方式重写 initialValueThreadLocalSimpleDateFormat local new ThreadLocal() { Override protected SimpleDateFormat initialValue() { return new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); } };注意initialValue 只在第一次调用 get 且当前线程还没有对应值的时候才会执行。也就是说即使通过 withInitial 定义了初始化逻辑如果某个线程从始至终没有调用 get它的初始化函数也不会被触发。四、ThreadLocal 的主要使用场景面试中经常让候选人举例说明 ThreadLocal 的应用场景。能说到下面几类基本可以证明你真的用过、理解过它。4.1 线程不安全的工具类隔离典型代表是 SimpleDateFormat、Random 等线程不安全对象。与其每次加锁或 new 新实例不如让每个线程持有一份独立实例。这样既保证了线程安全又避免了锁竞争和重复创建的开销。4.2 数据库连接、Session 管理在早期的 JDBC 编程中常把 Connection 绑定到当前线程保证同一个线程内多次数据库操作复用同一个连接事务也能跟随同一连接执行。Spring 的 TransactionSynchronizationManager、MyBatis 的 SqlSession 管理等框架里都有类似 ThreadLocal 的身影。4.3 链路追踪与日志上下文分布式链路追踪中需要把 traceId、spanId 等标识贯穿一次请求处理的整个调用链。这些上下文信息通常放在 ThreadLocal 里这样任何一个方法里都能拿到当前请求的 traceId而不需要层层传参。MDCMapped Diagnostic Context本质上也是基于 ThreadLocal 实现的。public class TraceIdHolder { private static final ThreadLocalString TRACE_ID new ThreadLocal(); 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(); } }4.4 全局参数的隐式传递有些参数例如当前登录用户、租户 ID、语言环境、分页参数几乎贯穿所有方法调用。如果通过方法签名层层传递代码会非常臃肿。把这些参数放进 ThreadLocal可以在任意层级的代码中取用大大简化 API 设计。4.5 避免同步的性能优化某些场景下一个变量大多数时候只有单个线程访问用 ThreadLocal 就能避免锁。例如统计每个线程的耗时、缓存每个线程自己的临时对象等。不过这类优化要谨慎因为 ThreadLocal 本身也有维护成本。4.6 ThreadLocal 不适合做什么ThreadLocal 不是万能的。它解决不了真正意义上的“多个线程需要通信、需要看到彼此修改结果”的问题。如果业务上多个线程本来就要协作修改同一个数据那还是需要加锁或者使用线程安全的并发容器。ThreadLocal 只适合“各玩各的”场景。五、底层实现原理数据到底存在哪里这是整个 ThreadLocal 知识体系中最核心的部分也是面试官最喜欢追问的地方。很多人的第一反应是“ThreadLocal 内部维护了一个 Mapkey 是线程value 是值”。这个说法在功能表现上是对的但实现细节恰恰相反。5.1 Thread 持有 ThreadLocalMap真正的存储结构是每个 Thread 对象内部持有一个 ThreadLocalMap 成员变量名为 threadLocals。ThreadLocal 对象本身并不保存任何变量值它只负责作为 key 去查找当前线程的 map。打开 Thread 类的源码可以看到这样两个字段public class Thread implements Runnable { /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals null; ThreadLocal.ThreadLocalMap inheritableThreadLocals null; // 省略其他代码 }也就是说数据最终的栖息地是 Thread 对象身上的 ThreadLocalMap而不是 ThreadLocal 对象内部。搞清这一点后面的很多问题都会迎刃而解。5.2 get 方法的执行流程以 ThreadLocal 的 get 为例它的源码大致如下public T get() { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { ThreadLocalMap.Entry e map.getEntry(this); if (e ! null) { SuppressWarnings(unchecked) T result (T) e.value; return result; } } return setInitialValue(); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; }执行流程可以归纳为拿到当前线程对象。通过 getMap 取出当前线程的 threadLocals。如果 map 不为空则以当前 ThreadLocal 实例为 key在 map 中查找 Entry。找到则把 value 强转返回找不到或 map 为空则调用 setInitialValue 完成初始化并返回初始值。5.3 set 方法的执行流程public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { map.set(this, value); } else { createMap(t, value); } } void createMap(Thread t, T firstValue) { t.threadLocals new ThreadLocalMap(this, firstValue); }set 的逻辑更简单如果当前线程已经有 map就直接向 map 里写入如果还没有就先创建 map并把第一个键值对放进去然后把 map 挂到线程对象上。5.4 为什么不用“一个大 Map 以线程为 key”从功能上全局维护一个 ConcurrentHashMapkey 是线程、value 是值也能实现线程隔离。但 JDK 选择每个线程各持有一个 map至少有两个明显好处减少全局竞争每个线程操作自己的 map不需要对整个全局 map 加锁性能更好。生命周期管理更自然当线程结束时它持有的 threadLocals 引用会随 Thread 对象一起被回收不容易出现线程死亡后数据仍残留在全局容器里的问题。六、ThreadLocalMap 详解真正的主角ThreadLocalMap 是 ThreadLocal 的一个静态内部类它才是真正负责存储的地方。它没有实现 Map 接口而是自己定义了一套轻量实现。6.1 Entry 的结构ThreadLocalMap 的节点叫 Entry它继承自 WeakReferencestatic class Entry extends WeakReferenceThreadLocal? { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocallt;?gt; k, Object v) { super(k); value v; } }注意两个关键点Entry 的 key 是弱引用引用的是 ThreadLocal 对象value 是强引用保存我们真正要存的数据。Entry 同时扮演了“键值对”和“弱引用对象”两个角色。6.2 哈希与冲突解决开放地址法与 HashMap 不同ThreadLocalMap 没有使用链表或红黑树来解决哈希冲突而是采用开放地址法中的线性探测。ThreadLocal 里维护了一个全局的原子计数器private static AtomicInteger nextHashCode new AtomicInteger(); private static final int HASH_INCREMENT 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }每个 ThreadLocal 实例在创建时都会分到一个 threadLocalHashCode。这个值不是简单地自增 1而是每次增加黄金分割数相关的魔数 0x61c88647目的是让相邻创建的 ThreadLocal 在哈希表中分布得更均匀。定位数组下标的逻辑如下int i key.threadLocalHashCode (table.length - 1);如果该位置已经被别的 key 占用就向后线性探测找到下一个可用位置for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { // 处理逻辑 } private static int nextIndex(int i, int len) { return ((i 1 len) ? i 1 : 0); }这种实现意味着ThreadLocalMap 并不适合存放海量键值它默认容量 16负载因子为三分之二超过阈值就会扩容为原来的两倍。6.3 set 过程中的“顺手清理”ThreadLocalMap 在 set 时会遍历探测路径上的桶。如果发现某个桶的 key 已经是 null说明 ThreadLocal 被 GC 掉了就会调用 replaceStaleEntry 把这个失效桶替换掉同时触发 expungeStaleEntry 清理。expungeStaleEntry 做的事情是把失效 Entry 的 value 置为 null同时把当前桶置为 null断开对 value 对象的引用。从下一个位置开始重新整理后面那些因为哈希冲突而放在较远位置的 Entry让它们尽可能回到正确或更近的位置减少后续探测的步数。清理中还有一个 cleanSomeSlots 方法它会做启发式扫描也就是并不会每次都全表扫描而是按 log2(n) 的次数尝试清理失效槽在清理效果和性能之间做平衡。6.4 扩容条件超过阈值先清理再判断当 set 过程中发现 size 达到阈值并不会立刻扩容而是先执行 rehashprivate void rehash() { expungeStaleEntries(); if (size gt; threshold - threshold / 4) resize(); }它会先全表清除失效 Entry。如果清理之后 size 仍然达到阈值的四分之三以上才真正扩容。这个设计体现了一个思想先清理垃圾再考虑扩容。七、引用类型与内存泄漏分析这是 ThreadLocal 面试题中最容易“翻车”的一部分。要讲清楚内存泄漏必须先理解 Java 的四种引用类型。7.1 Java 的四种引用引用类型回收时机强引用只要引用链可达对象就永远不会被回收是最常用的引用方式。软引用内存不足、即将发生 OOM 时才会被回收适合用于实现缓存。弱引用只要发生 GC 就会被回收即使内存还充足只要对象只剩弱引用就可能被回收。虚引用无法通过它拿到对象实例主要用于对象回收前的特殊处理例如堆外内存清理。7.2 为什么 ThreadLocalMap 的 key 要设计成弱引用ThreadLocalMap 的 Entry 继承自 WeakReference因此它的 key也就是 ThreadLocal 对象本身是被弱引用指向的。这样设计的主要目的是当业务代码中不再持有某个 ThreadLocal 对象的强引用时该 ThreadLocal 可以被 GC 正常回收而不会因为每个线程的 ThreadLocalMap 都持有它的强引用而永远无法释放。这是一种“key 可被回收”的设计但它同时也带来了新问题key 被回收后变成 null而 value 仍然是强引用可能没有被及时清理。也就是说弱引用的 key 只是让 ThreadLocal 对象本身可以被回收并不能保证 value 也能立即释放。7.3 内存泄漏的成因先给出一个统一的内存泄漏判断视角一个对象如果不会再被使用却因为某些引用链仍然可达而无法被 GC 回收就形成了内存泄漏。在 ThreadLocalMap 中value 是被 Entry 强引用保存的。当 ThreadLocal 的强引用被置空后如果这个 ThreadLocal 恰好被某个线程的 ThreadLocalMap 引用那么 GC 可以回收 ThreadLocal 对象使 key 变为 null但 value 仍然通过 Entry 被 Thread 对象、ThreadLocalMap、Entry 这条链所引用。只要线程还活着这个 value 就可能一直无法释放这就是常见的内存泄漏场景。不过需要特别说明在 JDK 的 ThreadLocalMap 实现中已经加入了“顺手清理”机制。在 get、set、remove、rehash 等操作时会尽量清理 key 为 null 的失效 Entry。因此正常的读写操作可以在一定程度上缓解泄漏但并不能完全杜绝。如果线程之后再也不调用 get、set、remove失效 Entry 仍然会一直留在那里。7.4 如何避免内存泄漏用完即删在 finally 代码块中调用 remove这是最推荐、最根本的解决方案。使用 static final 修饰 ThreadLocalstatic 修饰后ThreadLocal 的生命周期与类一致可以避免局部 ThreadLocal 被频繁创建又失去引用但 static 也意味着一旦线程池存活数据必须靠 remove 清理。注意线程池场景线程复用会让 value 存留更久务必将 remove 放在 finally 中执行。避免把大对象长期放进 ThreadLocal如果 value 本身很大即使最后能回收也可能对内存产生持续压力。八、InheritableThreadLocal 与父子线程数据传递普通的 ThreadLocal 只能保证同一个线程内部的数据隔离无法实现父子线程之间的数据传递。当主线程创建子线程时希望在子线程里也能拿到主线程设置的上下文例如当前登录用户、traceId 等普通 ThreadLocal 是做不到的。JDK 为此提供了 InheritableThreadLocal。8.1 InheritableThreadLocal 的作用InheritableThreadLocal 继承自 ThreadLocal它在创建子线程时会把父线程 ThreadLocalMap 中的值自动复制到子线程中。这样父子线程之间可以实现“创建时快照式”的数据继承。8.2 实现原理前面提到Thread 对象中有两个字段threadLocals 和 inheritableThreadLocals。InheritableThreadLocal 重写了 getMap 和 createMap 方法让它读写的是 inheritableThreadLocals而不是 threadLocals。更关键的一步发生在 Thread 的 init 方法中if (parent.inheritableThreadLocals ! null) { this.inheritableThreadLocals ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); }创建子线程时JDK 会检查父线程的 inheritableThreadLocals 是否为 null。如果不为 null就基于父线程的数据创建一份新的 map复制给子线程。也就是说它复制的是创建子线程那一刻的父线程数据快照子线程后续对数据的修改不会反过来影响父线程父线程后续的修改也不会同步给已经创建的子线程。8.3 使用示例public class InheritableThreadLocalDemo { private static final InheritableThreadLocalString CONTEXT new InheritableThreadLocal(); public static void main(String[] args) { CONTEXT.set(父线程的数据); Thread child new Thread(() -gt; { System.out.println(子线程读取 CONTEXT.get()); }); child.start(); } }运行后子线程可以读到“父线程的数据”。这就是普通 ThreadLocal 无法实现的效果。8.4 线程池场景的局限InheritableThreadLocal 的复制发生在“创建子线程”时而线程池会复用已经创建好的线程并不会为每个任务重新 new Thread因此后续提交到线程池的任务不会再次触发 init 中的复制逻辑。如果父线程在提交任务后修改了 InheritableThreadLocal线程池里已经复用的线程是看不到新值的。要在线程池中传递上下文通常需要借助阿里 TTLTransmittableThreadLocal、包装 Runnable或在使用线程池时手动设置和清理上下文等方案。九、线程池中的脏数据问题线程池是 ThreadLocal 最容易踩坑的场景之一。因为线程池里的线程会被反复复用同一个 Thread 对象会依次执行多个任务。如果上一个任务给 ThreadLocal 设置了值任务结束后又没有清理下一个复用该线程的任务就可能读到上一个任务留下的“脏数据”。9.1 脏数据是如何产生的线程池中的 worker 线程 T 执行任务 A任务 A 调用了 context.set(userA)。任务 A 执行结束后没有调用 remove于是 T 的 ThreadLocalMap 中仍然保存着 userA。线程 T 被复用来执行任务 B任务 B 在设置自己的值之前就调用了 context.get()结果读到的是 userA造成数据污染。这种问题在系统比较空闲、任务串行执行时尤其容易碰巧发生因此排查起来比较隐蔽。9.2 解决方案finally 中 remove最直接的解决方案是把清理动作放到 finally 中确保无论任务正常结束还是抛出异常都能清空当前线程的 ThreadLocalpublic void handle() { try { TraceIdHolder.set(requestId); // 业务处理 } finally { TraceIdHolder.clear(); } }如果上下文需要跨线程传递还需要结合上一节的 InheritableThreadLocal 或 TTL 等方案并在任务执行前设置、执行后清理。十、Netty 的 FastThreadLocal 优化Netty 作为高性能网络通信框架在很多地方都会用到线程本地变量。JDK 原生的 ThreadLocal 在 Netty 的高并发场景下存在性能损耗因此 Netty 自己实现了一套 FastThreadLocal。10.1 FastThreadLocal 解决了什么问题JDK ThreadLocal 的性能开销主要来自 ThreadLocalMap 基于哈希表的查找。虽然它已经针对线性探测做了优化但在高频访问场景下仍然存在哈希计算、冲突探测以及定期清理带来的额外成本。FastThreadLocal 的核心理念是“用数组加固定下标”代替哈希查找。每个 FastThreadLocal 在创建时分配一个唯一的 index数据直接存储在 FastThreadLocalThread 内部的数组中通过下标以 O(1) 方式直接访问省去哈希计算和冲突探测过程性能更好。10.2 基本使用方式使用 FastThreadLocal 时线程必须是 Netty 提供的 FastThreadLocalThread或者通过 FastThreadLocalRunnable 包装任务public class FastThreadLocalDemo { private static final FastThreadLocalString HOLDER new FastThreadLocal(); public static void main(String[] args) { Thread thread new FastThreadLocalThread(() -gt; { HOLDER.set(Netty 数据); System.out.println(HOLDER.get()); }, netty-thread); thread.start(); } }当然它也需要在使用后调用 remove否则同样可能带来脏数据或数据滞留问题。十一、高频面试题汇总最后把全文涉及的高频问题集中整理一下方便面试前快速回顾ThreadLocal 的作用是什么为每个线程提供独立的变量副本避免共享变量带来的线程安全问题。ThreadLocal 的数据存在哪里存放在当前 Thread 对象持有的 ThreadLocalMap 中ThreadLocal 只是作为 key。ThreadLocalMap 如何解决哈希冲突使用开放地址法中的线性探测而不是链表或红黑树。为什么 key 使用弱引用让 ThreadLocal 对象在外部不再被强引用时可以被 GC 回收避免 key 本身无法释放。ThreadLocal 会造成内存泄漏吗如果 value 是强引用且线程长期存活又没有调用 remove就可能造成 value 无法回收形成内存泄漏。如何避免内存泄漏和脏数据在 finally 中调用 remove并及时清理线程本地变量。InheritableThreadLocal 有什么用可以在创建子线程时把父线程的上下文数据复制到子线程。线程池中使用 ThreadLocal 要注意什么线程会被复用任务结束必须清理否则会产生脏数据。Netty 为什么重新实现 FastThreadLocal通过数组加固定下标代替哈希查找降低高并发场景下的访问开销。十二、总结ThreadLocal 的设计并不复杂但真正吃透它需要把 Thread、ThreadLocalMap、Entry、弱引用以及线程池这些知识点串成一个整体。回到开头的面试题它的核心就是为了实现线程隔离数据最终存在 Thread 持有的 ThreadLocalMap 中key 使用弱引用是为了让 ThreadLocal 本身可以被回收但也因此带来了 value 滞留的风险只要坚持在 finally 中调用 remove就能避免大部分内存泄漏和脏数据问题。至于 InheritableThreadLocal 和 FastThreadLocal则是在父子线程传递和高性能场景下对基础模型的补充与优化。理解一套实现最好的方式仍然是回到源码里走一遍 get、set、remove 和 rehash 的调用链。把原理和场景对应起来面试和实际开发都会更有底气。
返回列表