JAVA ThreadLocal、伪共享与Disruptor

09 · ThreadLocal、伪共享与 Disruptor

对应学习计划:第一月第2周「Java 并发编程(重中之重!薪资分水岭)」

面试定位:这篇是「拉开差距」篇------ThreadLocal 烂大街必考,伪共享 @Contended 和 Disruptor 是简历亮点素材,普通人答不上来,你能讲透就是加分项。


一、本文能帮你回答的面试题

  1. ThreadLocal 是什么?实现原理(ThreadLocalMap)?
  2. ThreadLocal 为什么会有内存泄漏?怎么解决?
  3. 线程池里用 ThreadLocal 有什么坑?
  4. ThreadLocal 的 key 为什么要用弱引用?
  5. 什么是伪共享?为什么性能杀手?怎么解决?
  6. @Contended 注解是什么?什么 JDK 版本默认限制?
  7. 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 越来越大 → 内存泄漏。

解决:

  1. 用完调用 remove()(关键!)。
  2. 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 本来互不相干,却互相拖累 → 「伪共享」

场景:多线程各写各的计数变量,若这些变量恰好连续分布在同一个缓存行,性能骤降数量级。

解决手段(三选一):

  1. 填充(padding):变量前后各塞 7 个 long 占满 64 字节缓存行(JDK7 手写)。

  2. @Contended 注解 (JDK8 自带):sun.misc.Contended,JVM 自动填充。但在 JDK 8 默认受限 ,应用启动要加:

    复制代码
    -XX:-RestrictContended
  3. 用现成的 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 别乱用 需求达不到低延迟就别引入,复杂度高、维护成本大

七、课后作业

  1. 用 -XX:-RestrictContended 跑伪共享 Demo,记录无填充 vs 填充耗时,生成对比表(简历素材)。
  2. 手动实现一个 ThreadLocal(仅 set/get 单值即可),体会 ThreadLocalMap 原理。
  3. 写清楚:为什么 InheritableThreadLocal 在 ThreadPoolExecutor 里失效(提示:Worker 是提前 new 的线程)。
  4. 调研一下 TransmittableThreadLocal(阿里),用它替换业务中的 ThreadLocal 传参,会有什么优势?
相关推荐
慧都小项1 小时前
图解 MyEclipse Deployment Assembly:资源如何进入部署产物?
java·ide·tomcat·maven·myeclipse·部署排查
Wang's Blog1 小时前
Java 项目实战: 交友项目 - MongoDB基础总结与自检清单
java·mongodb·交友
鬼手点金1 小时前
消息的四种角色
java·前端·人工智能·git·深度学习
zzu123zsw1 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
snpgroupcn1 小时前
SAP 系统退役的归档架构怎么设计:可检索、可理解、可追溯、可证明
java·数据库·架构
文丑颜不良啊1 小时前
LeetCode476~500题目解析
java·leetcode
雨落在了我的手上1 小时前
JavaEE初级(1):计算机是如何⼯作的
java·java-ee
VimorphTech1 小时前
文件名是 MP4 就能处理?用 ffprobe 检查视频流、音轨和时长
java·音视频开发
惊讶的猫2 小时前
Spring AOP
java·开发语言