Java高并发底层原理(二)—— 为什么 count++ 会出错

发布时间:2026/7/4 18:18:55
Java高并发底层原理(二)—— 为什么 count++ 会出错 第2章 为什么 count 会出错先看一段代码。两个线程各对同一个计数器自增一百万次理论上最终值应该是两百万。publicclassCountDemo{privatestaticfinalintTIMES1_000_000staticclassCounter{privateintcount0publicvoidincrement(){count}publicintgetCount(){returncount}}publicstaticvoidmain(String[]args)throwsInterruptedException{CountercounternewCounter()ThreadthreadAnewThread(()-{for(inti0 iTIMES i){counter.increment()}})ThreadthreadBnewThread(()-{for(inti0 iTIMES i){counter.increment()}}) threadA.start() threadB.start() threadA.join() threadB.join()System.out.println(expected TIMES*2)System.out.println(actual counter.getCount())}}实际跑起来actual大概率小于expected。多跑几次每次结果还不一样。代码没变输入也没变唯一变化的是两个线程谁先谁后——这就是这一章要讲的问题。1. count 不是一步是三步count在源码里是一行但 JVM 执行时会拆成三步:读一次count的当前值把这个值加一把结果写回count写成一行只是 Java 给你的语法糖。等价地展开就是这样:intcurrentcount// 读intnextcurrent1// 算countnext// 写这三步之间没有任何保护线程可以在读完之后、写回之前被切走让另一个线程插进来。这才是问题的根源。2. 一次更新是怎么丢的假设count初始为0A、B 两个线程各执行一次count。下面是一种真实可能发生的执行顺序:┌──────┬────────────────────────┬────────────────────────┬───────────┐ │ Step │ Thread A │ Thread B │ Heap │ ├──────┼────────────────────────┼────────────────────────┼───────────┤ │ 1 │ Read count 0 │ │ count 0 │ │ 2 │ Calculate next 1 │ │ count 0 │ │ 3 │ │ Read count 0 │ count 0 │ │ 4 │ │ Calculate next 1 │ count 0 │ │ 5 │ Write count 1 │ │ count 1 │ │ 6 │ │ Write count 1 │ count 1 │ └──────┴────────────────────────┴────────────────────────┴───────────┘A 和 B 各自的三步都执行得很正常没有任何一步出错。问题出在第 3 步:B 读count的时候A 已经算完了1但还没来得及写回去所以 B 看到的还是旧值0。两边各自算出1分别写回B 的写操作发生在最后直接把 A 的结果覆盖掉了。A 那次自增相当于白做了——这就是丢失更新(Lost Update)。单核 CPU 上线程来回切换就能凑出这种顺序多核上两个线程甚至可以真的同时执行读写窗口直接重叠效果是一样的。只要读、算、写这三步之间没有硬性的先后约束这种覆盖就随时可能发生。同样这段代码换一种执行顺序结果就是对的——比如 A 三步都执行完了B 才开始读这时候读到的就是1最后能拿到2。所以这类 bug 出现得毫无规律:源码没有问题问题出在每次运行时线程被调度的顺序不一样。3. 什么是原子性如果一个操作在执行过程中不能被打断——别的线程要么看到它执行前的状态要么看到它执行完之后的状态中间过程完全不可见——这个操作就是原子的(Atomic)。count不满足这个条件因为它是读、算、写三步拼出来的而不是一步到位。别的线程完全可能在它读完还没写回的空档插进来。判断一个操作是不是安全不能看它在代码里占几行要看它在真正执行的时候能不能被拆成好几步、这几步中间会不会暴露给别的线程。同样的问题也藏在很多长得人畜无害的代码里:balancebalance-amount indexindex1 valuevalue*2 list.add(element)这几行代码有一个共同点:结果都依赖读进来的旧值而不是凭空算出来的。只要涉及先读旧值、再算新值、最后写回去这个过程就有被打断的风险跟代码写成一行还是三行没有关系。4. 什么是竞态条件最终结果取决于多个线程谁先谁后这种情况叫竞态条件(Race Condition)。这里的竞争不是说两个线程必须同时执行而是它们都在抢同一份数据——谁先读到、谁后写入直接决定了最后的值是多少。回到计数器的例子两种不同的执行顺序会给出两个不同的答案:A 三步全部执行完B 才开始 - count 2 A、B 在中间读到了同一个旧值 - count 1程序只保证了一件事:单个线程内部读、算、写这三步必须按顺序执行。但它完全没规定 A 和 B 之间谁先谁后操作系统想怎么切换线程都可以只要合法。只要存在一种切换顺序会让结果出错这段代码就有竞态条件。具体来说满足下面三条就一定存在竞态条件:多个线程会碰同一份数据、至少有一个线程会去改这份数据、这组相关操作有可能被别的线程从中间插一脚。Counter这个例子三条全占。反过来如果线程操作的是各自独立的数据或者共享数据从头到尾都不会变竞态条件也就不存在了——真正的根源不是多线程这三个字而是共享的、会变的状态。5. 为什么跑测试证明不了它是安全的并发 bug 依赖具体的执行时序。跑一次拿到两百万只能说明这一次没撞上问题的执行顺序不代表下一次也没事。CPU 核数、系统负载、线程调度策略、循环次数随便一个变了线程交错的方式就可能跟着变。哪怕连续跑一千次都是对的代码里的竞态条件也没有消失——只要存在一种合法的执行顺序会出错这段代码就不是线程安全的跟它跑了多少次结果都对没关系。调试的时候还会遇到更麻烦的事:加一行日志、打一个断点都会拖慢线程的执行速度原本很容易复现的 bug 可能因此暂时消失。这不是玄学只是多余的操作把线程原本的时序打乱了而已。所以判断一段并发代码安不安全不能只看它跑测试跑没跑出问题得去看它到底共享了什么数据、谁在改这份数据、这些操作有没有可能被打断。6. 三个问题判断一段代码安不安全拿到一段并发代码依次问自己三个问题就够了:这几个线程会不会碰到同一份状态?局部变量一般是线程私有的但它保存的引用可能指向一个所有线程共享的对象不能只看变量是在方法里声明的就当成安全。这份共享状态会不会被改?如果所有线程都只读不写通常不会有丢失更新的问题一旦出现写操作就得继续往下查。一次操作是不是由好几步拼起来的?读旧值、算新值、写回去是最典型的复合操作。只要这几步能被别的线程插进来打断结果就会跟执行顺序挂钩。拿计数器的例子过一遍这三条:两个线程共享同一个Counter对象count会被写count是读、算、写三步组合——三条全中竞态条件确认。小结count出错不是因为加法算错了而是因为它依赖旧值由读、算、写三步拼成而这三步之间没有任何保护。两个线程完全可能读到同一个旧值、算出同一个新值、再互相覆盖对方的写入导致其中一次自增凭空消失。一句话概括:多个线程并发执行一个非原子的复合操作并且这个操作会修改同一份共享状态。问题已经找到:要让一组相关的步骤在并发环境下保持完整不能让别的线程从中间插一脚。下一章从这个要求出发看看 Java 提供了哪些手段来做到这件事。