09 · ThreadLocal、伪共享与 Disruptor
对应学习计划:第一月第2周「Java 并发编程(重中之重!薪资分水岭)」
面试定位:这篇是「拉开差距」篇------ThreadLocal 烂大街必考,伪共享 @Contended 和 Disruptor 是简历亮点素材,普通人答不上来,你能讲透就是加分项。
一、本文能帮你回答的面试题
- ThreadLocal 是什么?实现原理(ThreadLocalMap)?
- ThreadLocal 为什么会有内存泄漏?怎么解决?
- 线程池里用 ThreadLocal 有什么坑?
- ThreadLocal 的 key 为什么要用弱引用?
- 什么是伪共享?为什么性能杀手?怎么解决?
@Contended注解是什么?什么 JDK 版本默认限制?- Disruptor 是什么?和无锁队列什么关系?
二、概念介绍
| 技术 | 一句话 | 亮点程度 |
|---|---|---|
| ThreadLocal | 线程私有变量,每个线程一份拷贝 | 必考,人人会 |
| 伪共享 | CPU 缓存行失效导致的「假竞争」 | 面试分水岭 |
| Disruptor | 无锁环形队列,LMAX 交易的超高吞吐队列 | 简历亮点 |
ThreadLocal 适用场景:JDBC 连接管理(一个线程一个连接)、Spring 的 RequestContext(一次请求的上下文贯穿 Controller→Service)、SimpleDateFormat 线程安全包装、会话上下文透传。
三、原理深入
3.1 ThreadLocal 底层结构(必考)
Thread
└── ThreadLocalMap threadLocals ← 每个线程一份
└── Entry[] table(数组,非链表!开放寻址法)
├── Entry(key=ThreadLocal弱引用, value=Object)
├── Entry(...)
└── ...
- 不是 ThreadLocal 持有值,是「线程持有 ThreadLocalMap」。ThreadLocal 只是 key。
- 内部用开放寻址法(数组线性探测) 而非链地址法(HashMap 那种)。
- 赋值流程:
set(v)→ 当前线程的 threadLocals map →map.set(this, v)。 - 取值流程:
get()→ 当前线程 map →getEntry(this)。
3.2 为什么用弱引用?内存泄漏怎么来?
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // value 是强引用!
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 走弱引用
value = v;
}
}
设计意图 :ThreadLocal 对象(key)在业务上经常是一次性的(用完丢掉)。若 key 是强引用,ThreadLocal 永远无法被 GC。用弱引用:当外部不再持有 ThreadLocal 时,key 自动变 null。
泄漏场景(重点):
- 线程存活时间长(如线程池线程常驻)。
- key 被 GC 置空,但
Entry.value仍被 Entry 强引用 → value 永远释放不了。 - 累积 →
ThreadLocalMap越来越大 → 内存泄漏。
解决:
- 用完调用
remove()(关键!)。 - ThreadLocalMap 在
set/get时会顺带清理被清空的 Entry(惰性清理)。
3.3 线程池 + ThreadLocal 的大坑
java
// 线程池线程复用 → ThreadLocal 被「继承」!
// 线程A:set(用户ID=1001) → 处理完没 remove
// 线程A 被复用来处理线程B的任务 → 读到 1001 → 串号!严重的越权事故
原因 :线程池的线程是复用的,ThreadLocalMap 属于线程,不随任务销毁。
解决 :每个任务 finally 里 remove();或用 TransmittableThreadLocal(阿里,ThreadLocal 增强,支持线程池值传递)。
3.4 伪共享(False Sharing)(亮点)
背景 :CPU 缓存行(Cache Line)通常 64 字节。多个变量如果落在同一条缓存行上:
线程1 修改 变量A(缓存行1) → 整个缓存行1 标记为失效
线程2 读/写 变量B(也在缓存行1)→ 缓存未命中 → 重新从主存加载
→ A 和 B 本来互不相干,却互相拖累 → 「伪共享」
场景:多线程各写各的计数变量,若这些变量恰好连续分布在同一个缓存行,性能骤降数量级。
解决手段(三选一):
-
填充(padding):变量前后各塞 7 个 long 占满 64 字节缓存行(JDK7 手写)。
-
@Contended注解 (JDK8 自带):sun.misc.Contended,JVM 自动填充。但在 JDK 8 默认受限 ,应用启动要加:-XX:-RestrictContended -
用现成的
LongAdder/Striped64内部的 Cell 数组(也是 padding 思想)。
java
// 经典填充方式(左对齐,凑满 64 字节)
public final static class PaddedLong {
public volatile long value;
public long p1, p2, p3, p4, p5, p6, p7; // 填充物
}
// 或 JDK8:
@sun.misc.Contended
public final static class ContendedLong {
public volatile long value;
}
3.5 Disruptor(终极亮点,讲 3 分钟)
是什么 :LMAX(伦敦交易所撮合)开源的低延迟、高吞吐无锁队列框架。核心:环形数组(RingBuffer)+ 序号(Sequence)+ 无锁(CAS/屏障)。
为什么快(对比 LinkedBlockingQueue):
| 对比 | BlockingQueue | Disruptor |
|---|---|---|
| 存储 | 链表节点(内存不连续) | 环形数组(预分配,内存连续,缓存友好) |
| 锁 | 锁 + 条件队列 | 无锁(CAS 序号),生产者消费者各维护序号 |
| 分配 | 每个元素 new 对象(GC 压力) | 预分配对象槽位,反复使用(零 GC) |
| 批量 | 逐个消费 | 支持批量取序范围内的数据 |
经典骨架(看结构就行,别背死代码):
java
// 1. 定义事件(要处理的消息类型)
class OrderEvent { long orderId; }
// 2. 定义事件工厂(RingBuffer 预分配对象用)
EventFactory<OrderEvent> factory = OrderEvent::new;
// 3. 定义处理器(消费者逻辑)
EventHandler<OrderEvent> handler = (event, seq, endOfBatch) -> {
System.out.println("消费订单: " + event.orderId);
};
// 4. 装配
Disruptor<OrderEvent> disruptor = new Disruptor<>(
factory, 1024, Executors.defaultThreadFactory(),
ProducerType.MULTI, new BlockingWaitStrategy());
disruptor.handleEventsWith(handler);
RingBuffer<OrderEvent> ringBuffer = disruptor.start();
// 5. 生产(先取槽位,再发布)
long seq = ringBuffer.next();
try {
OrderEvent e = ringBuffer.get(seq);
e.orderId = 10001;
} finally {
ringBuffer.publish(seq);
}
什么时候用它 :真正的极致低延迟(金融撮合、游戏服务器、日志高性能采集)才需要;普通业务 Redis/MQ 就够了。简历怎么用:在电商项目中「用 Disruptor 做订单事件的无锁传递」(让商品秒杀/订单流转的事件走轻量环形队列),配上压测数字,就是普通人没有的亮点。
四、实战例子
实战 1:ThreadLocal 正确使用模板(含 remove)
java
public class ThreadLocalDemo {
// 线程安全的 SimpleDateFormat
private static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 请求上下文透传(每次请求一个 UUID)
private static final ThreadLocal<String> REQ_ID = new ThreadLocal<>();
public static void main(String[] args) {
Runnable task = () -> {
try {
REQ_ID.set("req-" + Thread.currentThread().getId());
FMT.get().format(new java.util.Date());
System.out.println(REQ_ID.get() + " 处理中");
} finally {
REQ_ID.remove(); // ★ 必须 remove,尤其线程池环境
}
};
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
for (int i = 0; i < 4; i++) pool.execute(task);
pool.shutdown();
}
}
实战 2:复现内存泄漏(检验 remove 的必要性)
java
// -Xmx64m 跑:
// 任务里不断 set 大对象但从不 remove → 线程池线程常驻 → OOM
public class LeakDemo {
static final ThreadLocal<byte[]> LOCAL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
var pool = java.util.concurrent.Executors.newFixedThreadPool(4);
for (int i = 0; i < 1_000_000; i++) {
pool.execute(() -> LOCAL.set(new byte[1024 * 1024])); // 泄漏
Thread.sleep(1);
}
pool.shutdown();
}
// 结论:GC 后 Metaspace/堆 持续增长,直到 OOM
// 修复:finally { LOCAL.remove(); }
}
实战 3:伪共享压测 Demo(用于简历压测数据)
java
import java.util.concurrent.CountDownLatch;
public class FalseSharingDemo {
// 直接对比:4 个线程各自写一个 long
static volatile long[] noPadding = new long[4];
// 填充版:每个 long 独占缓存行
@sun.misc.Contended static volatile long[] padded = new long[4];
public static void main(String[] args) throws InterruptedException {
System.out.println("无填充耗时: " + run(noPadding) + " ms");
System.out.println("@Contended填充耗时: " + run(padded) + " ms");
// 典型结果:无填充 6000ms vs 填充 800ms(数倍差距)
// 注意:Contended 默认只对 JDK 内部类生效,自己类要加 -XX:-RestrictContended
}
static long run(long[] arr) throws InterruptedException {
int n = 4, loops = 100_000_000;
CountDownLatch latch = new CountDownLatch(n);
long start = System.nanoTime();
for (int i = 0; i < n; i++) {
final int idx = i;
new Thread(() -> {
long v = 0;
for (int j = 0; j < loops; j++) arr[idx] = ++v;
latch.countDown();
}).start();
}
latch.await();
return (System.nanoTime() - start) / 1_000_000;
}
}
五、面试话术(背下来)
Q:ThreadLocal 内存泄漏原理?
「每个线程有个 ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。当 ThreadLocal 外部引用没了,key 变 null,但 value 还被 Entry 强引用着;如果线程存活久(线程池线程),value 就一直不释放,造成泄漏。解决:及时 remove,Map 的 set/get 也会惰性清理。线程复用场景务必 remove 防止串号。」
Q:伪共享是什么?怎么解决?
「CPU 一缓存行 64 字节,多个变量挤在一条缓存行,某个线程改了其中一个,整个缓存行失效,其他线程访问同行的无关变量也得重新走主存,退化为串行。解决:缓存行填充(前后塞占位 long)或 @Contended,或用 LongAdder 这类内置 padding 的类。」
Q:Disruptor 为什么快?
「三点:环形数组预分配连续内存,缓存命中率高、零 GC;生产者消费者只维护序号,用 CAS 无锁替代锁;支持批量消费。把『锁竞争』变成了『序号递增』的原子操作。」
Q:线程池里 ThreadLocal 串号案例?
「有过线上事故:在线程池任务里 set 了用户信息没 remove,线程被复用后下个任务读到上一个用户的 ID,出现越权。从此约定:ThreadLocal 一定在 finally 里 remove,或者用阿里的 TransmittableThreadLocal 处理线程池传递。」
六、踩坑总结(易错点)
| # | 坑 | 说明 |
|---|---|---|
| 1 | 线程池里不 remove | 串号/越权/泄漏,最严重的坑 |
| 2 | 以为 ThreadLocal 是「全局共享」 | 恰恰相反:每个线程一份,别拿它做跨线程通信 |
| 3 | InheritableThreadLocal 传线程池 | 只在新线程创建时拷贝一次,线程池复用线程不传值 |
| 4 | @Contended 不生效 | JDK8 默认对自己类禁用了,必须 -XX:-RestrictContended |
| 5 | 手写 padding 是 7 个 long | 缓存行 64B = 8 个 long;JDK 类型头等额外占位时多填更稳 |
| 6 | Disruptor 别乱用 | 需求达不到低延迟就别引入,复杂度高、维护成本大 |
七、课后作业
- 用
-XX:-RestrictContended跑伪共享 Demo,记录无填充 vs 填充耗时,生成对比表(简历素材)。 - 手动实现一个 ThreadLocal(仅 set/get 单值即可),体会 ThreadLocalMap 原理。
- 写清楚:为什么
InheritableThreadLocal在 ThreadPoolExecutor 里失效(提示:Worker 是提前 new 的线程)。 - 调研一下
TransmittableThreadLocal(阿里),用它替换业务中的 ThreadLocal 传参,会有什么优势?