做并发编程的人,迟早会遇到这么一种情况:代码在自己机器上跑得好好的,一上多线程就像中了邪,数据不是多了就是少了,程序偶尔还卡死在某个看似无限循环的地方。我在某电商项目做库存扣减时就撞上过类似的事——并发量一上来,库存对不上账,翻日志查到凌晨都没定位到问题,最后才发现是传统的"并发三大特性"没守住。所谓并发三大特性,指的是原子性(Atomicity)、可见性(Visibility)和有序性(Orderliness)。这是并发编程的基本功,也是排查线上问题时的核心框架,每次遇到莫名其妙的并发Bug,我都习惯按这三条逐项套一遍,基本不会落空。这篇文章不打算照着课本念定义,我想从实际踩过的坑出发,把这三条特性掰开揉碎讲清楚,配合代码和排查思路,适合刚接触多线程的初学者,也适合写了好几年单线程、一碰并发就头疼的同行。1. 并发问题的起点:一次事故背后的三条线索1.1 库存对不上账,问题到底出在哪个环节先说那起库存事故。业务逻辑本身很简单:用户在商品页确认下单,后端判断库存大于0,减掉库存,生成订单。伪代码长这样:if (stock > 0) { stock--; createOrder(); }单线程下这段代码没有任何问题,但压测环境一开,两个线程同时进了if判断,都看见stock等于1,于是都执行了减库存,最后库存变成0,订单却生成了两笔。这就是典型的超卖。当时我第一反应是"是不是幂等没做好"或者"数据库事务隔离级别配错了",查来查去都不是。后来把这段代码的字节码拉出来看才发现,问题比想象中底层——一行stock--在字节码里根本不是一条指令,而是"读值、减一、写回"三个动作。线程在执行完第一步后被切换走,另一个线程插进来照原样执行,数据就这么错了。1.2 三大特性是应对谁的"组合拳"那次排错之后,我把并发问题的根源整理了一下,发现它们都跑不出三个来源:线程切换、CPU缓存、指令重排序。并发三大特性,恰好就是分别应对这三板斧的。原子性问题,根源在线程切换。线程随时可能被切走,一段看起来连贯的操作,内部只要分成若干步骤,就可能被其他线程插队。可见性问题,根源在每个CPU都有多级缓存。线程修改了变量,数据可能还留在自己的缓存里,没有及时写回主内存,其他线程读到的就是旧值。有序性问题,根源在编译器和CPU会为了性能调整指令执行顺序。单线程内怎么调结果都一样,一旦多线程共享变量,调整后的顺序就可能暴露中间状态。我用银行柜台打过一个比方:多个窗口各自维护一小本账,往里存钱先记在自己的小本上,等某个时间点再统一归总到总账。你要是只知道往自己窗口的小本上写了金额,隔壁窗口查总账自然查不到最新的。并发三大特性要解决的,就是"小本什么时候归总、谁先谁后、排队怎么排"这档子事。2. 原子性:你以为的一条语句,其实是三次握手2.1 从字节码看 i++ 的幕后原子性(Atomicity)的定义并不复杂:一个操作要么全部执行成功且中途不被任何因素打断,要么完全不执行。但难就难在,绝大多数人分不清"看起来是一步"和"实际上是几步"。我拿最经典的count++来说。很多人写多线程计数器都默认这行代码是原子操作,其实完全不是。用javap -c拆开看,count++在字节码层大概是这么四条:getstatic // 读取 count 的当前值 iconst_1 // 常量1入栈 iadd // 加法操作 putstatic // 把结果写回 count一条高级语言语句,对应四条字节码指令。线程在这四条指令之间的任意位置都可能被操作系统切走。假设线程A执行完 getstatic,读到 count=5,然后被切走;线程B也进来执行完整的count++,把 count 改成6;这时候线程A恢复,继续执行 iconst_1、iadd、putstatic,结果还是把6写回去。两次自增,最终只加了1,更新就这么丢了。2.2 天生原子和容易翻车的操作那哪些操作天然原子?在主流JVM里,基本类型赋值(除了long/double)通常是原子的,比如int a = 1;、boolean flag = true;,这类操作在底层就是一次简单的写操作,线程切换不会把它拆开。对象引用赋值在32位和64位JVM上也都能保证原子写。容易踩坑的是这三类:读改写复合操作:i++、count--、x = x * 2这类,先读旧值再算新值再写回,三步之间处处可被插队。检查再行动操作:if (stock > 0) { stock--; },判断和执行是两个独立步骤,判断完之后、执行之前,共享状态可能已经被别人改了。64位数据在32位虚拟机上的读写:long和double会被拆成两次32位写入。线程A写高32位,线程B写低32位,写出来可能是一个高32位来自A、低32位来自B的混合值。这属于比较冷门但确实存在的坑。2.3 保证原子性的三种武器实践中保证原子性,我常用的有三种方式,各有适用场景:方案原理适用场景缺点synchronized / Lock给临界区加锁,保证同时只有一个线程进入代码块较长、涉及多个变量、需要事务感阻塞式,锁竞争激烈时吞吐下降原子类 AtomicInteger、AtomicLong、AtomicReference底层使用CAS,比较并交换,失败就重试单变量计数、状态标记、序列号生成只能在单个变量上保证原子性,多变量联动就无能为力局部变量 / 不可变对象不共享可变状态,自然不存在竞争能设计成无状态就无状态需要重构代码,不是所有业务都适用我个人的习惯是:能用局部变量就不提共享变量;必须共享且只是计数,优先用原子类;涉及多个共享变量联动,老老实实上锁,别用CAS硬凑。CAS虽然无锁,但多变量一致性没法靠它保证,硬凑反而会在业务层捅出更大的篓子。2.4 常见误区:volatile 不是原子性的钥匙这里必须单独拎出来说清楚一件事:volatile 不保证原子性。很多初学者以为给变量加上 volatile 就能解决并发计数问题,这是并发三大特性里最典型的认知误区。public class Counter { private volatile int count = 0; public void incr() { count++; // 看起来有volatile保护,实际上还是读改写三步 } }这个incr()方法即使 count 声明成 volatile,也解决不了丢更新的问题。因为 volatile 只保证"读到的一定是最新的值"和"禁止指令重排",但它管不到"读取->计算->写回"这个复合流程。线程A读到了最新的5,线程B也读到了最新的5,两人都算完写回6,照样丢一次更新。真正要避免这种丢更新,要么用synchronized,要么用AtomicInteger。把 volatile 用在计数器上,属于把"可见性"当成"原子性"用,这是并发编程新手最容易犯的错误,没有之一。3. 可见性:内存里的修改,为什么对方就是看不到3.1 工作内存与主内存的分工可见性(Visibility)要解决的问题是:线程A改了共享变量,线程B能不能立刻看到。先看Java内存模型(JMM)怎么规定的。JMM里有一个主内存(Main Memory),所有共享变量都放在主内存;每个线程还有一份自己的工作内存(Working Memory),线程对变量的所有读写都得先在工作内存里操作,再同步回主内存。这个"工作内存"其实是CPU缓存加寄存器等概念的综合抽象,不是真实存在的独立空间。也就是说,线程A执行flag = false,修改的是自己工作内存里的副本;如果这个副本没有及时刷回主内存,线程B在主内存里看到的 flag 仍然是 true。刷回时机不由程序员控制,由虚拟机和硬件决定。这跟银行柜台的小本账一模一样:你办了存款,柜员在自己小本上记一笔,总账还没更新,隔壁窗口查余额当然查不到。3.2 用代码复现一个"停不下来"的死循环我刚工作那会儿,被一个问题卡了整整一天:一个后台任务线程在某些机器上死活不退出。代码精简之后大概长这样:public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (flag) { // 空转 } System.out.println("工作线程退出"); }); worker.start(); Thread.sleep(1000); flag = false; System.out.println("flag 已被主线程修改"); worker.join(); } }我预想主线程睡一秒后把 flag 改成 false,工作线程就会退出。实际运行呢?在某些JDK版本、某些机器上,工作线程就是一直在空转,程序永远不结束。原因就是上面说的可见性问题:主线程把 flag 的新值写进了自己的工作内存,没有立刻同步到主内存,工作线程每次循环都在读自己的工作内存副本,拿到的永远是旧值 true。给flag加上 volatile 之后,程序就正常退出了:private static volatile boolean flag = true;3.3 volatile 的可见性原理:从写缓存到缓存一致性volatile 为什么有效?它给读写操作加了两条语义:写 volatile 变量时:JMM 会强制把新值立即写回主内存,并让其他CPU核心里对应的缓存行失效。读 volatile 变量时:JMM 会强制从主内存重新读,或重新从总线获取最新值,而不是直接复用本地缓存的旧副本。底层依赖的是 CPU 的缓存一致性协议,比如常见的 MESI 协议。大致流程是:核心A修改了 volatile 变量,通过总线发出消息,让其他核心的缓存行标记为失效;其他核心一旦要读这个变量,发现缓存行失效,就得重新从主内存拉取。这套机制相当于给"小本账"加了一条硬性规定:改完账立刻广播给所有窗口,谁再查余额都必须用最新总账。不过,这里要提醒一下:volatile 保证的是"一个线程写、其他线程读"的可见性,不等于所有并发问题都解决了。如果两个线程同时去写同一个 volatile 变量,该出现的竞争还是会出现,这一点和第2节说的原子性问题要连起来理解。3.4 不只是 volatile:synchronized 和 Lock 也刷可见性很多人以为 synchronized 只负责互斥,其实它同时解决了可见性。JMM 规定:线程在释放锁之前,会把自己工作内存里的修改强制刷新到主内存;另一个线程获取锁之后,会强制重新从主内存读取共享变量。也就是说,锁的进入和退出附带了一次内存屏障。Lock 接口(比如 ReentrantLock)走的是同样的内存语义,lock和unlock同样伴随工作内存的刷新。所以我排查可见性问题时,除了看变量有没有加 volatile,还会顺带看这段代码是否已经被锁保护。如果读写都在锁内,可见性通常不缺,缺的是别的东西。反过来,如果一个共享变量没有加 volatile 也没有锁保护,而你又明确希望修改对其他线程立即可见,那它很大概率就是隐患源。4. 有序性:代码不会说谎,但执行顺序会4.1 重排序的三个来源有序性(Orderliness)是关于执行顺序的问题。我写代码时理所当然认为,第一行先执行,第二行后执行。但编译器和CPU为了挖掘性能,会悄悄调整指令的执行顺序——只要调整之后,单线程内的结果还是符合语义的。重排序主要有三个来源:编译器优化重排序:编译器觉得自己能证明两条语句不互相影响,就可能在生成指令时交换它们的顺序。处理器指令级并行重排序:现代CPU有多条执行流水线,计算单元和加载单元是并行的,后一条计算指令可能先于前一条加载指令完成。内存系统的重排序:CPU读写缓存和写缓冲器的时机不完全一致,看起来像是先访问了内存的某一块,实际先落到了另一块。单线程环境下,重排序永远不会暴露问题,因为重排序的前提就是"结果不能变"。多线程环境下就危险了:一个线程的中间执行状态,可能被另一个线程看在眼里,而这个状态并不符合代码书写顺序。4.2 经典翻车现场:双重检查锁有序性最著名的翻车案例就是双重检查锁(DCL)单例。当年大家为了让单例既延迟加载又高效,写出过这样的代码: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; } }看起来逻辑很严密:先无锁判断,避免大多数情况进锁;进锁后再检查一次,防止两个线程同时通过初审。但问题恰恰出在new Singleton()这行代码。在字节码层面,创建一个对象大致分三步:分配内存空间;在内存上初始化对象,构造方法执行;把instance引用指向这块内存。单线程内,顺序是 1->2->3 不会错,但重排序可能让实际执行变成 1->3->2:先分配内存,把引用暴露出去,再回头执行构造方法。多线程下会出现什么?线程A执行到步骤3,instance已经不是 null 了,但对象还没初始化完;线程B通过第一次检查发现instance != null,直接拿去用,读到的就是一个半成品对象,轻则字段全是默认值,重则直接抛异常。4.3 volatile 与内存屏障解决办法很直接:给instance加上volatile。volatile 的另一个核心能力就是禁止指令重排序,靠的是内存屏障(Memory Barrier)。内存屏障是处理器层面的一组指令,用来强制规定前后指令的内存操作顺序。常见的有四种:屏障类型作用LoadLoad屏障前的读操作先于屏障后的读操作完成LoadStore屏障前的读操作先于屏障后的写操作完成StoreStore屏障前的写操作先于屏障后的写操作完成StoreLoad屏障前的写操作先于屏障后的读操作完成volatile 在实现上,会在写操作前后插入 StoreStore 和 StoreLoad 屏障,在读操作前后插入 LoadLoad 和 LoadStore 屏障,从而把重排序限制在一个安全范围内。DCL单例里的instance = new Singleton()就不会被重排成先暴露引用再初始化对象了。这里插一句我的经验:现在不少资料建议直接用静态内部类或者枚举实现单例,DCL确实越来越像考古题。但DCL作为理解有序性的教学案例,价值非常大,因为它的失败原理和线上很多诡异问题是一模一样的——不是代码逻辑错,是对象初始化的中间状态被别人看见了。5. happens-before:并发世界的红绿灯5.1 六条规则速览学了三大特性之后,还有个问题摆在面前:我总不能在每个共享变量上都加 volatile 吧?那代码没法看了。JMM 给出了更系统的规则——happens-before 关系。它定义了一套"如果A操作 happens-before B操作,那么A的执行结果对B可见,且A的执行顺序先于B"的判定规则。我平时排查并发问题,先不急着加关键字,先拿这套规则推演一遍代码。常用的规则有这些:规则内容程序次序规则同一个线程内,按代码书写顺序,前面的操作 happens-before 后面的操作锁规则解锁操作 happens-before 后续对同一把锁的加锁操作volatile 变量规则对一个 volatile 变量的写 happens-before 之后对该变量的读传递性如果A happens-before B,B happens-before C,则A happens-before C线程启动规则线程的 start() 调用 happens-before 该线程内的任何操作线程终止规则线程内的所有操作 happens-before 其他线程检测到该线程终止(如 join() 返回)以锁规则举例:线程A执行完 synchronized 块,释放锁之前把共享变量都刷回主内存;线程B随后进入同一个 synchronized 块,由于锁规则,B 不仅获得了互斥权,也继承了A在这之前所有对共享变量的修改。所以锁既是互斥工具,也是内存同步工具。5.2 为什么有了 volatile 还需要锁一个常见的问题:既然 volatile 有完整的写读 happens-before 规则,为什么很多场景还得上锁?因为 happens-before 只能保证"看得到最新的值",保证不了"多个操作合在一起是原子的"。举个直观例子:多个线程同时对 volatile 变量做sum += x,即使每个线程都能读到最新的 sum,也可能因为读改写三步的交错,丢掉中间某次累加。可见性管不到复合操作的整体性,原子性才管得到。所以遇到"多个共享变量一起操作""先判断再修改"这类场景,还是要靠锁把整个临界区串行化,锁能同时覆盖原子性和可见性两重需求。5.3 用 happens-before 推演一次线上异常之前排查过一个消息队列消费者的问题:消费者线程从内存队列取任务,处理完后把统计值写到共享 Map;另一个监控线程每秒读一次 Map 做报表。偶尔报表数据比实际少一条。推演过程是这样的:任务处理线程写共享 Map,发生在释放某把锁之前;监控线程也通过同一把锁进入临界区读取 Map,锁规则成立,理论上下一次读取应该能看到最新值;但我看了代码,监控线程并没有加锁,只是普通读取。于是锁规则在这里根本不成立,监控线程能读到什么,取决于 CPU 缓存刷回主内存的时机,完全随机。这就解释了"偶尔少一条"的现象。修复方案有两种:监控线程也加同一把锁;或者把 Map 换成支持并发读的数据结构,再把写操作做原子化。用 happens-before 规则一推演,问题症结一目了然,比靠猜快得多。6. 实战复盘:一个订单号生成器的三重修复6.1 第一版:唯一性全凭运气前面讲了不少理论,最后用一个实际项目里的小工具串一遍。当时某系统要做分布式订单号,要求同一时刻不能重复,我上来的第一版很天真:public class OrderNoGenerator { private static int sequence = 0; public static String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + (sequence++); } }时间戳加自增序号,看起来每个订单号都不一样。压测刚开始就出问题:同一毫秒内多个线程拿到的 sequence 相同,订单号直接重复。6.2 用三大特性逐条对照找病根我没急着改代码,而是拿并发三大特性逐条去对照:原子性:sequence++是"读-改-写"复合操作,多线程并发时更新会丢。可见性:sequence没有 volatile 也没有锁,线程B修改后线程A不一定看得到,进一步加重重复。有序性:这个场景下重排序虽然不直接导致重复,但System.currentTimeMillis()和sequence++之间如果乱序,长串订单号的排序性和可读性都会受影响。结论很清楚:根子在原子性和可见性,尤其是原子性。6.3 修正版本与压测对比修正方案我试了两种。第一种简单粗暴,直接加 synchronized:public class OrderNoGenerator { private static int sequence = 0; public static synchronized String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + (sequence++); } }第二种用 AtomicInteger,无锁但保证原子性:public class OrderNoGenerator { private static final AtomicInteger SEQUENCE = new AtomicInteger(0); public static String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + SEQUENCE.incrementAndGet(); } }压测对比结果如下(单机8线程,每秒生成订单数):方案线程数是否出现重复平均响应第一版:int + 自增8是较快,但不可用第二版:synchronized8否吞吐下降约30%第三版:AtomicInteger8否接近第一版如果业务上还要兼顾日期格式、机器标识和随机段,我会在第三版基础上加日期格式化段。AtomicInteger 在单实例内部完全够用;如果订单号要跨实例生成,就得引入分布式ID方案,那又是另一个话题了。6.4 写并发代码前的自查清单把这次复盘提炼成一张自查清单,我写并发相关的共享变量时会反复对照:这个操作是原子的吗?如果中间有读改写或者先判断后执行,必须上锁或用原子类。这个变量需要立刻被其他线程看到吗?需要就加 volatile,或者保证读写都在同一个锁内。有没有依赖代码书写顺序?如果有,原顺序重排后会不会暴露中间状态?需要 volatile 或锁来限制重排。这组共享变量之间有没有联动?有联动就不要用单个原子类硬撑,直接设计一把锁保护整组变量。每次写完并发代码,我都会问自己这四句话。能答清楚,说明三大特性的功课到位了;答不清楚,多半未来线上会给你上一课。最后再分享一个小体会:面试时很多人能把三大特性名字背得滚瓜烂熟,但一问"volatile 能不能保证原子性"就卡住,一让排查并发问题就只会加锁。其实三大特性不是三个孤立的考点,它们是一套排查框架。遇到并发Bug,先判断是读改写丢更新还是读旧值,再看是不是执行顺序被重排了,按这个思路走,绝大多数问题都能迅速定位。写代码之前用这套框架过一遍大脑,比事后熬夜查日志要划算得多。
做并发编程的人,迟早会遇到这么一种情况:代码在自己机器上跑得好好的,一上多线程就像中了邪,数据不是多了就是少了,程序偶尔还卡死在某个看似无限循环的地方。我在某电商项目做库存扣减时就撞上过类似的事——并发量一上来,库存对不上账,翻日志查到凌晨都没定位到问题,最后才发现是传统的"并发三大特性"没守住。所谓并发三大特性,指的是原子性(Atomicity)、可见性(Visibility)和有序性(Orderliness)。这是并发编程的基本功,也是排查线上问题时的核心框架,每次遇到莫名其妙的并发Bug,我都习惯按这三条逐项套一遍,基本不会落空。这篇文章不打算照着课本念定义,我想从实际踩过的坑出发,把这三条特性掰开揉碎讲清楚,配合代码和排查思路,适合刚接触多线程的初学者,也适合写了好几年单线程、一碰并发就头疼的同行。1. 并发问题的起点:一次事故背后的三条线索1.1 库存对不上账,问题到底出在哪个环节先说那起库存事故。业务逻辑本身很简单:用户在商品页确认下单,后端判断库存大于0,减掉库存,生成订单。伪代码长这样:if (stock > 0) { stock--; createOrder(); }单线程下这段代码没有任何问题,但压测环境一开,两个线程同时进了if判断,都看见stock等于1,于是都执行了减库存,最后库存变成0,订单却生成了两笔。这就是典型的超卖。当时我第一反应是"是不是幂等没做好"或者"数据库事务隔离级别配错了",查来查去都不是。后来把这段代码的字节码拉出来看才发现,问题比想象中底层——一行stock--在字节码里根本不是一条指令,而是"读值、减一、写回"三个动作。线程在执行完第一步后被切换走,另一个线程插进来照原样执行,数据就这么错了。1.2 三大特性是应对谁的"组合拳"那次排错之后,我把并发问题的根源整理了一下,发现它们都跑不出三个来源:线程切换、CPU缓存、指令重排序。并发三大特性,恰好就是分别应对这三板斧的。原子性问题,根源在线程切换。线程随时可能被切走,一段看起来连贯的操作,内部只要分成若干步骤,就可能被其他线程插队。可见性问题,根源在每个CPU都有多级缓存。线程修改了变量,数据可能还留在自己的缓存里,没有及时写回主内存,其他线程读到的就是旧值。有序性问题,根源在编译器和CPU会为了性能调整指令执行顺序。单线程内怎么调结果都一样,一旦多线程共享变量,调整后的顺序就可能暴露中间状态。我用银行柜台打过一个比方:多个窗口各自维护一小本账,往里存钱先记在自己的小本上,等某个时间点再统一归总到总账。你要是只知道往自己窗口的小本上写了金额,隔壁窗口查总账自然查不到最新的。并发三大特性要解决的,就是"小本什么时候归总、谁先谁后、排队怎么排"这档子事。2. 原子性:你以为的一条语句,其实是三次握手2.1 从字节码看 i++ 的幕后原子性(Atomicity)的定义并不复杂:一个操作要么全部执行成功且中途不被任何因素打断,要么完全不执行。但难就难在,绝大多数人分不清"看起来是一步"和"实际上是几步"。我拿最经典的count++来说。很多人写多线程计数器都默认这行代码是原子操作,其实完全不是。用javap -c拆开看,count++在字节码层大概是这么四条:getstatic // 读取 count 的当前值 iconst_1 // 常量1入栈 iadd // 加法操作 putstatic // 把结果写回 count一条高级语言语句,对应四条字节码指令。线程在这四条指令之间的任意位置都可能被操作系统切走。假设线程A执行完 getstatic,读到 count=5,然后被切走;线程B也进来执行完整的count++,把 count 改成6;这时候线程A恢复,继续执行 iconst_1、iadd、putstatic,结果还是把6写回去。两次自增,最终只加了1,更新就这么丢了。2.2 天生原子和容易翻车的操作那哪些操作天然原子?在主流JVM里,基本类型赋值(除了long/double)通常是原子的,比如int a = 1;、boolean flag = true;,这类操作在底层就是一次简单的写操作,线程切换不会把它拆开。对象引用赋值在32位和64位JVM上也都能保证原子写。容易踩坑的是这三类:读改写复合操作:i++、count--、x = x * 2这类,先读旧值再算新值再写回,三步之间处处可被插队。检查再行动操作:if (stock > 0) { stock--; },判断和执行是两个独立步骤,判断完之后、执行之前,共享状态可能已经被别人改了。64位数据在32位虚拟机上的读写:long和double会被拆成两次32位写入。线程A写高32位,线程B写低32位,写出来可能是一个高32位来自A、低32位来自B的混合值。这属于比较冷门但确实存在的坑。2.3 保证原子性的三种武器实践中保证原子性,我常用的有三种方式,各有适用场景:方案原理适用场景缺点synchronized / Lock给临界区加锁,保证同时只有一个线程进入代码块较长、涉及多个变量、需要事务感阻塞式,锁竞争激烈时吞吐下降原子类 AtomicInteger、AtomicLong、AtomicReference底层使用CAS,比较并交换,失败就重试单变量计数、状态标记、序列号生成只能在单个变量上保证原子性,多变量联动就无能为力局部变量 / 不可变对象不共享可变状态,自然不存在竞争能设计成无状态就无状态需要重构代码,不是所有业务都适用我个人的习惯是:能用局部变量就不提共享变量;必须共享且只是计数,优先用原子类;涉及多个共享变量联动,老老实实上锁,别用CAS硬凑。CAS虽然无锁,但多变量一致性没法靠它保证,硬凑反而会在业务层捅出更大的篓子。2.4 常见误区:volatile 不是原子性的钥匙这里必须单独拎出来说清楚一件事:volatile 不保证原子性。很多初学者以为给变量加上 volatile 就能解决并发计数问题,这是并发三大特性里最典型的认知误区。public class Counter { private volatile int count = 0; public void incr() { count++; // 看起来有volatile保护,实际上还是读改写三步 } }这个incr()方法即使 count 声明成 volatile,也解决不了丢更新的问题。因为 volatile 只保证"读到的一定是最新的值"和"禁止指令重排",但它管不到"读取->计算->写回"这个复合流程。线程A读到了最新的5,线程B也读到了最新的5,两人都算完写回6,照样丢一次更新。真正要避免这种丢更新,要么用synchronized,要么用AtomicInteger。把 volatile 用在计数器上,属于把"可见性"当成"原子性"用,这是并发编程新手最容易犯的错误,没有之一。3. 可见性:内存里的修改,为什么对方就是看不到3.1 工作内存与主内存的分工可见性(Visibility)要解决的问题是:线程A改了共享变量,线程B能不能立刻看到。先看Java内存模型(JMM)怎么规定的。JMM里有一个主内存(Main Memory),所有共享变量都放在主内存;每个线程还有一份自己的工作内存(Working Memory),线程对变量的所有读写都得先在工作内存里操作,再同步回主内存。这个"工作内存"其实是CPU缓存加寄存器等概念的综合抽象,不是真实存在的独立空间。也就是说,线程A执行flag = false,修改的是自己工作内存里的副本;如果这个副本没有及时刷回主内存,线程B在主内存里看到的 flag 仍然是 true。刷回时机不由程序员控制,由虚拟机和硬件决定。这跟银行柜台的小本账一模一样:你办了存款,柜员在自己小本上记一笔,总账还没更新,隔壁窗口查余额当然查不到。3.2 用代码复现一个"停不下来"的死循环我刚工作那会儿,被一个问题卡了整整一天:一个后台任务线程在某些机器上死活不退出。代码精简之后大概长这样:public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (flag) { // 空转 } System.out.println("工作线程退出"); }); worker.start(); Thread.sleep(1000); flag = false; System.out.println("flag 已被主线程修改"); worker.join(); } }我预想主线程睡一秒后把 flag 改成 false,工作线程就会退出。实际运行呢?在某些JDK版本、某些机器上,工作线程就是一直在空转,程序永远不结束。原因就是上面说的可见性问题:主线程把 flag 的新值写进了自己的工作内存,没有立刻同步到主内存,工作线程每次循环都在读自己的工作内存副本,拿到的永远是旧值 true。给flag加上 volatile 之后,程序就正常退出了:private static volatile boolean flag = true;3.3 volatile 的可见性原理:从写缓存到缓存一致性volatile 为什么有效?它给读写操作加了两条语义:写 volatile 变量时:JMM 会强制把新值立即写回主内存,并让其他CPU核心里对应的缓存行失效。读 volatile 变量时:JMM 会强制从主内存重新读,或重新从总线获取最新值,而不是直接复用本地缓存的旧副本。底层依赖的是 CPU 的缓存一致性协议,比如常见的 MESI 协议。大致流程是:核心A修改了 volatile 变量,通过总线发出消息,让其他核心的缓存行标记为失效;其他核心一旦要读这个变量,发现缓存行失效,就得重新从主内存拉取。这套机制相当于给"小本账"加了一条硬性规定:改完账立刻广播给所有窗口,谁再查余额都必须用最新总账。不过,这里要提醒一下:volatile 保证的是"一个线程写、其他线程读"的可见性,不等于所有并发问题都解决了。如果两个线程同时去写同一个 volatile 变量,该出现的竞争还是会出现,这一点和第2节说的原子性问题要连起来理解。3.4 不只是 volatile:synchronized 和 Lock 也刷可见性很多人以为 synchronized 只负责互斥,其实它同时解决了可见性。JMM 规定:线程在释放锁之前,会把自己工作内存里的修改强制刷新到主内存;另一个线程获取锁之后,会强制重新从主内存读取共享变量。也就是说,锁的进入和退出附带了一次内存屏障。Lock 接口(比如 ReentrantLock)走的是同样的内存语义,lock和unlock同样伴随工作内存的刷新。所以我排查可见性问题时,除了看变量有没有加 volatile,还会顺带看这段代码是否已经被锁保护。如果读写都在锁内,可见性通常不缺,缺的是别的东西。反过来,如果一个共享变量没有加 volatile 也没有锁保护,而你又明确希望修改对其他线程立即可见,那它很大概率就是隐患源。4. 有序性:代码不会说谎,但执行顺序会4.1 重排序的三个来源有序性(Orderliness)是关于执行顺序的问题。我写代码时理所当然认为,第一行先执行,第二行后执行。但编译器和CPU为了挖掘性能,会悄悄调整指令的执行顺序——只要调整之后,单线程内的结果还是符合语义的。重排序主要有三个来源:编译器优化重排序:编译器觉得自己能证明两条语句不互相影响,就可能在生成指令时交换它们的顺序。处理器指令级并行重排序:现代CPU有多条执行流水线,计算单元和加载单元是并行的,后一条计算指令可能先于前一条加载指令完成。内存系统的重排序:CPU读写缓存和写缓冲器的时机不完全一致,看起来像是先访问了内存的某一块,实际先落到了另一块。单线程环境下,重排序永远不会暴露问题,因为重排序的前提就是"结果不能变"。多线程环境下就危险了:一个线程的中间执行状态,可能被另一个线程看在眼里,而这个状态并不符合代码书写顺序。4.2 经典翻车现场:双重检查锁有序性最著名的翻车案例就是双重检查锁(DCL)单例。当年大家为了让单例既延迟加载又高效,写出过这样的代码: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; } }看起来逻辑很严密:先无锁判断,避免大多数情况进锁;进锁后再检查一次,防止两个线程同时通过初审。但问题恰恰出在new Singleton()这行代码。在字节码层面,创建一个对象大致分三步:分配内存空间;在内存上初始化对象,构造方法执行;把instance引用指向这块内存。单线程内,顺序是 1->2->3 不会错,但重排序可能让实际执行变成 1->3->2:先分配内存,把引用暴露出去,再回头执行构造方法。多线程下会出现什么?线程A执行到步骤3,instance已经不是 null 了,但对象还没初始化完;线程B通过第一次检查发现instance != null,直接拿去用,读到的就是一个半成品对象,轻则字段全是默认值,重则直接抛异常。4.3 volatile 与内存屏障解决办法很直接:给instance加上volatile。volatile 的另一个核心能力就是禁止指令重排序,靠的是内存屏障(Memory Barrier)。内存屏障是处理器层面的一组指令,用来强制规定前后指令的内存操作顺序。常见的有四种:屏障类型作用LoadLoad屏障前的读操作先于屏障后的读操作完成LoadStore屏障前的读操作先于屏障后的写操作完成StoreStore屏障前的写操作先于屏障后的写操作完成StoreLoad屏障前的写操作先于屏障后的读操作完成volatile 在实现上,会在写操作前后插入 StoreStore 和 StoreLoad 屏障,在读操作前后插入 LoadLoad 和 LoadStore 屏障,从而把重排序限制在一个安全范围内。DCL单例里的instance = new Singleton()就不会被重排成先暴露引用再初始化对象了。这里插一句我的经验:现在不少资料建议直接用静态内部类或者枚举实现单例,DCL确实越来越像考古题。但DCL作为理解有序性的教学案例,价值非常大,因为它的失败原理和线上很多诡异问题是一模一样的——不是代码逻辑错,是对象初始化的中间状态被别人看见了。5. happens-before:并发世界的红绿灯5.1 六条规则速览学了三大特性之后,还有个问题摆在面前:我总不能在每个共享变量上都加 volatile 吧?那代码没法看了。JMM 给出了更系统的规则——happens-before 关系。它定义了一套"如果A操作 happens-before B操作,那么A的执行结果对B可见,且A的执行顺序先于B"的判定规则。我平时排查并发问题,先不急着加关键字,先拿这套规则推演一遍代码。常用的规则有这些:规则内容程序次序规则同一个线程内,按代码书写顺序,前面的操作 happens-before 后面的操作锁规则解锁操作 happens-before 后续对同一把锁的加锁操作volatile 变量规则对一个 volatile 变量的写 happens-before 之后对该变量的读传递性如果A happens-before B,B happens-before C,则A happens-before C线程启动规则线程的 start() 调用 happens-before 该线程内的任何操作线程终止规则线程内的所有操作 happens-before 其他线程检测到该线程终止(如 join() 返回)以锁规则举例:线程A执行完 synchronized 块,释放锁之前把共享变量都刷回主内存;线程B随后进入同一个 synchronized 块,由于锁规则,B 不仅获得了互斥权,也继承了A在这之前所有对共享变量的修改。所以锁既是互斥工具,也是内存同步工具。5.2 为什么有了 volatile 还需要锁一个常见的问题:既然 volatile 有完整的写读 happens-before 规则,为什么很多场景还得上锁?因为 happens-before 只能保证"看得到最新的值",保证不了"多个操作合在一起是原子的"。举个直观例子:多个线程同时对 volatile 变量做sum += x,即使每个线程都能读到最新的 sum,也可能因为读改写三步的交错,丢掉中间某次累加。可见性管不到复合操作的整体性,原子性才管得到。所以遇到"多个共享变量一起操作""先判断再修改"这类场景,还是要靠锁把整个临界区串行化,锁能同时覆盖原子性和可见性两重需求。5.3 用 happens-before 推演一次线上异常之前排查过一个消息队列消费者的问题:消费者线程从内存队列取任务,处理完后把统计值写到共享 Map;另一个监控线程每秒读一次 Map 做报表。偶尔报表数据比实际少一条。推演过程是这样的:任务处理线程写共享 Map,发生在释放某把锁之前;监控线程也通过同一把锁进入临界区读取 Map,锁规则成立,理论上下一次读取应该能看到最新值;但我看了代码,监控线程并没有加锁,只是普通读取。于是锁规则在这里根本不成立,监控线程能读到什么,取决于 CPU 缓存刷回主内存的时机,完全随机。这就解释了"偶尔少一条"的现象。修复方案有两种:监控线程也加同一把锁;或者把 Map 换成支持并发读的数据结构,再把写操作做原子化。用 happens-before 规则一推演,问题症结一目了然,比靠猜快得多。6. 实战复盘:一个订单号生成器的三重修复6.1 第一版:唯一性全凭运气前面讲了不少理论,最后用一个实际项目里的小工具串一遍。当时某系统要做分布式订单号,要求同一时刻不能重复,我上来的第一版很天真:public class OrderNoGenerator { private static int sequence = 0; public static String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + (sequence++); } }时间戳加自增序号,看起来每个订单号都不一样。压测刚开始就出问题:同一毫秒内多个线程拿到的 sequence 相同,订单号直接重复。6.2 用三大特性逐条对照找病根我没急着改代码,而是拿并发三大特性逐条去对照:原子性:sequence++是"读-改-写"复合操作,多线程并发时更新会丢。可见性:sequence没有 volatile 也没有锁,线程B修改后线程A不一定看得到,进一步加重重复。有序性:这个场景下重排序虽然不直接导致重复,但System.currentTimeMillis()和sequence++之间如果乱序,长串订单号的排序性和可读性都会受影响。结论很清楚:根子在原子性和可见性,尤其是原子性。6.3 修正版本与压测对比修正方案我试了两种。第一种简单粗暴,直接加 synchronized:public class OrderNoGenerator { private static int sequence = 0; public static synchronized String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + (sequence++); } }第二种用 AtomicInteger,无锁但保证原子性:public class OrderNoGenerator { private static final AtomicInteger SEQUENCE = new AtomicInteger(0); public static String nextOrderNo() { return "ORDER-" + System.currentTimeMillis() + "-" + SEQUENCE.incrementAndGet(); } }压测对比结果如下(单机8线程,每秒生成订单数):方案线程数是否出现重复平均响应第一版:int + 自增8是较快,但不可用第二版:synchronized8否吞吐下降约30%第三版:AtomicInteger8否接近第一版如果业务上还要兼顾日期格式、机器标识和随机段,我会在第三版基础上加日期格式化段。AtomicInteger 在单实例内部完全够用;如果订单号要跨实例生成,就得引入分布式ID方案,那又是另一个话题了。6.4 写并发代码前的自查清单把这次复盘提炼成一张自查清单,我写并发相关的共享变量时会反复对照:这个操作是原子的吗?如果中间有读改写或者先判断后执行,必须上锁或用原子类。这个变量需要立刻被其他线程看到吗?需要就加 volatile,或者保证读写都在同一个锁内。有没有依赖代码书写顺序?如果有,原顺序重排后会不会暴露中间状态?需要 volatile 或锁来限制重排。这组共享变量之间有没有联动?有联动就不要用单个原子类硬撑,直接设计一把锁保护整组变量。每次写完并发代码,我都会问自己这四句话。能答清楚,说明三大特性的功课到位了;答不清楚,多半未来线上会给你上一课。最后再分享一个小体会:面试时很多人能把三大特性名字背得滚瓜烂熟,但一问"volatile 能不能保证原子性"就卡住,一让排查并发问题就只会加锁。其实三大特性不是三个孤立的考点,它们是一套排查框架。遇到并发Bug,先判断是读改写丢更新还是读旧值,再看是不是执行顺序被重排了,按这个思路走,绝大多数问题都能迅速定位。写代码之前用这套框架过一遍大脑,比事后熬夜查日志要划算得多。