深入理解JMM:volatile与CAS是并发安全的两把钥匙

简介: JMM(Java 内存模型)是并发编程的地基。本文用一个死循环 bug 讲透可见性、有序性、原子性三大特性,代码可直接运行,附内存模型与 CAS 示意图。

大家好,我是晚安code。

这篇我按「并发三大特性」的线给你捋:先讲 JMM 定的规矩,再讲 volatile 为什么只管得动两样、CAS 又是怎么把最后一样补上的。代码我基于 JDK 8 跑过(2026 年 8 月实测),你在 JDK 17+ 上结果一样。面试问这块,看完你能接住追问。

一、JMM:并发安全

并发 bug 的根源,几乎都出在「工作内存和主内存之间拷贝不及时」这八个字上。

JMM(Java 内存模型,Java Memory Model):Java 虚拟机规范里约定的一套内存规则,规定线程怎么读写共享变量、什么时候能看到最新值。你可以把它理解成多线程世界的「交通规则」,不守规矩就要撞车。

JMM 把内存拆成两块:

  • 主内存:所有共享变量都住在这里,所有线程共享这一份。
  • 工作内存:每个线程一块独享的副本,线程只能在这块副本上动手,不能直接碰主内存。

所以两个线程之间的值传递,永远得绕主内存走一圈。绕得慢,就会读到旧值。

围绕这套「主内存 ⇄ 工作内存」,JMM 还约定了三条同步规则,这也是 synchronized 的语义来源:

  1. 线程解锁前,必须把共享变量的新值刷回主内存。
  2. 线程加锁前,必须从主内存把最新值读回工作内存。
  3. 加锁和解锁得是同一把锁。

图 1 就是主内存和工作内存的关系:两个线程各抱各的副本,唯一的通道就是主内存,线程之间谁也看不见谁的副本。

JMM 内存模型:主内存与两个线程工作内存之间通过 read/load、store/write 同步,线程间看不见对方副本

二、特性①可见性:volatile 的可见性靠什么保证

一个变量不加 volatile,多线程下你可能读到「过期值」——这是并发面试的第一道送分题。

public class StopThreadDemo {
   
    // 去掉 volatile,这个程序会一直空转,停不下来
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
   
        Thread worker = new Thread(() -> {
   
            while (running) {
    /* 模拟干活 */ }
        });
        worker.start();

        Thread.sleep(1000);
        running = false;   // 主线程改标记,想让 worker 停下
        System.out.println("main 已把 running 改为 false");
    }
}

worker 看不见这个改动,是因为它工作内存里的 running 副本还是 true。volatile 干的事就两件:写的时候强制把新值刷回主内存,读的时候强制从主内存重新拿。加上它,这个程序才能正常退出。

volatile 最典型的用法就是「状态开关」:一个线程改、其它线程读。这类「一写多读」场景用它最划算,不加锁也不阻塞。

volatile 就是那座架起鸿沟的桥——写回主内存、读自主内存

三、特性②有序性:指令重排与内存屏障

volatile 的第二个能力是禁止指令重排,底层靠内存屏障实现——相当于给代码排队装了两道铁栏杆。

你写的代码,编译器和 CPU 为了提速会偷偷调换执行顺序。单线程无所谓(结果一样),多线程就可能出事。看这个经典例子,a、b、x、y 初始都是 0:

// 线程A:        // 线程B:
x = a;            y = b;
b = 1;            a = 2;

正常跑下来 x=0、y=0。但如果两边都重排成「先写再读」:

// 线程A 重排后:  // 线程B 重排后:
b = 1;            a = 2;
x = a;            y = b;

就可能出现 x=2、y=1 这种按顺序执行永远到不了的怪结果。

内存屏障(Memory Barrier):一条 CPU 指令,作用有两个——保证屏障前后的指令顺序不被调换,顺便强制内存可见性。volatile 的读写前后都会插屏障,把重排的路堵死。

屏障两侧的指令不能互相越界,顺序就稳住了

可能有人会问:DCL 单例为什么必须加 volatile?

instance = new Singleton() 看着是一行,其实有「分配内存 → 初始化对象 → 指向引用」三步,不加 volatile 可能被重排成先指向引用再初始化,别的线程就会拿到一个半成品。volatile 禁止重排,保证对象完整发布后再被别人看到。

四、特性③原子性:volatile 不保证原子性,CAS 来补

volatile 不保证原子性——num++ 压根不是一条指令,是「读-改-写」三步,中间随时可能被插队。

public class AtomicityDemo {
   
    private static volatile int count = 0;

    static void add() {
    count++; }   // 读 -> 改 -> 写 三步

