1. 一次线上偶发Bug,把并发三大顽疾全暴露了
我先讲个真实案例。去年我们有个订单服务,上线后隔三差五收到告警:有个库存扣减接口偶尔会多扣。代码很简单——先查库存,判断足够,然后扣减。
code复制if (stock > 0) {
stock--;
}
单线程下这段代码绝对没问题。但压测一上,你会发现库存偶尔会从100直接跳到97,甚至96。更诡异的是,有的线程明明已经把stock扣到0了,另一个线程读到的居然还是5。
这个Bug我查了两天,最后把问题归成三类:变量不可见、指令重排序、操作不原子。我跟你讲,这三件事就是并发编程里所有"灵异事件"的总根源。理解了它们,你再看什么volatile、synchronized、CAS、锁,全都能串起来。
这篇文章我就按这个思路来拆——先讲每个问题为什么会出现,再讲它们背后的硬件原理,最后才是对应的解决手段。不是光给你结论,是让你以后遇到并发Bug,能有自己的排查思路。
先记住一个核心结论:CPU的缓存机制、编译器的优化机制、指令的执行机制,这三者合谋,让我们的代码看起来"不按剧本来"。 下面一个个说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 共享变量为什么会"凭空消失"——CPU缓存与可见性
2.1 内存访问速度的鸿沟,逼出了多级缓存
先说"看不见"这个事。专业点叫可见性问题:一个线程修改了共享变量的值,其他线程却"看不见"。
根子在CPU和内存的速度差。一个现代CPU的寄存器读写是1个时钟周期,L1缓存约3-4个周期,L2约10-12个,L3约40-50个,而主内存需要几百个周期。如果每次读写都直接访问主内存,CPU大部分时间都在等,性能直接崩。
所以硬件上搞了多级缓存:每个CPU核心都有自己的L1、L2缓存,多个核心共享L3,最后才是主内存。CPU计算时先把数据从主内存读到自己的缓存里,算完再写回。
听上去很合理,对吧?问题就出在这个"自己"上。
2.2 缓存不一致:各核心各看不各的
假设有双核CPU,变量x初始是0,存在主内存里。
- 线程A在核心0上执行 x = 1,它会把主内存里的x读到自己的L1缓存,改成1,然后写回L1。这时候主内存里的x可能还是0,等某个时机才同步。
- 线程B在核心1上执行读x,它去主内存读,拿到的是0。
这就出事了。线程A明明写了1,线程B读到0。不是B的错,也不是A的错,是缓存体系天然就会产生多份数据副本,各核心改的是自己的副本,同步需要时间。
为了解决这个,硬件层面搞了个缓存一致性协议。最典型的叫MESI协议,核心思想是:每个缓存行(cache line)有四种状态——Modified(修改)、Exclusive(独占)、Shared(共享)、Invalid(失效)。当某个核心修改了一个缓存行,它会通过总线广播一个"失效"信号,其他核心看到后把自己那份标记为Invalid,下次读取时必须重新从主内存加载。
听起来协议能解决可见性?对,MESI确实能。但有个前提:协议生效需要时间窗口,而且有优化会打破它。
这就是关键了:为了让CPU流水线跑得更快,硬件引入了Store Buffer(写缓冲)。CPU写变量时,不直接写缓存,而是先扔进Store Buffer,异步刷入L1缓存。读变量时如果Store Buffer里没有,就直接读L1。这个设计是为了避免CPU等总线同步信号——你想想,如果每次写都得等着其他核心确认"我失效了",性能又崩了。
于是出现了一个经典的坑:
code复制volatile无法保证安全——不对,这里应该说的是普通变量。
线程A:x = 1; flag = true;
线程B:while (!flag) {}; 读x;
由于Store Buffer的存在,线程A可能flag写入Store Buffer后还没刷到缓存,而B在另一个核心上反复读内存里的flag,永远是false,死循环。即使flag刷出去了,也不保证x先于flag刷出去——这又牵扯到重排序,我后面细讲。
2.3 主内存、工作内存与Java的内存模型视角
Java里这个概念被JMM(Java内存模型)抽象了一下。JMM规定:
- 所有变量存在主内存。
- 每个线程有自己的工作内存,保存变量的副本。
- 线程对变量的所有读写都必须在工作内存中,不能直接操作主内存。
- 不同线程之间不能直接访问对方的工作内存,传值必须经过主内存。
所以"看不见"翻译成JMM语言就是:线程A改了工作内存里的副本,没同步回主内存;或者同步回去了,但线程B的工作内存里还是旧副本,没重新加载。
C++11的std::atomic、C#的volatile、Go的atomic,本质都是在解决同一件事:让线程对共享变量的修改,能够按照你期望的顺序和时机,对其他线程可见。
这个"可见"不光是"值对不对"的问题,它直接关系到你程序的正确性。比如double-check locking(DCL)单例,如果instance不加volatile,线程A可能已经new完了,但还没有完全构造好(因为重排序),线程B就直接拿去用了,拿到的是半成品。
2.4 实操认知:可见性问题长什么样
我在实际排查中,可见性问题通常有这几个特征:
- 问题在线上偶现,本地压测复现不出来。因为不同机器的CPU架构、核数、负载不一样,缓存同步的时机也不一样。
- 程序跑着跑着状态就错了,但单步调试怎么调都对。调试器往往会暂停线程,或者触发一些额外的同步,恰好掩盖了问题。
- 加个System.out.println就"好了"。println里面有锁,锁会触发内存屏障,间接把变量刷回主内存。这种"好了"是最坑的,它会让你误判方向。
提示:如果你发现"打印一下就没问题,不打印就出Bug",不要觉得是墨菲定律,基本可以断定是可见性或重排序问题。
3. 代码为什么会乱序执行——编译器重排与CPU乱序执行
3.1 重排序到底在排什么
"乱序"问题的学名叫重排序(reordering)。你在代码里写的顺序,不等于CPU实际执行的顺序。
重排序分三层:
- 编译器重排:编译器在生成汇编时,只要不改变单线程语义,就可以调整语句顺序,目的是让指令流水线更顺畅、寄存器利用率更高。
- CPU指令级并行重排:现代CPU是超标量架构,可以同时发射多条指令。它会分析指令之间的数据依赖,如果两条指令互不依赖,就可能交换执行顺序。
- 内存系统重排:即使指令顺序没变,Store Buffer、Invalidate Queue(失效队列)这些异步机制,也会造成"看起来"乱序的效果。
有人可能会问:编译器不怕把程序改错吗?不怕,它的前提是保证单线程语义不变。但在多线程环境下,没人能保证。你只写了两个线程,编译器不知道它们是协作关系,于是它就大胆地重排了。
3.2 一个能跑出"不可能结果"的经典例子
来看教科书级别的例子:
code复制线程A:
a = 1; // 指令1
x = b; // 指令2
线程B:
b = 1; // 指令3
y = a; // 指令4
初值a=0, b=0。直觉上,如果执行完,x=0且y=0是完全不可能的——因为要x=0,线程A的指令2必然先于指令1执行(读b时b还是0,且还没来得及写a);要y=0,线程B的指令4必然先于指令3执行。但两个线程各自主导一段,怎么会发生"两边都先读后写"?
实际上可能。假设线程A重排了指令1和2:先读b(0),再写a(1)。线程B重排了3和4:先读a(0),再写b(1)。两个线程交错,最终x=0, y=0。这个结果在逻辑上完全说不通,但在多线程下它就是能发生。
这就是重排序破坏的"直觉因果"。你在代码里写的顺序,在另一个线程看来完全是乱的。
3.3 DCL单例:重排序的重灾区
聊到重排序,不能不提double-check locking。
code复制public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 这一步有隐患
}
}
}
return instance;
}
}
你以为new Singleton()是"分配内存→执行构造→赋值引用",但对于CPU和编译器,它完全可以变成"分配内存→赋值引用→执行构造"。对单线程来说没毛病,但对另一个线程来说,它可能在第一次检查时看到instance不为null,直接return,然后用一个没构造完的对象。
解法大家都知道:加volatile。为什么volatile能解决?因为volatile有内存屏障,会禁止编译器重排volatile读写前后的指令。具体来说,JMM要求volatile写的后面插入StoreLoad屏障,volatile读的前面插入LoadLoad和LoadStore屏障,这就把new的三步操作锁死了。
3.4 处理器内存模型与内存屏障
从硬件层面看,不同CPU架构对内存序的保证是不一样的:
- x86:属于强内存模型,CPU不会对写后读做重排,但读后读、读后写、写后写可能重排。整体来说行为相对可控。
- ARM/PowerPC:属于弱内存模型,大量允许重排,移动端和服务器ARM上更容易踩坑。
所以Java的volatile必须做成"平台无关的强约束",本质就是编译器根据目标架构插入不同数量的内存屏障指令。
内存屏障有四种:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 禁止该屏障前的读跟屏障后的读重排 |
| StoreStore | 禁止该屏障前的写跟屏障后的写重排 |
| LoadStore | 禁止该屏障前的读跟屏障后的写重排 |
| StoreLoad | 禁止该屏障前的写跟屏障后的读重排,且强制把写缓冲刷掉 |
为什么需要这么多组合?因为你要精确告诉CPU:哪些指令可以并行,哪些必须严格按顺序来。而最昂贵的StoreLoad屏障,连x86都会在执行时带来比较大的性能损失——这也是为什么volatile虽然不阻塞线程,但并不是零成本的原因。
提示:x86的强模型意味着很多在ARM上出现的诡异并发Bug,在x86上复现不出来。如果你做的是跨平台应用,一定别只在本机测过就以为稳了。
4. 不安全:一条i++的背后,藏着三步操作
4.1 原子性的定义与"不是一回事"
第三个问题是原子性(atomicity)。原子操作的意思是:这个操作要么全部执行,要么全部不执行,中间不可能被插入其他操作。
很多人以为count++是一行代码,所以是原子的。完全不是。它在字节码层面至少对应三步:
code复制1. 读取count的当前值
2. 计算count + 1
3. 把结果写回count
两步之间都可能被其他线程插入操作。假设count=5,线程A读到5,还没写回;线程B也读到5,也加1写回6;然后线程A把自己算好的6写回。两次自增,结果却只加了1。
这就是经典的丢失更新问题。并发不一定要极端才出错,只要恰好交错,脏数据就产生了。
4.2 复合操作:比i++更危险的场景
单独一个++还不算最可怕的。真正要命的是复合操作,比如经典的"先检查后执行":
code复制if (account.balance >= amount) { // 检查
account.balance -= amount; // 执行
}
两个线程同时走到检查,都发现余额够,然后都执行扣减,余额就变负了。这种逻辑只靠原子地读写单个变量是救不了的,它需要把"检查"和"执行"合成一个原子块——这就是锁和事务的语义。
还有一种更隐蔽的:不可变对象的发布。比如你在一个线程里构造了一个对象,然后赋给共享引用,另一个线程读取。如果对象的字段没有用final或者没有安全发布机制,可能看到的是部分初始化的字段。
4.3 CPU层面的原子指令
那么,硬件上有没有真正的原子操作?有。
CPU提供了一些原子指令,比如x86的Lock前缀指令(如LOCK CMPXCHG)。lock的作用是锁总线或锁缓存行,保证指令读-改-写期间,其他核心不能对这个内存地址做任何操作。CAS(Compare and Swap)就是基于这个实现的。
Java里的AtomicInteger.incrementAndGet()底层就是CAS自旋。CAS的逻辑是:
code复制if (当前值 == 预期值) {
更新为新值;
return true;
} else {
return false;
}
它把"比较"和"交换"做成一个不可分割的操作,硬件层面保证原子性。但CAS也不是银弹,它有三个出名的问题:
- ABA问题:值从A变成B又变回A,CAS判断"还是A",就认为没人改过。实际上中间改过。可以用版本号(AtomicStampedReference)解决。
- 自旋开销:竞争激烈时,CAS反复失败反复重试,CPU空转,性能反而比锁差。
- 只能保证单个变量的原子性:多个变量的一致性还是得靠锁。
4.4 从"不安全"到"正确":锁、CAS、原子类怎么选
应对原子性问题的方案,从重到轻排列:
| 方案 | 粒度 | 优缺点 |
|---|---|---|
| synchronized / ReentrantLock | 代码块 | 重量级但可靠,能同时解决可见性、原子性、有序性 |
| AtomicInteger等原子类 | 单变量 | 轻量、无阻塞,适合计数器、累计器场景 |
| volatile | 单变量读写 | 只保证可见性和有序性,不保证复合操作的原子性 |
| ConcurrentHashMap等并发容器 | 数据结构内部 | 内部用锁或CAS,你只管调用,不用关注实现 |
我平时有个选型原则:能用并发容器就别自己造锁,能用原子类就别上重锁,只有复合逻辑无法拆分时才用锁。 原子类虽然快,但绝大多数业务场景的瓶颈根本不在锁上,而在网络和IO,用锁反而更容易写对。
5. JMM与Happens-Before:给并发程序的"交通规则"
5.1 光靠感性理解不够,需要有规则
我在上面讲了缓存、重排序、原子性三个问题,但真到写代码时,你不可能每次都去分析底层。这时候就需要一个抽象层——Java内存模型(JMM),它定义了一套规则,让你不用管硬件细节,也能写出正确程序。
JMM的核心不是让你去禁掉所有重排序,而是规定:哪些场景下,一个线程的写操作对另一个线程是可见的,且顺序是确定的。这个场景就是Happens-Before关系。
5.2 六条规则,其实记四个就够用
严格来说,JMM定义了六条Happens-Before规则:
- 程序次序规则:线程内按代码顺序,前面的操作Happens-Before后面的。
- 监视器锁规则:锁的解锁Happens-Before后续对这个锁的加锁。
- volatile变量规则:volatile的写Happens-Before后续对这个volatile的读。
- 线程启动规则:Thread.start() Happens-Before该线程的任何操作。
- 线程终止规则:线程的所有操作Happens-Before其他线程检测到该线程终止(比如join返回)。
- 传递性:A Happens-Before B,B Happens-Before C,则A Happens-Before C。
实际工作中最常用到的是前三条加传递性。
举个例子,你用一个volatile的flag:
code复制线程A:
data = 42; // 普通写
flag = true; // volatile写,释放屏障
线程B:
while (!flag); // volatile读,获取屏障
// 此时读到data一定是42
按照volatile规则和传递性:data = 42 Happens-Before flag = true(程序次序),flag = true Happens-Before while(!flag)发现flag为true(volatile规则),所以data = 42 Happens-Before线程B读data。因此线程B看到的data就是42,不会读到旧值。
这就是volatile背后的真正语义:它不是把变量本身加锁,而是创造出一种跨线程的顺序保证。
5.3 synchronized其实也管可见性和有序性
很多人以为synchronized只管原子性,实际上它是"三项全能"——因为监视器锁规则涵盖了可见性(同一个锁的解锁和加锁形成Happens-Before关系),同时锁内部的代码天然不会被其他持有同一把锁的线程插入,也因为锁的互斥性而不会出现交错。
所以在锁临界区里,你同时解决了三个问题。这也是为什么我在写并发代码时,优先考虑能不能用小范围的同步块,而不是满屏的原子变量——原子的语义是局部安全的,而锁的语义是全局一致的。
5.4 C++和Java在内存模型上的差异
跟我一样搞过C++的应该知道,从C++11开始也引入了完整的内存模型,包括std::atomic、std::mutex、六种内存序(memory_order_relaxed, acquire, release, acq_rel, seq_cst等)。
但有一个关键差异:Java的volatile和synchronized在JMM层面是固定的,而C++把底层控制权完全交给你。C++里你可以用memory_order_relaxed告诉CPU和编译器"这个原子操作不需要任何顺序保证",以换取性能。代价是写起来容易出错,稍有疏忽就触发UB(未定义行为)。
相比之下,Go的原子操作和channel在设计上更偏向简单实用,直接建立goroutine间的同步关系,心智负担比Java小一些。
如果你不是在做无锁队列、lock-free数据结构这类极端性能场景,默认选择最强的内存序(Java的volatile语义、C++的seq_cst)就够了。优化永远发生在正确之后。
6. 亲手复现:从崩溃到稳定的并发排查实战
6.1 一个能跑出问题的最小Demo
光讲理论,你可能还是会觉得抽象。我来给一个能实实在在跑出错结果的Java代码。这个Demo我在各种分享会上用过,从没失手。
code复制public class LostUpdateDemo {
private static int count = 0;
private static final int THREADS = 8;
private static final int LOOPS = 10000;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < LOOPS; j++) {
count++;
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("期望值: " + (THREADS * LOOPS) + ", 实际值: " + count);
}
}
你在多核机器上跑一下,结果会非常神奇:有时候是77823、有时候是80000(碰巧没交错)、有时候是65000。反正几乎不会等于80000。这正好说明count++不是原子的。
把count改成volatile int count再跑,结果几乎不会变好——因为volatile不保证复合操作原子性。改成AtomicInteger或者用synchronized包起来,结果才会稳定等于80000。
6.2 排查工具的正确打开方式
真遇到线上并发Bug,不能只靠"换个关键字试试"。我分享一下我自己的排查链路:
第一步:确认症状是否与并发相关
- 看日志里有没有线程名、线程ID,把多个线程的操作按时间轴还原。
- 判断是偶发还是必现。必现的往往是逻辑死锁或数据竞争;偶现的更可能是可见性或重排序。
第二步:用工具找数据竞争
- Java可以用jconsole/jstack看线程状态,但jstack对数据竞争不敏感。真正专业的工具是jcstress(OpenJDK的并发压力测试框架),它能构造出各种内存交错场景,验证一个类的并发安全性。
- 也可以用ThreadSanitizer(C++/Go),它能直接报告"data race"。
第三步:缩小范围,固定复现
- 把业务线程数加大、循环次数加大,提高碰撞概率。
- 有团队会故意把机器调成大小核混用,增加缓存不一致的触发概率。
第四步:根据三大问题逐一排查
- 变量值异常但不崩:先怀疑可见性。
- 逻辑结果与代码顺序矛盾:先怀疑重排序。
- 数值丢失、少算、重复:先怀疑原子性。
这个思路在Java、C++、Go、Python多线程场景都适用,区别只在于工具不同。
6.3 volatile什么时候不够用
我见了太多新手把volatile当万能药。这里明确给个边界:
volatile能解决的:
- 一个线程写、多个线程读的开关状态(如flag、配置开关)。
- 状态标记配合再检查(如DCL)。
- 保证64位变量(long/double)的读写原子性(其实JMM规定普通long/double的读写可以不原子,但volatile强制原子读写)。
volatile不能解决的:
count++这种读-改-写。if (a > 0) { a--; }这种复合判断。- 同时需要多个变量保持一致性的场景。
凡是"检查后执行"、"先读再写"这种复合操作,老老实实用synchronized或者Lock。用volatile不是优雅,是埋雷。
6.4 一个我踩过的DCL坑,供大家参考
有一年我写一个配置中心的客户端,做了DCL单例:
code复制if (instance == null) {
synchronized (ConfigClient.class) {
if (instance == null) {
instance = new ConfigClient(config);
}
}
}
压测环境跑了一周,一切正常。上线后突然有个服务启动时出现NullPointerException,指向instance.getConfig()。我查了半天,最后意识到是instance初始化时重排序导致另一个线程拿到未构造完整的对象。
修复方案就一行:private static volatile ConfigClient instance;。加上之后问题彻底消失。后来我把项目里所有单例都排查一遍,发现还有两处同样的问题——都是只加了synchronized,没加volatile。这种坑,只有被咬过一次才会长记性。
7. 跨语言视角:为什么Python的GIL下也有并发问题,C++最需要小心
7.1 Python:GIL挡住了原子性,但没挡住可见性
聊Python多线程的人,多半绕不开GIL(全局解释器锁)。GIL保证了同一时刻只有一个线程在解释器里执行Python字节码,所以很多list.append()、dict.setdefault()这种操作在CPython里是原子的——不会被另一个线程插入。
但注意两点:
- GIL不等于所有操作都安全。比如
count += 1,在Python里依然分三步:读、加、写。GIL可能在字节码中间被释放,其他线程就能插进来。所以Python多线程下做计数器累加,一样会丢。 - GIL挡不住重排序和可见性问题吗?其实大部分挡得住——因为GIL本身就是一个巨大的锁,它天然形成了同步屏障。但如果你用
ctypes、numpy这类会释放GIL的库,或者写C扩展,GIL的庇护就没了。
所以Python多线程的并发问题没有Java那么尖锐,但也不是彻底安全。只靠GIL当"安全毯"是不行的,涉及共享状态修改时,该加锁还得加。
7.2 C++:自由度最高,坑也最深
C++11之前,C++在标准层面根本没提多线程内存模型,一切靠编译器实现和平台汇编。从C++11开始才有<atomic>和std::mutex。
但C++给了你太多选择,也等于给了你太多犯错空间。memory_order_relaxed、memory_order_acquire/release、memory_order_seq_cst,级别不同,语义不同。一个常见的误解是:用了std::atomic<int>就等于线程安全。其实不是,只有用seq_cst(默认值)时才是最强顺序保证,换成relaxed以后,编译器和CPU都可以自由重排,只有原子性、没有顺序性。
我之前在公司碰到过一个性能优化任务,有人把一堆std::atomic_flag从默认改成memory_order_relaxed,然后偶发死锁。排查了好几天,最终原因就是:他把本应由acquire/release保证的"先写数据再置flag"的顺序打破了,另一个线程看到flag变了,但数据还没写进去。
C++里我最常用的建议是:普通业务代码一律用默认的seq_cst,只有写lock-free库的人才需要去碰弱内存序。 性能差得不多,但正确性天差地别。
7.3 Go:channel、Mutex和atomic的应用边界
Go的并发原语设计得比较友好。goroutine之间推荐用channel通信,channel本质上在同步点的位置提供了Happens-Before语义。如果不用channel,也有sync.Mutex和sync/atomic。
Go的sync/atomic在文档里明确说了:内存序只是保证原子性,不保证顺序;更复杂的同步得用channel或mutex。很多从别的语言转Go的同事下意识用atomic做状态标志,其实在Go里更地道的做法是channel + select,或者直接mutex,代码反而更清晰。
7.4 语言对比表:一个表看清三种语言怎么管并发
| 维度 | Java | C++ | Python (CPython) |
|---|---|---|---|
| 内存模型 | JMM,语言级定义完整 | C++11 memory model,C++11开始才有 | GIL + 解释器实现决定 |
| 可见性 | volatile、synchronized、Lock | std::atomic(六种内存序) | GIL自身提供大部分可见性保证 |
| 原子性 | AtomicInteger、synchronized | std::atomic | GIL保证部分字节码原子,但不万能 |
| 重排序防护 | volatile、锁规则 | memory_order | GIL间接提供,C扩展时失效 |
| 推荐默认方案 | synchronized或并发容器 | 默认seq_cst,尽量少碰弱内存序 | mutex或queue,避免依赖GIL |
这张表不是让你死记硬背,而是帮你建立"每种语言的并发语义都不同"的敏感度。换语言时,别把旧语言的假设直接搬过去。
8. 别把"偶尔正常"当成"并发安全"——我的实践感悟
最后,我想聊聊经验层面的东西。
我见过太多团队处理并发Bug的方法是"加个sleep"、"重试几次"、"打印一下"——这些都属于掩盖问题,不是解决问题。因为并发问题就像沙子里的水,你按住一个地方,它就从另一个地方渗出来。
我自己这两年养成了几个习惯:
第一,凡是有共享可变状态,一律先考虑用不可变对象或线程封闭。 如果数据能被设计成不可变的,那可见性、原子性、重排序问题全部消失。这不是逃避,是最好的策略。
第二,用最重但最正确的方案起步,再根据性能测试决定是否优化。 很多新手上手就搞无锁、搞原子变量,结果Bug率飙升。正确用synchronized、用Lock,并发性能其实已经够强。真到了需要无锁的场景,你会有性能监控数据来支撑这个判断。
第三,把并发问题当作系统性风险来管理。 上线前的压测、并发场景下的日志链路、某些关键共享变量的监控,都要提前设计。等线上出问题再补,成本是十倍百倍。
第四,遇到诡异问题,先怀疑并发,不要先怀疑编译器或JDK。 我排查过的案例里,99%的"编译器Bug"最后都是程序员自己的数据竞争。编译器极少出错,你的内存模型理解出错的可能性大得多。
回到标题的三个词——看不见、乱序、不安全,它们其实是同一个底层现实的不同侧面:现代硬件和编译器为了性能,做了大量我们感知不到的优化,而这些优化在单线程下是良性的,在多线程下却可能破坏程序语义。
理解它们,不是让你去记住某个API,而是让你记住一个原则:多线程共享可变状态,必须有明确的同步手段,否则程序的每一步都可能出乎意料。 以后你写并发代码或者排查并发Bug时,脑子里能自动浮现这三件事——可见性、重排序、原子性——然后再往下想对策,你就已经比大多数靠试错写代码的人高出一个维度了。
