1. 先说结论:ThreadLocal 没那么神,真正快的是数组索引方案
最近我把网关里一组高频读写的 ThreadLocal 变量换成了数组索引型本地线程变量,单线程内 set/get 混合压测下来大概快了 2 到 3 倍,接口整体延时也降了一截。这不是什么黑魔法,原理很简单:ThreadLocal 内部靠“散列 + 线性探测”找变量,而数组索引方案直接靠下标定位变量。这篇文章我会拆开 ThreadLocal 的查询链路,然后给出一个可以运行的最小实现,最后聊聊哪些场景适合替换,哪些场景别乱动。如果你正在用 ThreadLocal 存用户上下文、链路信息,或者你在写中间件,建议看完再决定要不要上这套优化。
1.1 我为什么动了换掉它的念头
ThreadLocal 用起来确实舒服,每个线程只需 get/set,不用传参。但我们的网关里每个请求要读五六个上下文变量,比如请求 ID、用户 ID、环境标识、租户信息,每个组件各定义各的 ThreadLocal。最近压测时用性能分析工具看热点,发现 ThreadLocal.get 相关方法在调用栈上出现得很频繁,单看每个方法不大,但累加起来量很可观。进一步看实现才发现,它取一个变量不是“拿一下”那么简单,中间要经过 ThreadLocalMap 的散列定位、冲突探测,甚至可能触发扩容。对于高吞吐、方法调用密集的链路,这就是不必要的开销。
同时我们也遇到过一个内存问题:某业务线程池里的线程长期存活,业务代码只 set 不 remove,导致一批上下文对象迟迟释放不掉,最后只能靠重启缓解。ThreadLocal 的便利背后是有代价的,它把隔离性做到了,但把查找成本和生命周期成本都留给了使用者。后来我决定把核心链路上几个最常见的 ThreadLocal 换掉,才有了这篇文章的实践。
1.2 ThreadLocalMap 的查找策略:开放地址法,越用越慢
ThreadLocal 的数据结构常被忽略。每个 Thread 内部有一个 ThreadLocalMap,它是一个自定义的哈希表,用 Entry 数组存储。Entry 的 key 是 ThreadLocal 实例本身,value 是你塞进去的对象。插入和查找时,会对 key 的 threadLocalHashCode 做一次与运算得到桶位:index = key.threadLocalHashCode & (table.length - 1)。
如果这个位置已经被别的 Entry 占住,它不会像 HashMap 那样用链表,而是继续往下探测下一个位置,也就是线性探测。一旦变量数量接近容量阈值,冲突概率上升,set 和 get 都可能在探测循环里多跑几步。虽然 JDK 给 ThreadLocal 设计了一个很特别的哈希增量 0x61c88647,能尽量把不同实例的桶位分散开,但“分散”不等于“无冲突”,尤其当项目里 ThreadLocal 实例特别多时,查找开销会逐渐上涨。
另一个问题:ThreadLocalMap 在 set 时会检测并清理探测路径上那些 key 已被回收的 Entry,但这只在调用 set 的时候发生,而且清理也只是某一段路径。如果你的业务线程很少调用 set,获取值以 get 为主,一些废弃 Entry 就会长期存留。还有扩容,当 Entry 数量超过阈值,ThreadLocalMap 会重建数组并重新 rehash,这是偶发但代价很高的操作。所以说 ThreadLocal 每次 get 都是“散列 + 探测 + 类型检查”,和直接访问普通字段完全不是一个量级。
1.3 弱引用 key 也救不了 value 泄漏
ThreadLocalMap 的 Entry 继承自 WeakReference,这在设计上是想解决“ThreadLocal 对象被回收”的问题。假设你在一个方法里 new 了一个 ThreadLocal,用完之后局部变量消失,此时 Entry 的 key 因为弱引用可以被回收,但 value 仍然被 Entry 强引用着。只要这个线程还活着,value 就清理不掉。
为什么说它是泄漏?因为它占用堆内存且无法通过 GC 自动清理,除非之后有其他 set 或 get 操作恰好清理到该槽位。在池化的线程中,线程生命周期很长,如果业务不断创建 ThreadLocal 对象并设置 value 而忘记 remove,这就是一个长期累积的坑。网上很多资料把问题全赖在弱引用上,其实弱引用已经把 key 的问题解决了大半,真正的风险是 value 强引用。
JDK 也提供了 remove 方法来帮助处理这个问题。但业务代码很少会记得在 finally 里 remove。这一点是所有“线程绑定的变量”的共同难题,不是换成数组索引方案就能自动消失的。下面要讲的优化方案,解决的是“更快”的问题,而“及时清理”依然要靠代码纪律。
2. 拆解“吊打”背后的设计:数组下标直取
2.1 从“搜抽屉”到“按号取件”
在单线程内,每个变量如果能够有一个固定编号,线程又有一个“抽屉数组”,那么 set 和 get 就变成:
Object[] variables = threadVariables; variables[index] = value; // set Object value = variables[index]; // get这个操作没有任何散列计算,也没有循环探测。数组只需要一次内存访问,甚至 JIT 会优化边界检查,再加一次强转就够了。这个数组相当于一个“按号取件”的柜子,而 ThreadLocal 则是一个需要自己根据号码找位置的仓库。柜子取件肯定更快,但前提是你要提前分好号码。
这种思路其实不算新鲜,很多高性能框架的本地线程变量就是按这个思路实现的。它通常叫 FastThreadLocal 之类的名字,实现细节各有差异。我这里不点名具体某个框架,我们按最朴素的思路自己做一个,原理完全一样。了解了原理,看什么开源实现都不再是黑盒。
2.2 下标分配:每个变量都有唯一索引
关键问题是“固定编号”从哪来。最简单可靠的办法是用一个静态的 AtomicInteger 作为全局发号器。每个 FastThreadLocal 实例在初始化时做一次 int index = NEXT_INDEX.getAndIncrement(),这个实例就永久占用该编号。所有线程里,这个实例都放在数组的同一位置。
比如第一个变量拿到 0,第二个拿到 1,以此类推。因为 FastThreadLocal 通常作为类的静态字段使用,数量级也就几十个,所以发号器递增不会太大。每个线程的数组按需扩容,数组长度取 2 的幂,可以简单实现为:访问时如果 index 超出容量,就扩容到比 index+1 大的下一个 2 的幂。
为什么不直接复用 ThreadLocal 里的 threadLocalHashCode?因为那个值是用来做散列的,不适合当数组下标。散列值可能很大,也不唯一,无法直接使用。而发号器保证连续且唯一,刚好可以映射到数组下标。这个设计牺牲了一点启动时分配成本,换来了每次读写时 O(1) 的确定性消耗。
2.3 它解决的只是“单线程内访问”,不是所有 ThreadLocal 问题
虽然这套方案在读写速度上优于 ThreadLocalMap,但存在前提:你得先从某个地方拿到“这个线程对应的数组”。为了做到这一点,最简单的办法是给线程加一个字段。但 JDK 的 Thread 类字段是私有的,普通线程并不开放,所以一般做法是提供自定义 Thread 子类,或者用一个包装接口,让线程池创建线程时使用这种线程。如果线程本身不支持,还得兜底退化成 ThreadLocal 或抛异常。
另外,它无法解决跨线程传递。ThreadLocal 的子类 InheritableThreadLocal 能在创建子线程时继承值,而数组索引方案的数组是全新的,天然不会有父线程的数据。至于线程池中任务切换到另一线程,两者都无能为力,需要额外的上下文传递组件去处理。所以“吊打”是有边界的,针对的是“同一个线程内反复读写有限的几个变量”这个场景,而不是所有并发场景。
3. 手写一个可跑的极简高性能本地线程变量
3.1 先给线程增加变量槽数组
要零散列访问,先要有承载数组的线程。先定义接口 FastThreadLocalAccess:
public interface FastThreadLocalAccess { Object[] getIndexedVariables(); void setIndexedVariables(Object[] indexedVariables); }接着写一个默认线程子类 MyFastThread extends Thread implements FastThreadLocalAccess,内部用 Object[] 保存变量,提供两个方法实现。注意覆盖 run 方法时,这些字段自然成为当前执行的线程字段。自定义线程工厂创建线程时,只需要 new MyFastThread(runnable) 即可。
public class MyFastThread extends Thread implements FastThreadLocalAccess { private Object[] indexedVariables; public MyFastThread(Runnable target) { super(target); } @Override public Object[] getIndexedVariables() { return indexedVariables; } @Override public void setIndexedVariables(Object[] indexedVariables) { this.indexedVariables = indexedVariables; } }生产上你还要考虑线程名、守护线程、异常处理器等,这里只关注核心字段。
3.2 FastThreadLocal 核心类:get/set/remove
现在实现主类:
import java.util.Arrays; import java.util.concurrent.atomic.AtomicInteger; public class FastThreadLocal<V> { private static final AtomicInteger NEXT_INDEX = new AtomicInteger(0); private static final int INITIAL_SIZE = 16; private final int index = NEXT_INDEX.getAndIncrement(); public V get() { Object[] variables = threadVariables(); if (variables != null && index < variables.length) { Object value = variables[index]; if (value != null) { return (V) value; } } return initialize(); } public void set(V value) { Object[] variables = threadVariables(); if (variables == null) { variables = new Object[Math.max(INITIAL_SIZE, expandedSize(index))]; } else if (index >= variables.length) { variables = Arrays.copyOf(variables, expandedSize(index)); } ((FastThreadLocalAccess) Thread.currentThread()).setIndexedVariables(variables); variables[index] = value; } public void remove() { Object[] variables = threadVariables(); if (variables != null && index < variables.length) { variables[index] = null; } } protected V initialValue() { return null; } private V initialize() { Object[] variables = threadVariables(); if (variables == null) { variables = new Object[Math.max(INITIAL_SIZE, expandedSize(index))]; } else if (index >= variables.length) { variables = Arrays.copyOf(variables, expandedSize(index)); } ((FastThreadLocalAccess) Thread.currentThread()).setIndexedVariables(variables); V value = initialValue(); variables[index] = value; return value; } private static Object[] threadVariables() { Thread thread = Thread.currentThread(); if (thread instanceof FastThreadLocalAccess) { return ((FastThreadLocalAccess) thread).getIndexedVariables(); } throw new IllegalStateException("当前线程不支持 FastThreadLocalAccess,请使用 MyFastThread 或自定义线程工厂"); } private static int expandedSize(int targetIndex) { int cap = INITIAL_SIZE; while (cap <= targetIndex) { cap <<= 1; } return cap; } }这个类基本能跑了。get 方法有一个语义需要注意:如果槽位里的值是 null,会被当成“未初始化”,再次调用 initialValue。这和 ThreadLocal 的行为一致,所以设计上要么让 initialValue 返回非 null,要么避免向变量写入 null。set 传 null 同样会导致后续 get 重新初始化。
还要提醒,这段代码为了演示而缩略,生产环境需要考虑 FastThreadLocal 对象本身的垃圾回收。上面代码中,静态 NEXT_INDEX 只发号,但每个 FastThreadLocal 实例并不会被自动回收,因为它的 index 绑定在一个数组槽位上;如果类加载器被卸载,这些槽位仍然需要清理。如果你的项目用热部署,动态创建了大量 FastThreadLocal 实例,建议用专门的清理机制或升级版方案,不要直接套这个极简版。
3.3 初始化时机与数组扩容
ThreadLocal 的 initialValue 在第一次 get 时调用。我们的 initialValue 同样在 get 且槽位为空时调用。这里有个细节:如果 get 首次调用时线程的数组还没创建,要先建数组,再检查是否需要扩容,然后调用 initialValue 并放入数组。如果 set 先调用,也类似,只是调用方传入 value 而不调 initialValue。
扩容处理需要注意:因为数组是每线程各自持有的,扩容只影响当前线程。第二次扩容后底层数组换了一个新数组,引用会通过 setIndexedVariables 回写。其他线程不受影响,这个设计下不存在并发修改同一个数组的问题,所以不需要加锁。
数组初始容量为什么选 16 而不是更小?考虑到每个线程通常会有多个 FastThreadLocal 变量,16 可以容纳大多数场景,避免频繁扩容。如果你们的变量数少于 8,可以改成 8。老实说,扩容成本不高,就是一次数组拷贝,但出现得越少越好。
3.4 写个简单压测对比一下
不依赖第三方环境,直接用 nanoTime 循环测。注意必须用 MyFastThread 线程来跑,否则 FastThreadLocal 会抛异常。测试代码如下:
public class BenchmarkRun extends MyFastThread { @Override public void run() { int warmup = 1_000_000; int loop = 10_000_000; ThreadLocal<Integer> tl = new ThreadLocal<>(); FastThreadLocal<Integer> ftl = new FastThreadLocal<>(); for (int i = 0; i < warmup; i++) { tl.set(i); int v = tl.get(); ftl.set(i); int w = ftl.get(); } long start = System.nanoTime(); for (int i = 0; i < loop; i++) { tl.set(i); int v = tl.get(); } long threadLocalTime = System.nanoTime() - start; start = System.nanoTime(); for (int i = 0; i < loop; i++) { ftl.set(i); int v = ftl.get(); } long fastThreadLocalTime = System.nanoTime() - start; System.out.println("ThreadLocal: " + threadLocalTime / 1_000_000 + " ms"); System.out.println("FastThreadLocal: " + fastThreadLocalTime / 1_000_000 + " ms"); } public static void main(String[] args) throws Exception { new BenchmarkRun().start(); } }实际跑的时候,不同机器差异很大,但单变量 set/get 混合,FastThreadLocal 通常比 ThreadLocal 快 2 到 5 倍。注意压测有个陷阱:如果只测读取已存在的变量,JIT 可能把整个循环优化掉。我一般把 int value 累加到一个外部容器,或者在每次循环里故意消费掉结果,避免死代码消除。上面代码里的 int v 和 int w 虽然没用到,理论上 JIT 也可能消除,更严谨的做法是累加后打印。自己跑的时候要加一个黑盒,比如把 v 累加到 volatile 字段,或者直接累计到一个对象。
4. 实际替换的收益、边界和踩坑
4.1 我实测过的收益:快是快,但别被“吊打”带偏
我在网关的某个核心链路上,把六个上下文变量从 ThreadLocal 换成数组索引方案后,压测 QPS 提升了差不多 20% 到 30%。注意这个提升来自“高频访问 + 多变量”场景。如果业务接口一次请求只读取一个 ThreadLocal,那么优化几乎感知不到。你需要访问的变量数越多、频率越高,收益就越明显。
另外,收益也要考虑现有线程是否支持。如果项目里所有线程都是普通 Thread 对象,无法直接加字段,你得先改线程工厂。很多框架内部线程池是私有的,不方便替换。这种情况下,你可以退而求其次:用 ThreadLocal<Object[]> 当作线程数组的 holder,首次获取数组时仍走一次 ThreadLocalMap,但后续在这个线程里访问多个 FastThreadLocal 变量时,都复用同一个数组引用,省掉了多次散列探测。这种半替代方案虽然不如自定义线程干净,但兼容性更好,侵入性也更小。我建议从这种半替代方案开始评估收益,再决定要不要动线程工厂。
4.2 替换时最容易翻车的三个场景
第一个是线程池。数组索引方案要求每个访问线程都能拿出数组。线程池里的线程如果还是普通 Thread,那么 get 会抛“不支持”异常。不要用 try-catch 掩盖,应该从根源上统一线程工厂。你可以包装一层:创建线程时使用 MyFastThread,或者在线程真正执行目标任务前把 Thread 换成支持接口的包装类。
第二个是继承和传递。InheritableThreadLocal 的父子传递能力在数组索引方案里完全没有。如果某个请求先在这个线程设置了一个变量,然后交给另一个线程处理,新线程中数组是空的,读出来必然是 null。解决思路很简单:在执行子任务前,把上下文值作为参数显式传递,或者用包装 Runnable 做变量迁移。如果你不想全链路改传参,建议保留 ThreadLocal 或引入专门上下文传递工具,不要强行替换全部。
第三个是动态类加载。因为 index 是静态发号器分配的,每 new 一个 FastThreadLocal 实例,全局编号就加一。如果你在热部署环境里反复加载类,会不断产生新的 FastThreadLocal 实例和新的 index,旧实例又不回收,每个线程数组里都占着一个槽位。时间久了数组会越来越大,相当于慢性的内存泄漏。所以这类变量应该做成类级别的静态字段,而不是每实例都 new。
4.3 remove 的时机:数组索引方案同样需要 finally
数组索引方案在 get 和 set 上很快,但它不负责自动回收值。线程数组是一个强引用容器,只要数组槽位非空,对象就不会被 GC 回收。在线程池场景里,任务执行完如果不 remove,后续线程复用时会一直带着上一个任务的变量,既泄漏内存,又可能在下一次任务里读到脏数据。
安全写法是:在 try-finally 里释放,且只释放每个任务开始时实际写入的变量。不要无条件 remove 一堆变量,那样会把别的板块设好的数据也清掉。比如:
FastThreadLocal<String> context = ...; context.set(requestId); try { // do work } finally { context.remove(); }如果你同时用了多个 FastThreadLocal,可以封装成一个上下文对象,统一清理,减少遗漏。这个习惯和 ThreadLocal 完全一致,只是 ThreadLocal 的问题往往因为低频访问而被忽视,高频替换后如果不养成 remove 习惯,问题会被放大。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 调用 get 抛 IllegalStateException | 当前线程不是 FastThreadLocalAccess 子类 | 线程工厂中统一使用自定义线程 |
| 子线程或异步任务读不到值 | 数组索引方案没有父子继承 | 显式传参,或使用包装 Runnable 迁移数据 |
| 池化线程再次执行任务时读到旧值 | 任务结束后没有 remove | finally 中调用 remove,只清本任务设置过的变量 |
| 热部署后数组越来越大 | 动态 new FastThreadLocal 实例占用新的 index | 用静态字段持有变量,杜绝每请求创建 |
| value 为 null 时 get 行为异常 | 槽位 null 被当成未初始化 | initialValue 返回非 null,或避免存储 null |
| set 后又 get 仍为空 | 线程数组被替换或 remove 掉了 | 检查是否有其他代码调用 remove 或持有错误数组 |
4.5 虚拟线程出现后,还需要 ThreadLocal 吗
如果你已经开始用虚拟线程,会发现大量虚拟线程意味着大量线程对象,ThreadLocal 的内存占用和生命周期问题会被进一步放大。Java 社区这几年也在推动更轻量的上下文传递方式,比如把值作为参数显式下传,或者使用不可变局部绑定。数组索引方案依赖线程对象字段,虚拟线程如果支持这种访问方式,性能优势仍在,但不同实现环境对线程字段的处理不一样,不能指望所有地方都按同一套来。我的建议是:如果你的主要场景是虚拟线程,优先考虑上下文的显式传递,而不是继续堆 ThreadLocal 或类 ThreadLocal 工具。等真正需要线程本地变量,再按具体实现框架的特点选择。
最后分享一个我个人的习惯:我不会把项目里所有 ThreadLocal 都换掉,因为替换本身有侵入成本,而且大部分业务接口对 ThreadLocal 的访问频率远没有到瓶颈。只有在明确的热点链路上,并且能控制线程创建和变量清理的前提下,我才会用数组索引方案。改动时也从一两个变量开始,压测、观察内存,再逐步铺开。真正常用的不是“吊打”这个词,而是对每次读写成本的敏感度。你下次写并发代码前,不妨先问自己:这个变量真的需要绑定线程吗?如果只是在一个请求里传递状态,传参也许才是最便宜的方式。