资讯动态

线程中断、JMM与happens-before:Java并发编程的三大底层基石

发布时间:2026/10/20 15:48:16 来源:尧图企业网站定制
写并发代码这些年最容易被忽视又最能拉开水平差距的往往不是那些花哨的并发容器而是JUC底层最基础的三块东西线程中断、JMM和happens-before。很多人背过八股文知道volatile能保证可见性知道interrupt能“停掉”线程但真到线上排查问题或者写一个需要优雅停机的服务时会发现这些知识点全都用不上。这篇就把这三个主题串起来讲透从原理到实操到踩坑一次性说清楚。内容定位在中级Java工程师正在准备并发面试或者工作中被线程问题折磨的朋友都能从这里找到想要的答案。1. 线程中断不是“杀掉线程”而是“礼貌地请求停下”1.1 先纠正一个根深蒂固的误解很多初学者甚至工作两三年的开发对线程中断的理解还是“调用interrupt()就能让线程停止”然后发现线程根本没停于是得出“interrupt没用”的结论。这完全是方向搞错了。Java里的线程中断本质上是一个协作机制。你调用thread.interrupt()其实只是往那个线程的状态里打了个标记——中断标志位从false变成true。至于那个线程收到这个标记之后怎么做完全由线程自己决定。它可以选择响应中断立刻退出也可以选择忽略这个标记继续执行甚至可以在自己觉得合适的时机再检查这个标记。打个比方你喊一嗓子“该下班了”听到的人可以收拾东西走人也可以假装没听见继续加班这个决定权在听的人手上不在你这喊话的人手上。源码层面看Thread类里维护了一个volatile boolean interrupted字段这就是中断状态的本体。为什么是volatile因为一个线程修改中断状态另一个线程要能立刻看到这正是JMM里可见性的典型场景后文会详细讲。总之中断机制的核心就是一个线程对另一个线程状态的协作式通知。1.2 三个方法的坑一次讲清Thread类提供了三个和中断相关的方法名字长得像亲兄弟实际行为却差别很大用错的人不在少数。public void interrupt() // 实例方法给线程设置中断标志 public boolean isInterrupted() // 实例方法查询中断标志不改变状态 public static boolean interrupted() // 静态方法查询当前线程中断标志并清除标志先说interrupt()它最容易被误解的地方在于调用它之后如果目标线程正卡在sleep()、wait()、join()这类可中断方法上那些方法会立刻抛出InterruptedException而且中断标志会被清除。注意是清除不是保留。这意味着你进catch块之后再调用isInterrupted()拿到的是false而不是true。这一点坑了无数人后文专门展开。再说isInterrupted()和interrupted()。前者是实例方法查询某个指定线程的中断状态不会修改状态后者是静态方法查的是“当前线程”的中断状态并且查询后会把状态重置为false。所以interrupted()这个静态方法一个经典用途就是在循环体里获取并清除中断标记实现“一次性消费”的效果。三个方法配合使用时一个最常见的模式是这样while (!Thread.currentThread().isInterrupted()) { // 执行任务逻辑 } // 线程收到中断信号后正常退出循环做清理工作这种写法在线程池或者后台任务里非常常见但要注意一旦isInterrupted()返回true循环立刻结束如果业务上还需要执行一些清理动作要放在循环后面或者用try-finally来保证清理代码必然执行。1.3 阻塞中的中断为什么sleep会被打断搞清楚三个方法之后还得处理一个更让人头疼的问题当线程处于阻塞状态时中断机制到底怎么生效sleep()、wait()、join()这三种阻塞方式在JVM层面都实现了“可中断”语义。当阻塞中的线程被调用interrupt()时线程会从阻塞状态被唤醒然后抛出一个InterruptedException异常。这个过程不是瞬间强杀线程而是通过异常传播机制把“你被请求中断了”这个信息传递给正在执行的代码。这里有个关键细节值得留意抛出异常之后中断标志位被JVM清除了。也就是说除非你在catch块里重新设置中断标志否则后续代码通过isInterrupted()检查会认为线程从来没被中断过。看一下这个场景try { Thread.sleep(5000); } catch (InterruptedException e) { // 这里进来之后线程的中断标志已经被清除了 // 再次调用 Thread.currentThread().isInterrupted() 拿到的是 false e.printStackTrace(); }这就是很多“线程停不下来”问题的根源。你捕获了异常打印了日志但中断状态已经被清掉了线程自然继续往下跑。正确的做法是在catch里重新设置中断标志try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 // 把异常往外抛或者做自己的业务清理 }这个习惯极其重要。不光是sleep()任何捕获InterruptedException的地方如果当前代码不准备立即结束线程都应该把中断状态恢复回去。否则你就是在吞掉中断信号导致上层代码永远感知不到中断发生。1.4 实操案例优雅停掉一个后台监控线程分享一个实际场景某项目里有个定时上报系统状态的监控线程每两秒采集一次数据遇到系统繁忙时会sleep更久。这个线程需要常驻后台运行但服务关闭时得能优雅停止——不能kill -9强杀否则状态文件写一半会损坏。public class MonitorTask implements Runnable { private volatile boolean running true; Override public void run() { while (running !Thread.currentThread().isInterrupted()) { try { // 模拟采集和上报 Thread.sleep(2000); System.out.println(采集一次数据并上报); } catch (InterruptedException e) { // 别人调用了 interrupt()睡眠被打断 // 不能吞掉中断要恢复标志 Thread.currentThread().interrupt(); // 这里可以break也可以等循环条件自己判断 break; } } // 做清理收尾工作 System.out.println(监控线程已优雅退出); } public void shutdown() { this.running false; Thread.currentThread().interrupt(); // 这里其实是错误示范见下方说明 } }等等上面这段代码有个bugshutdown()里调用的是Thread.currentThread().interrupt()这是中断了调用shutdown的线程而不是中断监控线程自己。正确的写法应该是持有监控线程的引用然后调用那个线程的interrupt方法public class MonitorManager { private Thread monitorThread; public void start() { monitorThread new Thread(new MonitorTask()); monitorThread.start(); } public void stop() { monitorThread.interrupt(); // 中断的是监控线程不是当前线程 } }这个例子再次说明对中断的理解不能停留在方法名上得搞清楚每个方法的调用对象是谁。2. JMM为什么你写的并发代码运行结果和你想象的不一样2.1 从CPU缓存说起讲JMM之前先聊一个底层的事实CPU的运算速度远远快于内存的读写速度。为了弥补这个差距现代CPU内部引入了一层到多层的缓存。每个CPU核心有自己的L1、L2缓存多个核心共享L3缓存然后才是主内存。当一个线程要读取某个变量时它会先把主内存里的数据复制到自己的CPU缓存里后续操作都在缓存上完成等某个时机再把缓存数据写回主内存。这个机制放在单线程环境下没有任何问题但一旦多线程同时读写同一个变量问题就来了线程A在自己的CPU缓存里改了变量线程B在自己的CPU缓存里还留着旧值两边缓存一不一致完全取决于缓存何时刷新回主内存以及何时从主内存重新加载。Java的线程模型正好对应这个结构。每个线程都有自己的工作内存类比CPU缓存所有线程共享主内存类比物理内存。线程对变量的所有操作都必须先在工作内存中进行不能直接读写主内存。那么线程A对变量做的修改什么时候对线程B可见JVM只在特定时机同步工作内存和主内存的数据。这个“特定时机”由谁说了算就是Java内存模型Java Memory ModelJMM。网上很多文章喜欢说“JMM是一组规范”听起来很虚。我更愿意这样理解JMM是一份“Java多线程读写共享数据时的行为约定书”。它规定了哪些情况下一个线程的写操作对另一个线程是可见的哪些情况下是不可见的以及编译器、CPU可以对代码做哪些优化、不能做哪些优化。JMM的核心目标可以概括为三个词原子性、可见性、有序性。这三个特性正是并发编程的安全基石。原子性看的是“操作是不是不可分割”可见性看的是“一个线程改完另一个线程能不能马上看到”有序性看的是“代码执行顺序和指令重排序之间怎么协调”。2.2 工作内存与主内存一个容易混淆的比喻很多人把“工作内存”理解成线程栈上的私有内存这是不准确的。更贴近实际的画法是工作内存是线程对主内存中变量的一份缓存副本集合。线程之间不能直接访问对方的工作内存一切变量传递都要经过主内存中转。举个例子有两个线程T1和T2共享一个int变量count。T1执行count 10这一步实际发生在T1的工作内存里count的新值不会立刻同步到主内存。如果T2紧接着执行System.out.println(count)它从自己的工作内存甚至主内存里读到的可能还是旧值0。这就是典型的可见性问题。而volatile关键字的作用恰恰是针对这个场景用volatile修饰的变量线程在写入时会强制把值刷新到主内存读取时会强制从主内存重新加载确保每次读写都是最新值。public class VisibilityDemo { private static boolean flag false; // 不加volatile可能死循环 public static void main(String[] args) throws InterruptedException { Thread writer new Thread(() - { try { Thread.sleep(1000); } catch (InterruptedException e) {} flag true; System.out.println(flag 已被修改为 true); }); Thread reader new Thread(() - { while (!flag) { // 空转等待flag变为true } System.out.println(reader 发现 flag 变为 true退出循环); }); reader.start(); writer.start(); reader.join(); } }这段代码如果flag不加volatilereader线程很可能永远跳不出循环。因为writer线程修改flag true后只在自己的工作内存里改了reader线程的工作内存里flag还是false。加了volatile之后writer的写入会强制同步到主内存reader读取时也会强制从主内存拿最新值问题解决。实际工作中这类问题非常隐蔽代码表面上看起来“逻辑没问题”跑起来却行为诡异而且不同机器上表现还不一样——因为不同CPU的缓存一致性策略不一样。这也是为什么并发缺陷被称为最难排查的缺陷。2.3 volatile、synchronized、final三种同步手段的适用边界JMM定义了三种同步手段volatile、synchronized、final。它们各自解决不同的问题误用滥用都会有副作用。先说volatile。它保证可见性和有序性禁止指令重排序但不保证原子性。很多人用volatile修饰一个计数器然后做自增操作结果发现数值不对这就是没搞明白边界。count实际上是三步操作读count、count1、写回count。volatile只保证了“读”和“写”本身是新鲜的但三步操作之间可能会被其他线程插入所以自增不是原子的。要保证原子性得用synchronized或者AtomicInteger。再说synchronized。它为临界区提供了完整的三大保证——原子性、可见性、有序性。线程进入同步代码块时会清空自己的工作内存从主内存重新加载变量退出同步代码块时会把工作内存中的修改刷新到主内存。所以synchronized保护的变量读写天生就是安全的。代价是性能开销和阻塞风险。最后是final。JMM对final有特殊的重排序规则只要对象构造完毕且没有在构造函数里“泄漏”引用即this逃逸其他线程就能安全地看到final字段的最终值不需要额外的同步措施。这个规则是JMM给不可变对象开的一扇安全门也是很多并发框架大量使用不可变对象的原因。这三个关键字的选择逻辑并不复杂如果只是多线程间需要一个“开关”类变量可以用volatile如果需要多个变量的组合操作必须进临界区用synchronized如果一个字段初始化后永不修改用final最省心。2.4 指令重排序看不见的“乱序执行”JMM的另一个重要话题是有序性。为了提升性能处理器和编译器会对指令进行重排序——只要重排序后的结果与单线程语义一致就可以放心优化。打个比方你计划“出门—买菜—回家”编译器觉得“出门—回家—买菜”耗时更短于是偷偷改了顺序。在单线程下你看不出区别因为结果一样。但在多线程下另一个线程观察到的中间状态就和预期完全不同了。经典的乱序案例是双重检查锁Double-Checked LockingDCL问题。对象初始化的过程在字节码层面是分配内存、初始化对象、把引用指向内存。第2步和第3步可能被重排序导致另一个线程看到“引用不为null”但拿到的对象里的字段还没初始化完一调用就抛NullPointerException。解决方案就是给引用字段加volatile用禁止重排序的功能消除这个隐患。3. happens-before判断两个操作“谁先谁后”的唯一依据3.1 为什么需要一套操作序规则前文提到JMM保证可见性、有序性但具体到两个操作之间到底哪个操作的影响对另一个可见如何判断一个变量写完后能否被另一个线程读到为了解决这类问题JMM引入了happens-before规则。happens-before可以用一句话概括如果操作A happens-before 操作B那么A的结果对B是可见的且A的执行顺序在B之前。这里的“之前”不是时间上的先后而是“程序顺序和同步顺序共同导出的偏序关系”。这种区分非常重要后面单独展开。有了happens-before规则写并发代码时就不需要逐字逐句去推理编译器到底怎么重排序、CPU缓存什么时候刷新只需要检查两个操作之间是否存在happens-before关系即可。它是JMM给程序员开的“安全承诺书”。3.2 八条规则逐条落地JSR-133定义了八条happens-before规则其中前四条是核心后四条面向特定场景我把它们整理成一张速查表规则含义常见场景程序次序规则同一个线程内按代码书写顺序前面的操作happens-before后面的操作一个线程内变量的读写顺序管程锁定规则对同一个锁的unlock操作happens-before后续对该锁的lock操作synchronized块退出后另一个线程进入同一个锁volatile变量规则对一个volatile变量的写操作happens-before后续对该变量的读操作volatile标志位的发布与消费线程启动规则对线程调用start()方法 happens-before 被启动线程中的任意动作主线程启动子线程子线程能看到主线程start之前的操作线程终止规则线程中的所有操作 happens-before 其他线程检测到该线程终止主线程调用join()等待子线程结束中断规则调用线程interrupt()方法 happens-before 被中断线程检测到中断事件中断标志的传递终结器规则对象的构造函数执行完成 happens-before 它的finalize()方法开始对象销毁阶段的安全发布传递性如果A happens-before B且B happens-before C那么A happens-before C多条规则组合推导前四条规则构成了JMM内存可见性保证的核心骨架。尤其是传递性它让多条规则可以连起来使用例如线程A在synchronized块里修改了volatile变量线程B随后进入同一个synchronized块读取这个volatile变量通过锁规则和volatile规则的传递A的修改对B必然可见。3.3 “时间先后”不等于“happens-before”这是个非常容易踩线的问题。很多人在写多线程代码时习惯用“我sleep了1秒等另一个线程先执行完”来保证先后顺序。但在JMM里时间上的先后完全不能作为可见性的依据。举个例子线程A先调用了threadB.interrupt()然后线程B在很久之后检查中断标志。从时间顺序上看A的操作发生在B之前但如果没有握手协议这个“先”并不构成happens-before关系。中断规则说的是“调用interrupt() happens-before 被中断线程检测到中断事件”也就是说必须有一个正式的检测动作比如调用isInterrupted()发生规则才生效。如果B只是闷头跑业务逻辑根本没检查中断标志那中断这个事件对B来说等于不存在。再举个更实际的场景// 线程A data hello; // 普通写 Thread.sleep(100); // 主观上认为“等一会儿再让B读” flag true; // 普通写 // 线程B while (!flag) {} // 自旋等flag System.out.println(data); // 这里真的能看到hello吗答案是不确定。虽然B等到flag为true时时间上肯定在A写完data之后但flag没有volatile修饰A对flag的写和B对flag的读之间没有happens-before关系那么A对data的写是否对B可见仍然是未知数。即使A等了100毫秒也不能保证B的工作内存里能看到data的新值——因为A根本没把工作内存刷新回主内存。这也就是为什么我一直强调靠sleep、靠时间顺序来同步线程是并发编程里最危险的习惯。看起来在测试环境跑得好好的上了生产环境换了硬件或者JVM优化力度不同行为就变了。3.4 用happens-before推演一个可见性案例现在用happens-before规则完整推演一个常见场景看看规则是怎么落地的。假设有一个共享的Map读写都放在synchronized块里保护private final MapString, String cache new HashMap(); // 线程A public void put(String key, String value) { synchronized (cache) { cache.put(key, value); } } // 线程B public String get(String key) { synchronized (cache) { return cache.get(key); } }线程A执行put后释放锁这是一个unlock操作。线程B随后执行get获取同一个锁这是一个lock操作。根据管程锁定规则A的unlock happens-before B的lock。再加上传递性A在锁内对cache的所有写操作都对B后续锁内的读操作可见。所以只要读写都走同一个锁的临界区就不需要给Map里的每个key加volatile。这个例子说了一个很重要的道理synchronized不仅能保证原子性同时也能保证可见性。这也是绝大多数业务场景下synchronized比volatile更令人放心的原因——它能把一组操作打包成一个“可见性安全区”。而volatile典型的适用场景是“单写多读”的标志位比如运行开关、服务状态。一旦一个线程写多个线程读volatile规则正好覆盖写操作happens-before所有后续读操作。4. 常见问题与排查技巧实录4.1 吞掉中断异常的连锁反应这是我在Review别人代码时见过最多的问题。代码写的是这样try { Thread.sleep(1000); } catch (InterruptedException e) { // 什么都不做 }这种写法等于把中断信号彻底吞了。调用方明明调用了interrupt()希望线程从阻塞中醒来并退出任务结果线程醒来后像个没事人一样继续往下走。更糟的是如果这是一个线程池里的任务线程中断标志被清除后线程池的shutdownNow()都拿它没办法。实际的业务场景往往是这样一个定时任务批量处理文件中间调用了Thread.sleep()如果服务正在关闭线程池会通过中断来唤醒正在sleep的任务并让它快速退出。但任务代码吞了InterruptedException导致任务继续手头的工作直到正常结束后才归还线程拖慢了整个关闭流程。修复方式前面已经写过就三句话catch到InterruptedException后要么重新设置中断标志要么直接处理退出逻辑不要把异常捂在手心。4.2 双重检查锁用happens-before解释为什么要volatile双重检查锁是面试高频题也是实际项目中最容易“差一口气”的地方。先看这段经典代码public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题在这一行 } } } return instance; } }问题出在instance new Singleton()这一步。JVM在执行这一行时实际有多个步骤分配内存、调用构造函数初始化对象、将引用指向分配的内存。根据重排序的规则第2步和第3步可能被颠倒。也就是说线程A正在执行构造时先把引用指向了一块未初始化的内存此时线程B进入getInstance()发现instance ! null直接返回了这块“半成品”对象之后使用它的时候就会抛出意想不到的异常。加一个volatile修饰instance就能禁止这两步的重排序保证对象初始化完成后再发布引用。用happens-before解释就更清楚volatile写操作happens-before后续对该volatile变量的读操作因此线程B读到volatile变量时必定能看到线程A在volatile写之前的所有操作结果包括完整的对象初始化。这也是一个典型“知道结论但不知道原理”的知识点。希望读完这篇后再被问起“DCL为什么要volatile”你能直接说出“为了禁止重排序因为对象发布的完整性需要被保证”。4.3 循环检查中断标志自旋代码里的经典陷阱有些后台任务会采用自旋循环来检查中断标志public void run() { while (true) { if (Thread.currentThread().isInterrupted()) { break; } // 执行业务 } }这段代码看起来没问题isInterrupted()返回true就退出循环。但它有一个隐性风险如果任务模块里某处调用了Thread.sleep()或wait()而中断恰好在阻塞期间到达阻塞会被打断并抛出InterruptedException同时中断标志会被清除。等异常处理和业务代码转回来再检查isInterrupted()时得到的是false循环根本不会退出。正确的姿势是阻塞操作引起的InterruptedException要立即处理要么停止自旋要么重新设置中断标志不能让异常像个没事人一样溜过去。更稳妥的自旋写法是直接用Thread.interrupted()它在检查的同时会把标志清除这样不会重复“消费”中断信号——但前提是你确定自己不需要保留这个状态。4.4 排查可见性问题的心得先查“有没有happens-before关系”在真实项目里排查并发缺陷最怕的是“现象时而复现、时而消失”。我有过一段难忘的经历某个统计服务在高峰期偶尔会出现数据错乱但平时跑得好好的。一开始怀疑是锁不够加了各种同步还是偶发。后来静下心一行行审查代码发现一个标志位的读写没有用volatile而另一个线程在循环里靠这个标志位判断是否继续累加。读写之间没有happens-before关系导致写线程更新了标志读线程因为缓存原因没有感知到继续在错的方向上运行。那次之后我总结了一套排查可见性问题的思路先画一张线程和共享变量的交互图然后逐一检查每一对“读写”之间是否存在锁、volatile、start/join等能建立happens-before关系的操作。凡是找不到规则支撑的读写配对全都当作有问题的嫌疑对象去审查。这个方法不一定能马上定位bug但能快速缩小排查范围比对着日志瞎猜效率高得多。另外强烈建议在写并发代码的时候就养成一个习惯共享变量的每一次访问都要能说出它是靠什么同步机制保证安全的。说不上来就默认它有风险。5. 写在最后的经验总结我自己的体会是并发编程的难点从来不在API怎么调用而在于你心中有没有一张“内存可见性地图”。线程中断、JMM、happens-before这三块内容看起来是三个独立的知识点实际上是一条完整链条中断依赖可见性中断标志是volatile的可见性的保证靠JMM定义的内存交互规则而JMM的规则最终都落到happens-before这条核心判断标准上。在实际工作中我见过太多团队在并发代码里堆synchronized和锁工具箱出了问题就靠重启解决。但真正有经验的人会先问一个问题“这里有没有happens-before关系如果没有是补一条锁还是补一个volatile还是从设计上避免共享”如果你刚接触这几块内容我建议你重点做三件事第一把interrupt()、isInterrupted()、interrupted()三个方法的差异背熟写几个小Demo亲手验证第二把volatile的可见性实验跑起来自己亲眼看一次“不加volatile死循环、加了就正常”的现象第三遇到任何并发业务先画一张线程交互图再逐条用happens-before规则去核对。做到这三点并发代码的可靠性会有一个质的提升。这套底层知识平时用不上但在设计线程池生命周期、搞优雅停机、排查线上诡异问题时它就是唯一的救命稻草。把这些规则吃透再写并发代码你的眼里不再是零散的方法调用而是一张清晰可靠的操作序网。

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

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

免费获取报价 →
↑