资讯动态

并发编程三大特性实战:原子性、可见性、有序性及volatile全解析

发布时间:2026/10/20 15:34:24 来源:尧图企业网站定制
做并发编程的人迟早会遇到这么一种情况代码在自己机器上跑得好好的一上多线程就像中了邪数据不是多了就是少了程序偶尔还卡死在某个看似无限循环的地方。我在某电商项目做库存扣减时就撞上过类似的事——并发量一上来库存对不上账翻日志查到凌晨都没定位到问题最后才发现是传统的并发三大特性没守住。所谓并发三大特性指的是原子性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读到 count5然后被切走线程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 不只是 volatilesynchronized 和 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执行到步骤3instance已经不是 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 BB 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 修正版本与压测对比修正方案我试了两种。第一种简单粗暴直接加 synchronizedpublic 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先判断是读改写丢更新还是读旧值再看是不是执行顺序被重排了按这个思路走绝大多数问题都能迅速定位。写代码之前用这套框架过一遍大脑比事后熬夜查日志要划算得多。

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

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

免费获取报价 →
↑