1. 并发Bug为什么难排查:先从内存模型说起
做Java开发的,早晚都会遇到并发问题。但我发现一个很有意思的现象:很多写了三五年CRUD的工程师,遇到线上偶发的数据错乱、死循环、查询结果不一致,第一反应是"服务器有问题""数据库有问题",很少有人会想到,问题可能出在Java内存模型上。
我刚工作那会儿也一样。有一次做一个库存扣减的接口,单机部署一切正常,上线后两台实例一起跑,很快就出现超卖。当时的我很懵:代码明明加了synchronized,为什么还会超卖?后来排查了整整一天才发现,问题根本不在代码块上,而在并发三大特性的理解上——我把可见性当成了原子性来用。
所以这篇我把并发编程最底层的三件事一次讲透:原子性、可见性、有序性。这三件事不搞懂,synchronized、volatile、Lock这些工具你用起来始终是"知其然不知其所以然",面试被追问两句就露馅,线上出了诡异bug也没法定位。
先说这三件事是怎么来的。它们全部源自一个事实:CPU处理数据的速度远快于内存读写速度。为了解决速度不匹配,硬件和编译器搞了两件事:一是在CPU和内存之间加了一层或多层高速缓存(Cache),二是允许指令重排序。这两件事极大提升了运行效率,也把"变量在不同地方读到的值可能不一样""代码执行顺序可能和你写的不一样"这两个并发隐患引了进来。
Java为了屏蔽不同硬件平台的差异,定义了一套自己的规范,就是Java内存模型,简称JMM。JMM规定:所有变量存在主内存(对应物理内存的概念),每个线程有自己的工作内存(对应寄存器或缓存的抽象概念),线程不能直接操作主内存,只能先读入工作内存、操作完再写回。这套抽象模型,是所有并发知识的地基。
有了这个地基,三大特性的问题就变得具体了:多个线程同时对同一个变量做"读-改-写",就涉及原子性;不同线程的工作内存和主内存之间的同步时机,就涉及可见性;编译器和CPU对代码执行顺序的调整,就涉及有序性。下面逐个拆。
2. 原子性:一个操作不可分割,不只是"加锁"这么简单
原子性的定义很好说:一个或多个操作在CPU执行过程中不被中断的特性。但"不被中断"这个词很容易误导人,初学者总觉得"我写的一行代码就是原子操作",这是最典型的认知误区。
2.1 i++为什么不是原子操作
先看最经典的例子:
public class AtomicityDemo { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[100]; for (int i = 0; i < 100; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { count++; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println("count = " + count); } }这段代码开100个线程,每个线程对count自增10000次。你直觉上觉得结果应该是1000000,但跑出来的结果几乎每次都不一样,通常是七十万、八十万左右。
原因很简单:count++在字节码层面至少拆成三条指令——先读取count的当前值,然后加1,最后写回。三个线程完全可能同时读到count=100,各自加1,再各自写回,结果是101而不是103。这就是典型的"读-改-写"非原子操作。
这里要注意一个区分:赋值一个int变量的操作本身是原子的(JVM规范保证了基本类型赋值和引用赋值的原子性),但"读取-计算-写回"这个复合流程不是。很多人面试时被问"i++是不是线程安全的",答"不是"就完了,但面试官往往接着追问"那把这个变量声明成volatile呢,还安全吗"——答案依然不安全,因为volatile管的是可见性,管不了原子性。
2.2 解决原子性的三种工具怎么选
保证原子性,Java里可以用synchronized、Lock接口体系,以及java.util.concurrent.atomic包下的Atomic系列类。
synchronized是最朴素的方案,它通过监视器锁(Monitor)保证同一时刻只有一个线程能进入临界区:
public synchronized void increment() { count++; }JVM对synchronized的优化这些年做了很多,从偏向锁到轻量级锁再到重量级锁的升级路径,锁的粒度越收越细。早期版本中synchronized性能确实不如Lock,但从JDK 6之后,两者差距已经很小,能优先用synchronized就优先用,代码更简洁、不容易出错。
Lock接口(常用实现是ReentrantLock)的优势在于:支持尝试获取锁、超时获取锁、可中断获取锁,以及多个条件变量。这些能力在复杂并发场景下有用,但99%的业务代码用不到,不必为了"看起来高级"而滥用。
Atomic系列类走的是另一条路——无锁方案。底层依赖CAS(Compare And Swap)操作。CAS的思路是:比较当前内存值是否等于我预期的值,如果是,就更新为新值;如果不是,说明被别人改过了,就重试。
private static AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); }CAS适合竞争不激烈的场景,因为竞争激烈时重试次数多,CPU开销反而大。另外Atomic类无法解决包含多个独立变量更新的复合操作问题,比如"先扣款再减库存"这种需要整体原子性的业务操作,还是得加锁。
2.3 一个常被忽略的点:long和double的原子性问题
JMM规范里有一条特殊说明:对于64位的long和double类型,如果未声明为volatile,JVM允许把它的读写拆成两个32位操作执行。也就是说,理论上一个线程可能在写入long变量时写了高32位、还没写低32位,另一个线程就读到了这个"写了一半"的中间值。
不过在主流64位JVM中,long和double的读写实际都是原子的。只有早期32位JVM才容易出现这个问题。但面试官问到的时候,你要能说出这条规范以及主流平台的实际情况,这就比只背概念的人高一个层次。
3. 可见性:变量在另一个线程修改了,为什么我这里看不到
3.1 从一段"永远停不下来"的代码说起
这个问题我用一个实际场景展开。先看这段代码,很多面试八股文里都有类似例子:
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线程退出"); }); worker.start(); Thread.sleep(1000); flag = false; System.out.println("主线程已修改flag为false"); } }你猜这段代码的输出是什么?很多人在自己电脑上跑,发现worker线程根本不会退出,程序一直在运行。这背后的原因就是可见性问题:主线程改了flag的值,但worker线程的工作内存里还缓存着flag的旧值,它看不到主线程的修改。
为什么会出现这种情况?JMM规定线程修改变量时,不直接改主内存,而是先改工作内存,再由某个时机写回主内存。这个"时机"不受你控制。worker线程在循环里每次都从工作内存读flag,读到的始终是旧值,于是死循环。
3.2 volatile的内存语义:不仅仅是"强制读主内存"
解决可见性,最直接的工具是volatile。很多人对volatile的理解停留在"加了volatile就去主内存读取",这个说法不完全准确,但用来理解可见性足够了。
准确地说,volatile做了三件事:
- 写volatile变量时,JVM会把当前线程工作内存中该变量的值强制刷新到主内存;
- 读volatile变量时,JVM会使当前线程工作内存中该变量的缓存失效,必须从主内存重新读取;
- 写volatile变量时,JMM还会通过内存屏障(Memory Barrier)禁止相关指令重排,这也是volatile能保证禁止指令重排的关键机制。
所以对于上面的死循环代码,只要把flag声明为volatile,worker线程立刻就能感知到主线程的修改,循环退出:
private static volatile boolean flag = true;但要强调:volatile解决了可见性,但没有解决原子性。回到前面的count++例子,即便count声明为volatile,多线程自增依然会丢数据。因为volatile不管"读取-计算-写回"这三步的中间过程。这也是面试中最常见的连环坑之一。
3.3 synchronized怎么保证可见性
很多初学者以为synchronized只保证原子性,这是另一个误区。synchronized同样保证可见性,只是它的实现路径不同:线程释放锁时,会把工作内存中的共享变量刷新回主内存;线程获取锁时,会使工作内存中的相关缓存失效、从主内存重新加载。所以synchronized能同时保证进入临界区的线程看到的都是最新值。
举个实际例子,前面库存扣减的问题,如果只用synchronized修饰了扣减方法,但另一个查询库存的方法没有加锁,就会存在"扣减线程改了库存、查询线程看不到"的情况。解决方式是查询路径也加同一把锁,或者用volatile修饰库存变量(前提是库存操作本身能原子化)。
3.4 可见性问题在生产中的经典表现
在这几年的实际项目中,可见性问题的经典场景有这么几类:
- 状态开关变量(如服务降级开关、特征开关)被多个线程读取,但更新后其他线程感知不到——解决方式是volatile;
- 懒加载的单例对象,多线程下拿到半初始化的实例——这同时涉及可见性和有序性,下面详细展开;
- 日志开关、配置刷新场景,配置中心下发新值后,业务线程仍然用旧值——配置类属性一般直接做成volatile或者用AtomicReference持有配置对象。
排查这类问题,有个技巧:尽量不要迷信本地复现。可见性问题跟CPU型号、JVM版本、线程调度时机强相关,本地机器可能怎么跑都正常,线上压测才出现。可以用jstack抓线程栈,看线程当前停在哪里,结合代码分析是否存在共享变量的读写路径。
4. 有序性:代码不一定按你写的顺序执行
4.1 指令重排到底是什么
有序性指的是程序执行的顺序按照代码的先后顺序执行。但在JMM中,为了性能,编译器和CPU允许对指令进行重排序。重排序有个铁律:在单线程内,重排序不能改变程序的运行结果;但在多线程环境下,一个线程的"优化"可能会对另一个线程产生意想不到的影响。
指令重排有三个层面:
- 编译器优化重排:Java编译器在不改变单线程语义的前提下,调整语句执行顺序;
- 指令级并行重排:现代CPU采用流水线技术,多条指令重叠执行,处理器会调整指令执行顺序;
- 内存系统重排:由于CPU Cache的存在,加载和存储操作看起来可能在乱序执行。
这三个层面叠加,问题就变得很隐蔽了。
4.2 双重检查锁定的隐患
来看一个非常著名的有序性问题——双重检查锁定(Double-Checked Locking,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; } }这段代码初看没有问题:第一次检查避免重复加锁,第二次检查避免重复创建实例。但实际上它有一个严重隐患。
instance = new Singleton()这行代码不是原子操作,在字节码层面主要做了三件事:
- 分配内存空间;
- 在内存空间上初始化Singleton对象(构造方法执行);
- 将instance引用指向这块内存。
问题在于:步骤2和步骤3可能被重排。如果线程A先执行了步骤3,instance已经不再为null,但对象还没完成构造。此时线程B第一次检查看到instance不为null,直接return instance,取到的就是一个"半初始化"的对象。这时候如果这个对象的某个字段还没赋值,B线程拿去用,就会出现空指针或读到默认值0的诡异问题。
解决方案有两个方向。一个是给instance加上volatile修饰,利用volatile禁止重排序的内存语义,保证"引用赋值"一定发生在"对象构造完成"之后:
private static volatile Singleton instance;这也是业内目前普遍推荐的做法。另一个方案是使用静态内部类(Initialization-on-demand holder idiom),利用JVM的类加载机制天然保证线程安全:
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }JVM在类初始化阶段会加锁,保证一个类的初始化在多线程环境下只执行一次,而且其他线程会阻塞等待初始化完成。这个方案既延迟加载,又不需要额外操心可见性和有序性,是更优雅的选择。
4.3 volatile如何禁止重排:内存屏障机制
volatile禁止重排的核心是内存屏障。JMM在volatile读写操作的前后插入了内存屏障规则:
- 在每个volatile写操作的前面插入StoreStore屏障,禁止上面的普通写操作与下面的volatile写操作重排;
- 在每个volatile写操作的后面插入StoreLoad屏障,禁止上面的volatile写操作与下面可能有的volatile读/写操作重排;
- 在每个volatile读操作的后面插入LoadLoad屏障和LoadStore屏障,禁止上面的volatile读操作与下面的普通读/写操作重排。
这里不需要死记硬背屏障名称,关键是理解它的作用:屏障两边的指令不能越过屏障交换顺序。打个比方,内存屏障就像快递分拣中心的一条传送带,包裹有严格的先后顺序,前面的包裹不能被后面的包裹插队。
在一个更宏观的层面上,JMM把内存屏障按"读-读、读-写、写-读、写-写"四类来管理,结合volatile和锁,形成了一套有序性保证机制。日常写代码的时候,不需要手动插入屏障,但要知道volatile的约束边界在哪里:volatile只能保证volatile变量本身的读写不会被重排序,不能保证它周边所有操作的有序性。
5. 从Happens-Before规则看三大特性的统一逻辑
讲到这里,三大特性看起来是三个独立的话题,实际上它们都统一在Happens-Before规则之下。Happens-Before是JMM定义的一套偏序关系,用来描述两个操作之间的内存可见性。如果操作A Happens-Before操作B,那么A的执行结果对B是可见的,且A的执行顺序排在B之前。
JMM规定的Happens-Before规则主要有这么几条:
- 程序次序规则:同一个线程中,按代码顺序,前面的操作Happens-Before后面的操作;
- 监视器锁规则:对一个锁的解锁,Happens-Before于后续对这个锁的加锁;
- volatile变量规则:对一个volatile变量的写,Happens-Before于后续对这个volatile变量的读;
- 传递性规则:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C;
- 线程启动规则:Thread.start()方法调用,Happens-Before于该线程中的每一个动作;
- 线程终止规则:线程中的所有操作,Happens-Before于其他线程检测到该线程终止;
- 中断规则:调用线程interrupt()方法,Happens-Before于被中断线程检测到中断事件的发生。
为什么要了解这些规则?因为它们把"何时能保证可见性和有序性"这件事变成了可推导的逻辑。举个例子,你写了一段代码,想知道线程B修改的变量在线程A中能不能看到,不需要猜,只需要看两条操作之间是否满足某条Happens-Before规则。
把Happens-Before和三大特性对照起来看,逻辑就很清晰了。synchronized的加锁解锁,通过监视器锁规则保证临界区的原子性和可见性,同时通过加锁和解锁的顺序保证临界区代码的有序性。volatile通过volatile变量规则保证可见性,通过内存屏障保证有序性,但因为没有锁的存在,无法保证复合操作的原子性。final关键字也有自己的语义:在构造方法中写入final字段,在构造方法外读取这个字段,JMM保证之间有一次"冻结"操作,所以final字段在正确发布的场景下天然具备可见性。
这也就解释了为什么DCL单例必须加volatile才能彻底安全:volatile给instance的赋值操作和对象的构造操作之间建立了一种屏障,利用volatile变量规则,确保读线程看到的instance要么是null,要么是构造完全完成的对象,不会看到中间状态的半成品。
实际排查并发问题的时候,Happens-Before规则的工程价值在于:你可以快速判断某个变量到底需不需要加volatile、需不需要加锁。判断标准就是——两个线程访问同一个共享变量,如果它们之间本来就有Happens-Before关系(比如通过某个锁串联、通过start/join串联、通过volatile写读串联),就不需要额外同步;如果没有,就需要显式加工具建立同步关系。学会了这一招,你的并发代码设计思路会清晰很多,不再是"出了问题加锁"的碰运气式编程。
6. 面试怎么答才能让面试官觉得你真的懂
最后聊聊面试。聊到Java多线程三大特性,最怕的就是背书式回答——"原子性就是操作不可分割,可见性就是多线程访问同一变量时一个线程修改其他线程能立即看到,有序性就是代码按顺序执行"。这种答案背得再流畅,面试官追问两三句就穿帮了。真正有深度的回答应该在哪几个细节上做出差异化?
第一,一定要点出三大特性的由来是CPU缓存和指令重排。这能直接证明你不是死记硬背,而是理解了为什么会有这些问题。可以顺带把JMM的主内存-工作内存模型画出来(面试时用手画),说清楚线程修改变量的完整路径。
第二,要能说出volatile和synchronized的边界差异。很多面试官的经典问法是:"volatile能保证原子性吗?""synchronized能保证可见性吗?"你不仅要回答能或不能,还要给出场景——比如volatile修饰的状态开关为什么够用,但volatile修饰计数器为什么不够用;synchronized为什么能同时管住三件事。
第三,要能结合经典案例讲。DCL单例为什么需要volatile,这里既能聊有序性(指令重排导致半初始化对象),又能聊可见性(volatile的写读语义),是性价比极高的案例。建议准备的时候把字节码层面的instance初始化过程也了解一下,这样讲出来会非常有说服力。
第四,快速说出Happens-Before规则的本质,以及如何用它推导同步需求。能讲清楚这一层,说明你已经把三大特性从"需要记的知识点"升级成了"可以推导的思维框架",这是高级工程师和平庸工程师最明显的分水岭。
把三大特性真正吃透之后,你会发现后面学AQS、ConcurrentHashMap、ThreadLocal都会顺畅很多,因为它们本质上都是在解决"如何用最小的代价保证原子性、可见性、有序性"这个问题。并发编程没有银弹,所有工具都是在这三个约束条件下做取舍,理解了这一点,看源码时就不再是走马观花,而是能看懂设计者为什么选择这个方案、放弃哪个特性。这可能才是学三大特性真正的价值所在。