资讯动态

多线程协作的底层密码:线程中止、CPU缓存与内存屏障

发布时间:2026/10/14 22:03:30 来源:尧图企业网站定制
很多做并发开发的同行都有过这种崩溃瞬间程序在单线程下跑得飞快一上多线程就各种怪问题——要么CPU飙高但处理量上不去要么数据肉眼可见地错乱要么线程挂在原地怎么也不退出。我踩过这些坑之后最大的体会是多线程的难点根本不在API怎么调用而在搞清楚几件看似不相关的事情——线程中止到底该怎么配合、CPU缓存与内存屏障在背后做了什么、线程通信选哪套机制更合适。这篇文章就把线程中止、CPU缓存优化、内存屏障、线程通信wait/notify/notifyAll、park/unpark这四个部分放在一起讲因为它们本质上是同一个问题的不同侧面让多个线程在一个共享的数据世界里安全、高效、可控地协作。适合谁看正在系统学并发编程的人、线程池老遇到任务取消不掉的人、加了一堆锁还是慢到离谱的人以及想真正搞懂volatile到底干嘛的人。读完你至少能回答四个问题线程怎么安全地停下来多线程性能瓶颈为什么常常在缓存层内存屏障到底是墙还是锁两套线程通信机制各有什么脾气1. 线程中止协作式中断才是多线程世界的安全法则1.1 为什么Thread.stop被废弃强制中止到底错在哪很多刚接触多线程的开发者第一反应是停一个线程不就用stop()吗这可是个危险的误解。早年JDK确实提供过stop()但这方法早就被标记为废弃而废弃的原因不是性能问题是安全问题。强制中止线程意味着线程可能在任意字节码位置被打断比如它正在持有一把锁修改共享数据写到一半就被枪毙了。这会导致两件事一是锁永远不会释放其他等待这把锁的线程集体卡死二是共享数据处于半更新状态其他线程读到一半改的脏数据。当初设计者用类似于在不用try-finally的情况下突然杀掉一个进程来形容这种后果。所以JDK后来给出的正确路线是靠中断标志位请求线程自行结束而不是从外部物理终结它。1.2 interrupt的协作机制标志位、InterruptedException、isInterruptedJava的中断机制本质是请求-响应不是命令-执行。线程A调用b.interrupt()只是把b线程的中断标志位置为trueb线程是否停下完全由它自己的代码决定。核心API有三个很多人容易搞混方法作用是否清标志位interrupt()目标线程设置中断标志若目标阻塞在wait/sleep/join则抛InterruptedException否isInterrupted()查询目标线程中断标志否Thread.interrupted()查询当前线程中断标志是注意最后一行它是唯一会清除中断标志的方法。实战中常见的坑就是某个线程池里的工作线程在循环里调用Thread.interrupted()做条件判断一不留神把标志清掉了结果任务明明要求中止线程却忘记了这回事。下面是一个标准的中断响应写法任务在循环中定期检查标志遇到中断就释放资源、退出循环public class CooperativeTask implements Runnable { Override public void run() { try { while (!Thread.currentThread().isInterrupted()) { doSomeWork(); // 耗时的业务逻辑 } } finally { releaseResources(); // 无论怎样退出都要释放资源 } } }1.3 InterruptedException的两种处理姿势要么恢复标志要么向上抛阻塞方法遇到中断时会干两件事抛InterruptedException同时清除中断标志位。也就是说你catch到这个异常后当前线程的中断标志已经是false了。如果不做任何处理这个中断请求就石沉大海。业内分成两派处理方式自己不能处理时不要在catch里吞掉异常直接把InterruptedException声明到方法签名上让上层调用者决定。必须就地处理时catch到异常后调用Thread.currentThread().interrupt()重新设置标志位把中断还回去确保上层代码还能感知到。我见过好多线上问题就是线程池里有人catch住InterruptedException什么都不干导致shutdownNow()发的中断被吞掉任务怎么都不停。线程池的shutdownNow()本质就是遍历工作线程调interrupt()它只能发请求真正让任务停下来还得靠业务代码配合。提示不要在finally块里调用Thread.interrupted()清理标志位除非你非常清楚自己在做什么。否则它会把你为了响应中断而重新设置的标志再次清掉等于白忙一场。2. CPU缓存模型多线程性能瓶颈的根源在这层中间商2.1 从MESI协议看缓存一致性缓存行状态流转与失效代价先抛开Java看看现代CPU上发生了什么。每个CPU核心都有自己私有的L1/L2缓存多个核心共享L3缓存和主存。多线程真正关心的不是缓存有多大而是缓存里的数据怎么保证一致。经典的MESI协议把每个缓存行通常64字节标记为四种状态Modified本核改过还没写回主存数据是干净的但只在当前核有效。Exclusive只有本核持有数据与主存一致。Shared多个核心都持有这个缓存行数据与主存一致。Invalid缓存行失效需要重新从主存或其他核取。当一个核要修改一个处于Shared状态的缓存行时它必须向其他核发送失效Invalidate请求等其他核确认并标记缓存行为Invalid之后才动手。这套协议本身没问题问题是等待确认的时间太长了——一次缓存行状态同步可能耗掉几十个CPU周期对于核心的流水线来说这是天文数字。2.2 store buffer和invalidate queue为了快而引入的乱序帮凶硬件工程师为了掩盖同步延迟玩了个花活核心要写共享变量时不等对方确认失效完成先把写操作放到一个叫store buffer的地方假装已经写完CPU继续执行后面的指令。同时收到失效请求的核也不急着立即处理而是把请求丢进一个invalidate queue异步慢慢消化。这说法虽然在真实硬件里细节略有差异但作为理解模型完全够用。它能解释一个反直觉的现象两个线程各自把对方刚写的值晚一点才看到甚至看到的是旧值。因为写者把值压进了store buffer读者把失效请求放进了queue两个动作都没真正落定。这层结构就是后续可见性问题的物理根源也是为什么volatile、内存屏障这些概念必须存在。理解了store buffer和invalidate queue再看Java里的各种并发关键字就不再是背结论而是能推演因果。3. 伪共享一个被所有线程假装共享的性能杀手3.1 伪共享的产生各写各的缓存行却在打架基于前面的缓存行知识一个特别诡异的现象就浮出水面了两个线程完全不操作同一个变量各写各的但性能却比串行还差。这是因为两个变量碰巧落在同一条64字节的缓存行里。比如一个数组里前一个元素和后一个元素挨着放线程A改arr[0]线程B改arr[1]。按MESI协议A要写arr[0]时发现缓存行是Shared状态必须发Invalidate请求让B把整条缓存行作废B要写arr[1]时又反过来让A作废。两个线程互相踢对方的缓存行每次访问都要重新同步。明明数据上毫无共享缓存层面却打成一团这就叫伪共享False Sharing。我帮人排查过一个某活动系统的低效率问题8个生产者线程往各自的计数器上累加加锁加的也是各自的字段压测结果却显示CPU忙得像在数芝麻单线程写反而更快。用perf一查缓存命中率惨到离谱再一看对象内存布局问题就出在8个long变量被JVM分配到了相邻位置。3.2 解决手段Contended注解与手动填充缓存行针对伪共享Java生态里有两种主流做法手动补齐padding在字段前后加无意义的long变量把目标字段撑到一条完整的缓存行里。JDK里某些类的内部就干过这事代码里注释写着padding to prevent false sharing。Contended注解JDK 8之后提供的标准方案直接标注在字段或类上JVM会自动填充。但要注意默认只能用于JDK内部类业务代码要用得加JVM参数-XX:-RestrictContended解除限制。public class CounterWithPadding { jdk.internal.vm.annotation.Contended private volatile long value; public void increment() { value; } public long get() { return value; } }Contended在HotSpot里实际是靠JVM分配时把字段隔离到独立缓存行实现的比手动padding优雅得多但它也意味着每个实例会多占内存。如果不是热点对象别滥用——一个只有两个字段的小对象被注解后内存膨胀可能几倍垃圾回收压力反噬。3.3 什么时候才值得调伪共享伪共享优化不是银弹它建立在你确定这两个字段是真实的并发热点这个大前提下。判断标准加不加优化压测越多核越好差距小于5%就不要动。线上常见的值钱场景集中在三类数组头元素与尾元素的生产-消费模型、日志缓冲区的读写指针、统计计数器的集合。伪共享调优有一个很反直觉的经验很多时候把数据拆成一个一个的单独对象反而比数组更好因为对象之间天生不在同一条缓存行。Java里对象内存对齐后通常落在不同缓存行上而数组元素是紧凑排列的。所以如果你的队列里全是各线程独立处理的数据请优先考虑对象数组而不是原始数组。4. 内存屏障重排序世界的临时交规4.1 重排序从哪来编译器、CPU乱序、存储子系统为什么两条指令的代码执行结果会跟你看到的源码顺序不一样因为重排序有三个来源编译器重排序在不改变单线程语义的前提下编译器会把读和写调换顺序以获得更好的指令调度。CPU乱序执行处理器自己有指令流水线会根据数据依赖和资源占用情况调整执行顺序。存储子系统重排序store buffer的存在让先发生的写可能在后发生的写之后才真正刷到缓存。多线程程序里重排序的危害在于线程B观察到的共享变量变化顺序不再符合源代码顺序。double-checked locking早期版本为什么不安全不是因为加锁不对而是因为实例字段的赋值操作可能被重排到构造函数执行之前另一个线程会看到一个半初始化的对象。4.2 四种屏障与Java语义StoreStore、LoadLoad、LoadStore、StoreLoad内存屏障本质是给CPU和编译器下达的指令在这个位置不许随便重排。业界把它抽象为四种屏障类型约束规则典型场景LoadLoad屏障前的读先于屏障后的读完成volatile读之后的所有普通读不能提前StoreStore屏障前的写先于屏障后的写完成volatile写之前的所有普通写不能乱序LoadStore屏障前的读先于屏障后的写volatile读之后不能马上发起写StoreLoad屏障前的写先于屏障后的读最强volatile写后的普通读不能重排到前面x86架构的实际内存模型比ARM弱很多暴脾气少很多x86上只有StoreLoad是贵的其他屏障几乎被硬件默认保证了。而ARM、PowerPC这些弱内存模型架构上四种屏障都有实际开销。所以同一个Java并发程序在x86上跑得好好的上ARM盒子就出诡异问题常见解释就是架构内存模型差异。4.3 volatile和final背后的屏障插入规则Java内存模型没有让你直接插入内存屏障的通用语法但它规定了一些关键字必须满足的屏障语义volatile写在写之前插入StoreStore屏障保证之前的普通写都可见在写之后插入StoreLoad屏障防止后续读被重排到写之前。volatile读在读之后插入LoadLoad和LoadStore屏障防止后面普通读和普通写被重排到读之前。final字段构造函数结束时对该对象的引用发布前会插入StoreStore屏障阻止其他线程看到对象已有引用但字段还没初始化的中间态。实战中如果你确实需要比volatile更细粒度的屏障控制可以借助Unsafe的loadFence、storeFence、fullFence方法但这些方法属于深度定制玩法日常代码里几乎用不到。真到了需要手撸屏障的一天我建议你先反复确认自己的并发设计是不是出了什么问题。5. 线程通信wait/notify与park/unpark两套机制的底层差异5.1 wait/notify的正确姿势监视器、wait set与虚假唤醒wait/notify/notifyAll是Java最古老的线程通信方式它们必须运行在synchronized同步块或同步方法中。为什么因为wait除了让线程休眠还必须释放它持有的监视器锁以便其他线程进入同步块调用notify。如果不在锁内线程无法通过监视器管理自己的等待状态整个机制就没有立足之地。synchronized (lock) { while (!conditionMet()) { lock.wait(); // 释放锁挂起等待 } // 条件满足后的处理 }wait会把当前线程放入监视器的wait set中并释放锁notify会从wait set里唤醒一个线程但它不立即释放锁被唤醒线程要等到当前线程退出同步块后才能真正获得锁继续执行。notifyAll则唤醒全部。有一个新手必踩的坑虚假唤醒spurious wakeup。理论上线程可能在没有任何notify的情况下被唤醒现实中即使很少发生规范也要求代码能扛住——所以上面的代码用的是while循环而不是if判断醒来之后重新检查条件条件不满足就继续wait。生产者消费者模型里这就是铁律。5.2 wait/notify的缺陷错过通知与notify选择问题wait/notify有两处比较难受的缺陷错过通知如果notify先于wait执行先wait的线程会永远等不到通知直接死等。比如一个线程在条件变量还没准备好时就notify了另一个线程才开始wait它不会知道自己错过了一次通知。notify随机唤醒notify唤醒哪个线程是不可控的如果有多个线程在wait set中等待不同条件可能唤醒一个不对应的线程又得转回wait效率和安全都很差。所以工程上当有多个等待条件或需要精确唤醒时我更倾向使用Condition结合ReentrantLock或LockSupport而不是裸用wait/notify。5.3 park/unpark基于许可的更加灵活的通信原语LockSupport.park() / unpark(thread)是JUC的基石AQS、锁、线程池底层全靠它。它的核心是一个**许可permit**机制线程持有许可数为1时park不会阻塞直接消耗许可继续执行许可数为0时park调用会让线程停住。unpark(thread)则给目标线程发放一个许可。关键点在于unpark可以先于park执行。如果A线程先调unpark(b)B线程再调park()B不会阻塞因为许可已经备好了。这正好补上了wait/notify错过通知的短板。// 线程A LockSupport.unpark(threadB); // 线程B LockSupport.park(); // 若A已经unpark过这里不会阻塞 LockSupport.park(); // 这里才会真正挂起因为许可已消耗还有个细节很多人不知道park被interrupt时会立即返回但不会抛InterruptedException也不会清除中断标志。所以如果线程因为中断返回而退出你得自行检查Thread.interrupted()做清理否则下次再进入park还会被中断顶出来。park/unpark还支持传入Blocker对象这个对象不出现在业务逻辑里却会被记录在线程dump信息中排查问题时能直接看到卡在哪个业务代码块。所以生产环境里每写一个park都顺手传一个描述性Blocker看着很啰嗦到了拍错现场能省下几小时。5.4 两套通信机制对比什么时候选谁对比维度wait/notifypark/unpark是否需要锁必须持有监视器锁不需要锁等待条件必须循环重判支持复杂条件只支持简单许可配合轮询使用通知强度notify随机一个 / notifyAll全部精确唤醒指定线程错过通知会死等无法恢复许可可累积不会错过中断响应抛异常并清标志直接返回保留标志使用层级语言级语法JUC底层基础设施选择策略很简单写业务代码想快速实现生产者消费者优先考虑Condition或BlockingQueue内部本质走park/unpark非要手写等待逻辑时能用LockSupport解决精确唤醒问题就不去折腾wait/notify。但wait/notify作为语言级语法在面试题和JVM底层设计中依然是绕不开的必修课理解它对理解锁的monitor模型很有帮助。在我的经验里这几个主题最好先搞懂缓存与屏障的底层逻辑再回头看wait/notify和park/unpark会有一种原来如此的通透感。多线程开发不是背API是跟CPU、内存、编译器、JVM多方博弈只有理解了它们各自的脾气你才能写出既快又稳的并发代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