    public static void main(String[] args) throws InterruptedException {
   
        for (int i = 0; i < 20; i++) {
   
            new Thread(() -> {
   
                for (int j = 0; j < 1000; j++) add();
            }).start();
        }
        Thread.sleep(3000);   // 简化写法:睡到所有线程跑完
        System.out.println("count = " + count);   // 永远不是 20000
    }
}

我第一次跑这个,count 出来是 18938,怎么都想不通为啥不是 2 万。后来才明白:volatile 只保证「看得见最新值」,不保证「读写这个过程不被切开」。两个线程同一瞬间都读到 count=5,都加 1 再写回,就互相覆盖了一次。

缺口怎么补?两条路:加锁,或者用原子类。

CAS(Compare And Swap,比较并交换):一种无锁的原子操作,只有当前内存值等于我预期的旧值时,才把新值写进去;不等就重试。你可以把它理解成「乐观锁」——先相信没人抢,动手前再验一次货。

把上面 demo 换一行就能修好:

AtomicInteger count = new AtomicInteger(0);
count.getAndIncrement();   // 原子 +1,底层就是 CAS

同样 20 线程跑完,结果稳定 20000。

CAS 的底层是 Unsafe 类。Unsafe 类:Java 留的一个能直接操作内存的「后门」,CAS 的 native 方法都是它提供的。AtomicInteger 的 getAndIncrement 一路调到 Unsafe,核心逻辑就是自旋——比不对就一直重读再比:

// Unsafe.getAndAddInt 简化逻辑
public final int getAndAddInt(Object o, long offset, int delta) {
   
    int v;
    do {
   
        v = getIntVolatile(o, offset);                    // 1. 读当前值
    } while (!compareAndSwapInt(o, offset, v, v + delta)); // 2. 比得上就写,比不上继续转
    return v;
}

CAS 的「转圈」长图 6 这样:读 → 比 → 不行就重读,直到成功。

CAS 自旋流程:读当前值、与预期值比较,不相等就重新读继续尝试,相等才写入新值

五、CAS 的坑:ABA 问题与原子引用

CAS 有个天然盲区叫 ABA 问题——值绕一圈回到原点,CAS 就以为没人动过,其实中间已经悄悄变了两次。

ABA 问题:线程 1 读到一个值 A,线程 2 把它改成 B 又改回 A,线程 1 拿手里的 A 去比对,发现还是 A,直接更新成功——可中间其实被改过两回了。这就是成语里的「狸猫换太子」。

你品一下这个场景:收货时验货单上写着「货:A」,你验完满意签收。可快递员中途把货换成 B 又换回 A 了——你签收的确实是 A,但中途确实被掉过包。这种「看着没变、实际动过」的情况,CAS 完全看不出来。

解决办法是给值配个版本号,CAS 时值和版本号一起比:

public class AbaFixDemo {
   
    // 值 + 版本号(stamp) 一起管
    static AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(2020, 0);

    public static void main(String[] args) {
   
        int[] stamp = new int[1];
        Integer cur = ref.get(stamp);   // 一次拿回「值 + 版本号」

        // 别的线程:2020->2021->2020,版本号 0->1->2
        ref.compareAndSet(2020, 2021, 0, 1);
        ref.compareAndSet(2021, 2020, 1, 2);

        // 我们期望版本还是 0,比对失败,ABA 被拦下
        System.out.println(ref.compareAndSet(2020, 6666, 0, 3));  // false
    }
}

AtomicStampedReference(原子引用):给引用值配一个版本号戳,CAS 时值和版本号都得匹配才更新,用来识别「先变后恢复」的过程变化。你可以理解为「验货时不但看货,还看货单上写的是第几版」。

只比对值,认不出被调包;加上版本号戳,一眼识破

用它有个坑:别分开调 getReference() 和 getStamp(),两次调用之间状态可能已经变了,要像我上面这样用 get(stampHolder) 一次把值和版本号都拿回来。

可能有人会问:CAS 自旋一直失败会怎样?

会一直空转烧 CPU。自旋是拿 CPU 换等待,竞争激烈时线程一多,一圈圈空转反而更慢,这时候该退回悲观锁(synchronized / Lock)。

六、总结:一张表看清 volatile、CAS、synchronized

volatile、CAS、synchronized 不是三选一,是三个维度各管一摊——能分清谁管什么,面试问并发就不心虚。

特性 volatile CAS(原子类) synchronized
原子性 不保证 保证(单变量) 保证
可见性 保证 保证(靠底层 volatile) 保证
有序性 保证(禁重排) 部分 保证
代价 无阻塞 自旋可能空转 线程阻塞

