图 1:本文封面,两条时间线在「读」处交错。
先说明属性:这是一篇历史笔试题的复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面很短:全局变量 tally 初值为 0,两个线程各自执行 50 次 tally++,没有任何同步措施,问最终结果是多少。很多人脱口而出 100,但正确答案是「2 到 100 之间的某个值」。把这道题讲透,就看懂了多线程为什么必须同步。
tally++ 不是一条指令。
高级语言里的一行自增,编译到机器层面是三步:把 tally 从内存读进寄存器,寄存器里的值加一,把结果写回内存。关键在这三步不是原子的——线程在任意两步之间都可能被操作系统切走,寄存器现场被保存,等再切回来时接着跑。正是「读和写之间隔着一段不确定的时间」,给了交错可乘之机。
丢失更新:两次加一,只涨了一。
设想 tally 当前是 7。线程 A 读出 7,还没写回就被切走;线程 B 也读出 7;A 醒来把 8 写回;B 接着把自己算好的 8 也写回——B 的写覆盖了 A 的写。两个线程各完成了一次自增,tally 却只从 7 涨到 8:一次更新凭空丢失。这不是小概率的理论事件,用 Java 实测一下(本机 JDK 21,两个线程各加 20 万次,连跑 5 轮):
public class LostUpdate {
static int tally = 0;
static volatile boolean go = false; // 发令枪:保证两个线程同时开跑、充分交错
public static void main(String[] args) throws InterruptedException {
final int N = 200_000;
for (int round = 1; round <= 5; round++) {
tally = 0;
go = false;
Runnable job = () -> {
while (!go) { Thread.onSpinWait(); } // 等发令
for (int i = 0; i < N; i++) tally++;
};
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start(); b.start();
Thread.sleep(50); // 等两个线程都进入起跑线
go = true;
a.join(); b.join();
System.out.println("第 " + round + " 轮: 期望 " + (2 * N)
+ ",实际 " + tally + ",丢失 " + (2 * N - tally) + " 次更新");
}
}
}
实测输出:
第 1 轮: 期望 400000,实际 234748,丢失 165252 次更新
第 2 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 3 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 4 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 5 轮: 期望 400000,实际 200000,丢失 200000 次更新
四十万次自增,最差一轮只剩二十万——整整一半的更新被覆盖丢失。
范围推导:为什么是 2 到 100。
最大值 100 好理解:两个线程的循环完全不交错(或者干脆加了锁),每次自增都基于最新值,五十加五十等于一百。
最小值 2 需要构造一个极端交错,核心思路是让一个线程的几乎全部成果,被另一个线程的「旧值写回」覆盖。完整剧本是这样的:线程 B 第一次自增读出 0,还没写回就被切走;线程 A 连续完成 49 次自增,tally 涨到 49;B 醒来把「0 加 1」写回,A 的四十九次成果瞬间被抹成 1;A 接着做第 50 次自增,读出 1,又被切走;B 继续完成自己剩下的 49 次,tally 从 1 涨到 50;最后 A 醒来,把「1 加 1」写回——tally 停在 2。两个线程恰好各完成 50 次自增,谁也没偷懒,结果却是最小值。用一段按剧本调度的模拟验证(把每个线程的自增拆成读、写两个动作,按上面的顺序手工发牌):
B 写回 49(第 49 次自增完成)
B 读 tally=49
B 写回 50(第 50 次自增完成)
A 写回 2(第 50 次自增完成)
---
A 完成 50 次,B 完成 50 次,tally 最终 = 2
至于 2 到 100 之间的值:只要调整「覆盖写回」落在对方第几次自增之后,就能让任意数量的成果被抹掉或保住,所以区间里的每个整数都能构造出来。答案不是一个数,是整个闭区间 2 到 100。
图 2:三步分解、交错演示与取值范围。
工程里这就是「永远偏少」的计数器。
多 worker 并行统计请求数、样本数时,如果共享计数器少了原子操作或锁,线上表现就是这道题:总数永远偏少,而且压力越大丢得越多——因为并发把交错窗口放大了。解法(原子类、锁、各算各的最后汇总)这里一句话带过,重要的是诊断直觉:看到统计值莫名偏小,先怀疑丢失更新,再去查自增是不是复合操作。这类 bug 最阴的地方是测试环境常常测不出来——并发度低时交错少,结果看起来是对的,一上量就露馅。
回到笔试现场,答题结构建议:先给出区间而不是单值——最小 2、最大 100;再拆「读、加一、写回」三步说明非原子性;然后讲最小值 2 的交错构造。能把构造过程讲清楚,才说明是真懂而不是背答案。你在生产代码里抓到过计数不准的问题吗,当时是怎么定位到丢失更新的?欢迎分享你的排查过程。