1. 一个并发计数器把三大特性全踩了一遍先从一个最典型的例子说起。你写了一个多线程累加程序逻辑极其简单就是让100个线程分别对同一个变量执行10000次count最后期望值是1000000。结果跑下来结果可能是973452也可能是886021总之很难正好等于1000000。更诡异的是如果你在循环体里加一行无关痛痒的打印语句结果大概率就变成正确的了。很多人第一次遇到这种情况时第一反应是Java的线程是不是有问题第二反应是是不是该给变量加个volatile——然后发现加了volatile结果还是不对。这个现象背后藏着的就是并发编程里最基础也最容易被低估的一组概念原子性、可见性、有序性。我见过不少工作了三五年的开发能背出原子性就是一个操作不能被中断可见性就是一个线程修改的值其他线程能马上看到有序性就是代码按顺序执行这样的定义但一旦把代码摆到面前让他解释为什么这里要加锁、那里用volatile到底管不管用、为什么AtomicInteger在不加锁的情况下能做到线程安全他就开始含糊了。这篇文章我想换个讲法不按教科书式的三大特性依次罗列定义、举例、总结来写而是从一个个真实会踩坑的场景出发把这三个特性的底层逻辑、它们之间的联动关系、以及日常编码里到底该怎么用一次讲透。如果你正在准备面试或者已经在项目里被并发bug折磨过这篇文章应该能帮你在背概念和真正理解之间搭一座桥。先记住一个总纲式的心法原子性管的是操作会不会被拆散可见性管的是数据修改对其他线程透不透明有序性管的是代码执行的顺序会不会被改写。三者的核心都指向同一个问题——在多线程环境下你写的代码和你以为它会执行的方式可能根本不是一回事。2. 先从JMM说起可见性与有序性的问题根源2.1 每个线程都在操作自己的一份私房数据想理解为什么会有可见性问题必须回到Java内存模型Java Memory ModelJMM。JMM规定所有共享变量都存放在主内存中但每个线程在工作的时候并不会直接去主内存里读写而是先把变量复制到自己的工作内存可以粗略类比为CPU的高速缓存里在线程内部完成计算计算完再把结果刷回主内存。这个机制在单线程环境下毫无问题因为线程读取到的就是它自己刚写入的值。但多线程环境下就出事了线程A往自己的缓存里写入了count 5这个值还没来得及刷回主内存线程B在他自己的缓存里读取到的count还是旧的0。两个线程各玩各的彼此都不知道对方做了什么。这就是可见性问题的本质——一个线程的修改对另一个线程可能完全不可见因为你俩操作的根本不是同一份数据的最新鲜版本。我之前在文章里用过快递柜的类比这里再重复一次因为它太好用了主内存相当于快递驿站的公共货架每个线程的工作内存相当于你手里拿着的手机备忘录。你在备忘录上记了给李四的包裹放在3号柜但你没有把这个信息同步给王五王五跑到驿站去取包裹查的是他自己的备忘录自然找不到。volatile和锁的底层作用就是强制要求你在记完备忘录后必须通知所有人或者强制要求所有人都去查同一个公共货架。2.2 指令重排源码顺序不等于执行顺序再来解决一个日常开发中很不容易注意到的问题有序性。你以为你写的代码是这个顺序JVM为了提高执行效率在编译期和运行期都会做指令重排Instruction Reordering。现代CPU在执行指令时也有乱序执行的能力只要不改变单线程语义允许将没有数据依赖的指令调换顺序。也就是说你写的代码是boolean ready false; int value 0; // 线程A value 42; // 操作1 ready true; // 操作2 // 线程B while (!ready) { } System.out.println(value);按直觉线程B应该先看到ready true然后读到value 42。但经过重排之后线程A完全可能先把ready置为true再执行value 42的赋值。此时线程B看到ready为true一执行打印语句拿到的value可能是0。这就是有序性被破坏带来的坑——代码的逻辑顺序和实际的执行顺序错位了而你却拿源码顺序当做了判断依据。2.3 happens-before规则Java提供给并发程序的秩序契约看到这里你可能会慌如果指令重排这么不可控Java程序不就全都乱套了吗当然不是。JMM设计了一套happens-before先行发生规则它规定了哪些情况下一个线程的操作结果对另一个线程是可见的。这相当于一套契约只要你的代码满足了happens-before关系重排就会受到约束即使底层真的做了优化也不能破坏这种关系的语义。常见的happens-before规则包括程序次序规则同一个线程内写在前面的代码先行发生于后面的代码。volatile变量规则对一个volatile变量的写操作先行发生于后续对这个变量的读操作。锁规则对一个锁的解锁先行发生于后续对这个锁的加锁。传递性如果A先行发生于BB先行发生于C那么A先行发生于C。注意第三点只有后续的加锁/读操作才会和之前的解锁/写操作建立happens-before关系。如果你的线程B在时间上早于线程A完成了加锁那这条规则就保护不了你了。这也是并发编程里很多隐蔽bug的来源——要么你的加锁逻辑本身就有先后顺序问题要么你根本没意识到自己的访问不属于受保护关系。3. 原子性不是操作不能被中断这么简单3.1 synchronized到底保护了什么教科书式的定义说原子性就是一个操作要么全部执行成功、要么全部不执行操作过程中不能被线程调度机制中断。这个定义本身没错但它容易给人一个错觉好像只要给方法加了synchronized里面所有东西都自动原子了。实际不是。synchronized保护的是临界区内代码的互斥执行。它能保证两件事一是同一时刻只有一个线程能进入临界区二是当一个线程退出临界区时它对共享变量的修改对后续进入临界区的线程可见。但如果你把同步块写得太大把所有不相关的代码都塞进去会严重拖垮性能如果你把同步块写得太小漏掉了某个关键操作原子性就无从谈起。举个例子。银行转账操作要保证的就是扣钱和加钱必须作为一个整体中途不能被插入其他线程的转账操作。你如果只给扣钱代码加了锁不加锁的地方任由其他线程读取和修改余额那前面锁得再死也没用。3.2 count 为什么是三个字节码指令回到最开头的那个计数器例子。count在Java源码里看起来是一个操作但编译成字节码之后它其实是四条指令的组合getstatic // 读取 count 的值 iconst_1 // 常量1入栈 iadd // 执行加法 putstatic // 把结果写回 count这意味着即使没有指令重排和缓存问题仅仅是读-改-写这个流程中任何一个步骤被其他线程打断最终结果都会出错。两个线程同时读到了count 10各自执行1然后分别写回11。两次自增操作物理上确实都执行了但结果从12变成了11一次更新凭空消失了。这也是为什么volatile解决不了原子性问题——它能保证你把值读出来的时候是最新的、你写完值刷回主内存的时候别人能看到但它管不了读和写之间那个时间差。两个线程可以同时读到10然后同时写回11。volatile不是锁它不会让这两个线程在读取时排队。3.3 复合操作的原子化原子类为什么能行解决复合操作原子性问题最常见的手段是使用synchronized或者java.util.concurrent.atomic包里的原子类。如果你用AtomicInteger的incrementAndGet()它的底层实现依赖的是CASCompare And Swap比较并交换。CAS的逻辑可以概括成一句话内存位置V的当前值如果等于预期的旧值A那就把新值B写进去否则就什么都不做并返回操作失败。注意这个比较写入的动作在硬件层面是一个不可分割的指令所以CAS本身是原子的。但CAS不是银弹。它最大的坑在于ABA问题线程1读取到值A线程2把值改成了B又改回了A线程1再比较时发现还是A就认为没人动过提交了写操作。如果这个值代表的是一个对象引用并且你关心的是引用是否被改过ABA问题就非常致命。解决ABA问题要使用带版本号的原子类比如AtomicStampedReference。另外CAS在竞争激烈时会频繁自旋重试白白消耗CPU高并发场景下的性能未必比加锁好。4. volatile的三副面孔可见性、有序性、以及管不了的原子性4.1 volatile到底做了什么volatile应该是Java关键字里被误解最严重的一个。它做了三件事保证可见性对volatile变量的写操作会强制把值立刻刷回主内存并且使其他线程工作内存中对应的缓存行失效。防止重排序编译器和JVM会在volatile变量的读写操作前后插入内存屏障阻止指令跨越屏障重排。不保证原子性它管不住读-改-写三步之间被打断。第三点前面已经通过count的例子说明白了。但前两点非常有用所以volatile在一种场景下是绝佳选择——当共享变量的写入不依赖当前值或者只有一个线程写、多个线程读时。典型应用是状态标志位public class ShutdownHook { private volatile boolean running true; public void stop() { running false; } public void work() { while (running) { // do something } } }在这个场景里stop()只是把running改成false它的新值和旧值无关不涉及复合操作一个线程写、其他线程读。volatile在这里完美胜任写线程的修改对读线程立即可见而且while循环里的读操作不会因为指令重排导致出现问题。4.2 volatile的内存屏障与内存语义解读为了做到上面这些保证JMM对volatile变量的读写插入了内存屏障规则。简单概括如下在每个volatile写操作的前面插入StoreStore屏障禁止上面的普通写与下面的volatile写重排。在每个volatile写操作的后面插入StoreLoad屏障防止volatile写与之后可能有的volatile读/写重排。在每个volatile读操作后面插入LoadLoad屏障和LoadStore屏障禁止之后的普通读/写与前面的volatile读重排。如果你觉得这些屏障规则太底层记住一个结论就够了volatile变量的写操作强制刷新到主内存volatile变量的读操作强制从主内存重新读取读写前后存在内存屏障指令重排不能跨过这道屏障。这就同时解决了可见性和有序性的问题。4.3 volatile与synchronized的内存语义区别volatile和synchronized有一些类似的可见性保证但很多面试者说不出它们的关键区别。除了前面强调的原子性差异之外还有一个特别重要的点volatile只能修饰字段不能用在方法上不能修饰类synchronized可以修饰方法、代码块、对象、类。volatile不会进行加锁操作因此不会引起线程上下文切换在只有一个写线程等待场景下性能远好于synchronized。但如果你需要像count这样的复合操作volatile就无能为力了老老实实加锁或者用原子类。我遇到过不少这样的代码给count加了volatile然后在多线程里执行count百思不得其解为什么结果还是不对。问题不在volatile在心智模型——你要修的是操作的原子性而不是数据的可见性。5. 三大特性怎么联手AtomicInteger的源码级拆解5.1 从AtomicInteger看原子性和可见性的配合不少人对AtomicInteger的理解停留在它不用加锁所以是线程安全的。但如果你仔细看一眼源码会发现它内部使用了一个volatile字段来保存值public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID 6214790242416808320L; // 关键value是用volatile修饰的 private volatile int value; // 使用Unsafe类的CAS方法来完成原子更新 public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); } }你看CAS保证了读-改-写是原子的volatile保证了value的可见性。这两者组合起来才构成了完全线程安全的getAndAdd。没有volatileCAS即使能原子地比较和写入也无法保证其他线程能及时看到这个值的更新没有CASvolatile又管不住中间那几步的复合操作。这恰好说明了一点在真实世界中三大特性很少独立出现更多时候是协同工作。5.2 LongAdder为什么要来抢AtomicInteger的活面试中还有个高频问题既然AtomicInteger这么好为什么还要用LongAdder原因在于CAS在高并发下的自旋代价。当竞争非常激烈时CAS会反复执行失败-重试-失败-重试的循环大量CPU时间浪费在空转上。LongAdder的思路是把单一的热点value拆成多个cell每个线程在自己的cell里执行累加操作最后求和时把多个cell的值加起来。这就把竞争压力分散了。我测试过一个场景20个线程、每个线程累加100万次AtomicInteger的耗时大约是LongAdder的3到5倍。当然LongAdder的sum()在并发修改下返回的是一个不一致的快照但如果你的业务只需要最终累加结果不需要精确读取中间值LongAdder是更好的选择。5.3 synchronized在写多写复杂时的反直觉优势很多人觉得JUC里的原子类一定比synchronized快实际上未必。在低竞争场景下原子类确实快但在高竞争、高写频率、写逻辑复杂的场景下CAS的自旋开销可能超过synchronized带来的上下文切换开销。尤其是Java 6之后synchronized经过偏向锁、轻量级锁、重量级锁的升级优化性能已经有了大幅提升。所以选型时别迷信锁不好、CAS好的刻板印象要根据并发度、写频率、操作复杂度来综合判断。并发编程没有银弹只有匹配场景的工具。6. 一个实战清单日常代码里如何逐项落实三大特性6.1 操作级别上的选择策略拿实际业务代码来说我们可以给要不要用某种机制保证并发安全列个简单的决策逻辑如果一个操作只是简单的变量读写没有复合依赖且是一个线程写、多个线程读优先考虑volatile。如果操作是复合操作比如i、i i 5或者需要先读后写且新值依赖于当前值AtomicInteger、AtomicLong、AtomicReference是不错的选择或者用synchronized包裹整个复合步骤。如果业务逻辑本身有多个步骤需要作为一个整体对外不可分割比如转账扣款加款记录流水那就必须用锁而且锁的范围必须覆盖所有相关步骤。此时谈原子性才是完整的保障。6.2 可见性保障的三个检查点可见性问题很容易出现在多线程协调场景里。以下几个位置是我检查代码时必看的停止标志while循环依赖一个运行状态标志如果这个标志不是volatile也不是锁保护的线程可能永远停不下来。懒加载的单例字段双重检查锁Double-Checked Locking里单例字段必须声明为volatile否则由于指令重排另一个线程可能拿到一个尚未完成构造的对象。发布对象一个对象在构造函数里完成初始化之后如果没建立happens-before关系就把引用暴露给了其他线程其他线程可能看到部分构造的对象。正确做法是安全发布——通过volatile、final字段、锁或者线程启动/结束的隐式关系。这里特别说一下双重检查锁。它的代码是这样的public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }new Singleton()这一行实际上包含三个步骤分配内存、调用构造函数、把引用赋值给变量。如果不加volatile编译器和CPU可能把调用构造函数和把引用赋值给变量重排。那样的话线程A刚把引用指向一块尚未初始化完成的内存线程B进来发现instance不是null直接使用就会读到默认值甚至直接崩溃。加了volatile之后内存屏障阻止了这个重排instance的引用对所有线程的可见性有了保证。6.3 有序性依赖的识别与处理有序性问题的隐蔽性最强因为它不是结果错了而是在某些特定时机下结果才错。调试这类问题极其痛苦因为你在测试环境可能跑一个小时都复现不出来。我处理有序性问题的思路是先判断代码里是否依赖了非happens-before规则的顺序假设。如果A操作和B操作之间没有happens-before关系就不要假设A一定先发生。比如先设置一个配置项再设置一个标志位表示配置可用了另一个线程看到标志位后去读取配置项——如果这两个操作之间没有volatile/锁的保护你无法保证读取方一定拿到更新后的配置。解决方案是老生常谈把标志位声明为volatile或者用一个AtomicBoolean来承载配置是否可用的状态。锁同样可以因为锁规则本身就建立了happens-before关系。6.4 一张表看清常用工具的特性覆盖把Java里常见的几种并发工具的三大特性覆盖情况整理成一张表你在做技术选型或排查问题时会非常有用工具原子性可见性有序性适用场景volatile不保证保证保证通过内存屏障状态标志、单例双重检查、一个写多个读synchronized保证锁临界区保证保证复合操作、多步骤业务逻辑AtomicInteger等原子类保证CAS保证内部volatile保证计数器、自增自减、并发累计final安全发布不直接涉及有限保证有限保证不可变对象的安全发布ThreadLocal天然隔离不涉及跨线程不涉及每个线程独享的数据副本注意这张表里final那一行特别容易被人忽略。final字段在构造函数中正确初始化后通过安全发布方式对其他线程可见时JMM保证其他线程可以看到final字段的完整值不需要加锁。对一些不可变对象来说这等于拿到了一份并发安全的免费午餐。不过前提是安全发布——如果你通过this逸出或者在一个未同步的容器里把对象暴露出去final的保证也会失效。7. 用三大特性的思路去排查并发Bug最后分享一个我自己的排查习惯。遇到并发相关的问题我不会先去翻具体是哪一行代码写错了而是先做一道分类题这个bug更像是哪种特性被破坏了如果症状是结果比预期小、更新丢失优先怀疑原子性问题——查复合操作有没有被锁住或使用原子类。比如count结果不准先看它是不是裸奔的普通变量。如果症状是一个线程改了另一个线程看不到死循环停不下来优先怀疑可见性问题——查共享变量是否缺volatile循环条件里的变量是否在每次迭代时都会重新从主内存读取。如果症状是功能在低概率下出现诡异结果跟时序高度相关加不加打印语句结果可能就变了优先怀疑有序性问题——查有没有依赖没有happens-before关系的线程间顺序有没有在多线程环境里依赖单线程语义。这三步走完大部分并发bug的定位方向就清晰了。剩下的工作才是去看具体实现该加锁的加锁、该换原子类的换原子类、该加volatile的加volatile。我个人这些年反复被验证的一点是并发bug难解不是难在API不会用而是难在脑内的执行模型和真实执行模型之间有个巨大的落差。当你脑子里始终装着原子性管什么、可见性管什么、有序性管什么这三条线遇到任何并发问题都能快速把它归因到其中一条线上排查效率会高很多。不少面试题看起来是在考API、考源码实际想验证的其实就是你脑内有没有建立起这套分类框架。