一句话收束:并发安全 = 可见性 + 有序性 + 原子性,JMM 定规则,volatile 管前两样,CAS 补最后一样。下次再被问「谈谈你对 volatile 的理解」,先抛这个框架再往下拆,稳的。


我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:学并发的时候,你是先啃 JMM 还是先背 CAS?有没有在哪一步卡了很久?

目录
相关文章
|
24天前
|
机器学习/深度学习 人工智能 缓存
Grok 4.6:追平 GPT-5.6 Sol 的半价旗舰,但别只看跑分
Grok 4.6是xAI于2026年8月发布的1.5万亿参数大模型,专注长程智能体任务,AI指数61分追平GPT-5.6 Sol;回合效率领先(仅需53步),定价$2/$6(百万Token),但超20万Token即翻倍。
|
21天前
|
人工智能 机器人 程序员
上下文工程是什么?从提示词工程到上下文,一文讲透 AI 不跑偏
上下文工程是提示词工程后的新范式:AI 没有记忆,回答质量全看塞进上下文的"料"。本文讲透两者区别,也让 AI 学会自己记笔记、压缩对话,长任务不跑偏。
103 1
|
19天前
|
缓存 人工智能 5G
DeepSeek 涨价后怎么办:峰谷错峰省一半,小米多模态模型可平替
DeepSeek 涨价 8 月 17 日生效,最高涨 1100%。看懂峰谷计价、错峰调用能省一半;预算还紧,小米 MiMo 多模态模型可以平替。
231 0
DeepSeek 涨价后怎么办:峰谷错峰省一半,小米多模态模型可平替
|
22天前
|
JSON 人工智能 API
Function Calling 会被 MCP 取代吗?理清两者关系与使用细节
Function Calling 会被 MCP 取代吗?不会。本文讲透它的调用机制与使用细节,说清两者分层关系:一个是机制,一个是协议。
156 0
Function Calling 会被 MCP 取代吗?理清两者关系与使用细节
|
24天前
|
人工智能 安全 数据可视化
守护数字世界的“探照灯”:揭秘公共互联网反网络钓鱼工作组
公共互联网反网络钓鱼工作组成立于2024年,由工信部网安局指导、CNNIC牵头,联合百余家银行、安全厂商等单位,构建全国一体化反钓鱼协同处置平台,通过标准制定、情报共享、宣教培训与国际合作,筑牢数字信任防线。(239字)
64 0
|
24天前
|
缓存 人工智能 JSON
DeepSeek V4 Pro转正,性能逼近Fable 5,价格仅六十分之一
DeepSeek V4 Pro正式版发布:1M上下文、384K输出,价格只有Fable 5的六十分之一。本文从价格、能力、跑分可信度拆解,说清该不该切。
279 0
|
1月前
|
运维 Java 调度
XXL-JOB 分布式定时任务框架:任务分片、失败重试、调度中心一次讲透
单机 @Scheduled 扛不住分布式定时任务?拆解 XXL-JOB 调度中心与执行器架构、任务分片、失败重试机制,附 30 分钟接入示例。
219 0
XXL-JOB 分布式定时任务框架:任务分片、失败重试、调度中心一次讲透
|
2月前
|
自然语言处理 关系型数据库 MySQL
Elasticsearch 入门教程:核心原理、Docker 部署与中文搜索实战
适合想快速上手 Elasticsearch 的后端开发者和搜索服务初学者。读完你会理解 ES 为什么搜得快、怎么在本地搭一套完整环境、怎么写查询、以及怎么把 MySQL 数据同步到 ES。
482 2
Elasticsearch 入门教程:核心原理、Docker 部署与中文搜索实战
|
1月前
|
机器学习/深度学习 人工智能 缓存
DeepSeek V4 Flash 对标 Gemini 3.6,AI 大跑毒时代
DeepSeek V4 Flash 0731 上线公测,智能指数追平 Gemini 3.6 Flash,价格仅其零头,拆解「大跑毒时代」谁先出局。
376 0
DeepSeek V4 Flash 对标 Gemini 3.6,AI 大跑毒时代
|
3月前
|
前端开发 Java Nacos
Nacos 注解全解析:7 个核心注解 + 5 个生产踩坑清单(2026 实测)
Nacos 注解用错,是 90% 微服务 Bug 的根源。本文把注册发现、配置热更新的 7 个核心注解、动态刷新原理和 5 个生产踩坑一次讲透,附可运行代码。
483 2
Nacos 注解全解析:7 个核心注解 + 5 个生产踩坑清单(2026 实测)