ARTICLE DETAIL

资讯详情

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

拆解ThreadLocal性能困境:数组索引方案设计与实践

拆解ThreadLocal性能困境:数组索引方案设计与实践 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 超出容量就扩容到比 index1 大的下一个 2 的幂。为什么不直接复用 ThreadLocal 里的 threadLocalHashCode因为那个值是用来做散列的不适合当数组下标。散列值可能很大也不唯一无法直接使用。而发号器保证连续且唯一刚好可以映射到数组下标。这个设计牺牲了一点启动时分配成本换来了每次读写时 O(1) 的确定性消耗。2.3 它解决的只是“单线程内访问”不是所有 ThreadLocal 问题虽然这套方案在读写速度上优于 ThreadLocalMap但存在前提你得先从某个地方拿到“这个线程对应的数组”。为了做到这一点最简单的办法是给线程加一个字段。但 JDK 的 Thread 类字段是私有的普通线程并不开放所以一般做法是提供自定义 Thread 子类或者用一个包装接口让线程池创建线程时使用这种线程。如果线程本身不支持还得兜底退化成 ThreadLocal 或抛异常。另外它无法解决跨线程传递。ThreadLocal 的子类 InheritableThreadLocal 能在创建子线程时继承值而数组索引方案的数组是全新的天然不会有父线程的数据。至于线程池中任务切换到另一线程两者都无能为力需要额外的上下文传递组件去处理。所以“吊打”是有边界的针对的是“同一个线程内反复读写有限的几个变量”这个场景而不是所有并发场景。3. 手写一个可跑的极简高性能本地线程变量3.1 先给线程增加变量槽数组要零散列访问先要有承载数组的线程。先定义接口 FastThreadLocalAccesspublic 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 FastThreadLocalV { 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; ThreadLocalInteger tl new ThreadLocal(); FastThreadLocalInteger 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 对象无法直接加字段你得先改线程工厂。很多框架内部线程池是私有的不方便替换。这种情况下你可以退而求其次用 ThreadLocalObject[] 当作线程数组的 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 一堆变量那样会把别的板块设好的数据也清掉。比如FastThreadLocalString context ...; context.set(requestId); try { // do work } finally { context.remove(); }如果你同时用了多个 FastThreadLocal可以封装成一个上下文对象统一清理减少遗漏。这个习惯和 ThreadLocal 完全一致只是 ThreadLocal 的问题往往因为低频访问而被忽视高频替换后如果不养成 remove 习惯问题会被放大。4.4 常见问题速查表问题可能原因解决办法调用 get 抛 IllegalStateException当前线程不是 FastThreadLocalAccess 子类线程工厂中统一使用自定义线程子线程或异步任务读不到值数组索引方案没有父子继承显式传参或使用包装 Runnable 迁移数据池化线程再次执行任务时读到旧值任务结束后没有 removefinally 中调用 remove只清本任务设置过的变量热部署后数组越来越大动态 new FastThreadLocal 实例占用新的 index用静态字段持有变量杜绝每请求创建value 为 null 时 get 行为异常槽位 null 被当成未初始化initialValue 返回非 null或避免存储 nullset 后又 get 仍为空线程数组被替换或 remove 掉了检查是否有其他代码调用 remove 或持有错误数组4.5 虚拟线程出现后还需要 ThreadLocal 吗如果你已经开始用虚拟线程会发现大量虚拟线程意味着大量线程对象ThreadLocal 的内存占用和生命周期问题会被进一步放大。Java 社区这几年也在推动更轻量的上下文传递方式比如把值作为参数显式下传或者使用不可变局部绑定。数组索引方案依赖线程对象字段虚拟线程如果支持这种访问方式性能优势仍在但不同实现环境对线程字段的处理不一样不能指望所有地方都按同一套来。我的建议是如果你的主要场景是虚拟线程优先考虑上下文的显式传递而不是继续堆 ThreadLocal 或类 ThreadLocal 工具。等真正需要线程本地变量再按具体实现框架的特点选择。最后分享一个我个人的习惯我不会把项目里所有 ThreadLocal 都换掉因为替换本身有侵入成本而且大部分业务接口对 ThreadLocal 的访问频率远没有到瓶颈。只有在明确的热点链路上并且能控制线程创建和变量清理的前提下我才会用数组索引方案。改动时也从一两个变量开始压测、观察内存再逐步铺开。真正常用的不是“吊打”这个词而是对每次读写成本的敏感度。你下次写并发代码前不妨先问自己这个变量真的需要绑定线程吗如果只是在一个请求里传递状态传参也许才是最便宜的方式。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表