☰
Java并发编程核心:原子性、可见性、有序性与JMM实战解析
2026/10/13 20:14:49 网站建设 项目流程

1. 并发究竟在解决什么问题:先搞清楚线程安全为什么难

很多同学第一次接触 JUC 并发编程,都是从背八股文开始的:进程和线程的区别、start 和 run 的区别、sleep 和 wait 的区别……这些基础概念当然重要,但如果你真去写并发代码,会发现最大的难点根本不在这些名词上,而在于“你根本想象不到代码会以什么样的顺序被执行”。

先讲一个我实际接手过的案例。某个后台服务里有段很“经典”的代码:一个计数器,多个线程并发累加。负责人理直气壮地说加上了 synchronized,肯定安全,结果压测的时候数据就是不对。后来查下来,他发现锁加在了方法内部的局部变量上——每次进入方法都是新对象,锁了个寂寞。这类问题并不罕见,根源就在于很多人背了概念,却不知道每个并发工具到底解决的是哪一类问题。

在我看来,并发编程要解决的核心问题其实只有三个大类:原子性、可见性、有序性。这三件事也是 JUC 所有工具、所有关键字存在的根本理由。

  • 原子性:一个操作或者多个操作,要么全部执行且执行过程中不被任何因素打断,要么全部不执行。典型例子就是count++,它不是原子操作,拆成“读、加、写”三步,线程交替执行就丢了更新。
  • 可见性:当一个线程修改了共享变量时,其他线程能否立刻看到这个修改。现代 CPU 有多级缓存,线程读到的可能不是最新值。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。但编译器和 CPU 为了优化,可能会对指令进行重排。

这三个特性并不是独立存在的,它们经常交织在一起。比如经典的“双重检查锁单例”,synchronized 保证了原子性,但如果不加 volatile,有序性问题(对象实例化时的指令重排)就会导致另一个线程拿到“半个对象”。

搞清楚这三个特性的定义之后,你再看 JUC 包里的东西,会发现它们都是对症下药的:synchronized 和 Lock 解决原子性(顺带通过锁的机制也解决了可见性),volatile 解决可见性和有序性,CAS 走的是乐观锁路线,ThreadLocal 则是直接从“不共享”角度回避问题。

很多人在准备并发面试时,只背概念,不做归因,导致面试官换个说法就答不上来。我建议你把每一个并发问题都归结到“三特性”上,弄清楚每个工具分别解决什么,这样不仅面试能过,写代码时也能少出很多幺蛾子。

2. 从 CPU 缓存到 JMM:理解内存模型是理解并发的底座

聊并发三特性很容易停留在抽象层面,你可能会觉得“原子性好理解,可见性为什么这么难?”要真正理解可见性,你必须先知道现代计算机的存储结构和 Java 内存模型的关系。

多核 CPU 的缓存结构大致是:每个核心都有一级缓存和二级缓存(每个核独立),再共享一个三级缓存,然后才到内存。当 CPU 执行指令时,它不会每次都从内存读数据,而是先从缓存里找。如果多个核缓存了同一个变量的副本,某个核改了之后,其他核的缓存不失效,就会读到旧值。

Java 为了屏蔽不同硬件和操作系统的差异,定义了自己的内存模型规范,即 JMM(Java Memory Model)。它的规矩是:

  • 所有共享变量存在主内存中(对应物理内存的概念)
  • 每个线程有自己的工作内存(对应 CPU 缓存、寄存器等),线程对变量的读改写都必须在工作内存中完成
  • 线程之间不能直接访问对方的工作内存,只能通过主内存交互

所以,如果线程 A 修改了变量 i,线程 B 的 i 依然保存在自己的工作内存中,它是无法感知 A 的修改的。这就是可见性问题最朴素的来源。

用生活类比的话:你和同事在同一个文档协作平台(主内存)上编辑同一份文档,但你俩各自的本地缓存里都有一份副本。你把某一处改成了新版本并保存到了平台,可同事的本地副本还是旧的,他就看不到你改的东西。除非平台通知他重新拉取,或者你们的编辑软件做了实时同步。

JMM 通过volatile关键字来规定:用 volatile 修饰的变量,线程每次使用前都必须回主内存读取最新值,每次修改后也必须立即刷回主内存。所以 volatile 解决的就是可见性问题。

这里我要给个重要提醒:volatile 只保证可见性,不保证原子性。多线程同时对同一个 volatile 变量做count++,照样会丢数据。这是因为“读最新值”和“写新值”已经是两次独立操作了,中间线程一样可能被切走。很多初学者觉得“volatile 万金油”,实际上它必须用在对的场景——典型的场景是状态标志位、单例对象引用、基于 CAS 的原子类的内部 value 字段。

另外,JMM 还有一个很重要的东西叫happens-before 规则。它是判断两个操作是否可见的规则集,面试八股里常考,但很多人只是背名字。简单说,如果 A happens-before B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。包括程序顺序规则、监视器锁规则(解锁 happens-before 后续加锁)、volatile 变量规则(写 happens-before 后续读)、线程启动规则、线程终止规则等。

搞懂这一点,很多看似玄学的并发问题就能解释通了:为什么 synchronized 块里读到的变量一定是新的?因为管程锁规则里,释放锁动作 happens-before 后续获取同一个锁的动作,所以临界区内部相当于被“隔离保护”了,天然具备可见性。

3. synchronized 与 volatile 的使用边界:不是所有场景都该上锁

面试基础题里最高频的就是“synchronized 和 volatile 的区别”,但很多回答都是干巴巴罗列条目。我从实际代码设计的角度聊聊,什么时候用什么。

synchronized 是阻塞式同步。它的特点就是重操作,一旦某个线程拿到锁,其他想拿锁的线程只能阻塞等待,直到锁被释放。Java 底层对 synchronized 做过很多优化,比如偏向锁、轻量级锁、重量级锁的升级路径,不同锁状态对应不同的获取方式。虽然现代 JDK 里 synchronized 性能已经不输给 ReentrantLock,但它本质上是“互斥串行化”的思路——同一时刻只允许一个线程执行临界区。

volatile 是非阻塞式同步。它不锁任何东西,不阻塞任何线程,只做两件事:保证可见性,禁止指令重排。它的好处是开销极小,但代价是它管不了多步骤复合操作的原子性。

基于这两个核心差异,我总结出几个实际选择原则:

对比维度volatilesynchronized
线程阻塞不阻塞会阻塞等待锁
原子性保障不保证保证临界区原子性
可见性保障保证保证
指令重排禁止保证保证(加锁和解锁两侧也有边界)
性能开销极小相对更大(现代 JDK 已优化)
典型应用状态标志、单例 DCL多步更新、复合操作、资源池管理

举个非常典型的场景对比:状态标志位和多步更新。

状态标志位,比如一个线程负责启动后台处理,另一个线程负责接受停止命令,那么用一个 volatile boolean 字段当开关旗标就够了:

public class TaskRunner { private volatile boolean running = true; public void stop() { running = false; } public void run() { while (running) { // 执行任务主循环 } } }

这里用 volatile 而不是 synchronized 的原因很直接:主循环轮询不需要阻塞,用锁反而会拖慢整个执行流程;而且running = false是单步写入,不涉及复合操作,天然原子。反过来看多步更新,比如转账的余额扣减 + 流水记录 + 对方余额增加,这三步必须是一个不可分割的整体,任何一个中间状态都不允许被其他线程看到,这种场景就必须用 synchronized 或者 Lock:

public synchronized void transfer(Account from, Account to, int amount) { from.balance -= amount; to.balance += amount; // 写流水、做校验…… }

还有一类场景要特别警惕:多个锁的顺序问题。如果你的转账方法里同时锁定了两个账户,线程 T1 锁 A 再锁 B,线程 T2 锁 B 再锁 A,就会互相等待,形成死锁。JUC 包里有专门定位死锁的工具,比如 jstack 可以打线程快照,jconsole也可以直接检测到死锁。实际编码时更推荐的做法是锁粒度尽量小、锁顺序全局一致,或者用ReentrantLock的tryLock()方法加超时时间避免无限期等待。

4. 线程的生命周期与核心方法:抛开八股,看它们真正控制什么

线程这一块是并发基础里最基本的,但面试和实际使用之间存在一道明显的坎。背八股的同学能脱口而出六种状态(NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED),但真要问“什么操作会让线程进入 WAITING 状态,又靠什么唤醒”,往往就卡住了。这个问题我觉得值得展开讲,因为你在理解生产代码里的协作机制时,时刻都要面对它。

Java 线程状态转换的核心逻辑其实很简单:

  • NEW:创建后还没调用 start()
  • RUNNABLE:调用 start() 后,等待 CPU 调度。注意这里它已经在运行状态上了,操作系统层面的 ready 和 running 在 Java 这边统一归为 RUNNABLE
  • BLOCKED:想进入 synchronized 代码块但锁被其他线程持有,等待锁
  • WAITING:主动调用了 wait()、join()、park() 等方法,进入无时间限制的等待
  • TIMED_WAITING:调用了 sleep(ms)、wait(ms)、join(ms) 等方法,有超时时间的等待
  • TERMINATED:run() 方法正常执行完,或中途抛出未捕获异常退出

这几个状态之间的转换,才是并发协作机制的精髓。

先说Thread.sleep() 和 Object.wait() 的区别。这是面试必问的。sleep 不会释放任何锁,纯粹是“睡一会儿”;wait 的语义则是“我暂时不满足条件了,放弃锁,等别人唤醒我”。所以 wait 必须在 synchronized 代码块中调用,否则就违背了“放弃锁”的前提。而且 wait 之后线程进入的是 WAITING 或 TIMED_WAITING,而不是 BLOCKED。

另一个容易搞混的是join() 和 CountDownLatch。join 的语义是“等待某个线程终止”,底层实现其实是 main 线程调用t1.join()时,进入 wait 循环直到 t1 终止。如果你有三个线程并发执行,想等它们全部结束后再汇总结果,直接调用三个 join 按顺序执行即可。CountDownLatch 更像是“倒计时门闩”,每个线程完成工作后调用countDown(),主线程 await 直到计数归零,实现比 join 更灵活,因为你可以控制“哪几步完成后再继续”。

还有interrupt() 和中断响应机制。Java 的中断不是强杀线程,而是协作式地“标记中断状态,并让阻塞中的线程被唤醒”。调用thread.interrupt()后,如果目标线程正在 sleep/wait/join,它会被唤醒并抛出 InterruptedException;如果线程正在执行普通代码,它只会被设置中断标志位,需要代码里自己判断Thread.interrupted()或者isInterrupted()来决定后续行为。生产代码里这是优雅停止工作线程的常用套路:

while (!Thread.currentThread().isInterrupted()) { // 处理任务 }

之所以强调这些,是因为你在写线程池的时候,shutdownNow()内部就是靠 interrupt 来中断正在执行的任务的,如果任务代码没有合理响应中断,线程池没办法立即停掉它,只能等其自然结束。

5. start() 与 run() 的底层差异:为什么面试官死磕这个看似简单的问题

“调用 run() 和调用 start() 的区别”算是最基础的八股题,但很多人答得流于表面:start 是启动新线程,run 是执行当前线程里的方法。要答得让面试官眼前一亮,就得深入底层。

在 HotSpot 虚拟机中,start() 的核心链路是:Thread.start()→start0()(JNI 方法)→ JVM 内部创建操作系统线程,并调用thread_entry→ 最终回调 Java 的run()方法。也就是说,只有 start() 才会触发 JVM 创建新的操作系统线程,run() 只是一个普通方法,在调用它的线程里同步执行。

因此:直接thread.run()本质上是把多线程编程当作单线程串行执行,完全没有并发效果。这通常不是调用者有意为之,更多是从Runnable里拿了run方法直接调用的误操作,压测环境里这类“假并发”还很不好排查——你看到输出日志顺序是正常的,线程状态监控里却只有一个 RUNNABLE 线程在跑。

更有意思的是,Thread 类本身也是实现了 Runnable 接口的。如果用匿名内部类创建一个自定义 Thread,并重写了它的run()方法,那你传进来的 Runnable 参数其实不会被执行。因为 Thread 的默认run()方法是“如果 target(传入的 Runnable)不为空,就执行 target.run()”,而一旦重写了 run(),target 就被丢到一边了。这是初学时很容易踩的坑:

// 错误示范 Thread t = new Thread(() -> System.out.println("runnable 实现")) { @Override public void run() { System.out.println("子类覆盖实现"); } }; t.start(); // 只输出"子类覆盖实现"

所以在实际代码规范里,我一般都推荐优先使用实现 Runnable 或 Callable 的方式,而不是继承 Thread。理由有三点:

  • Java 单继承,继承了 Thread 就没法继承别的类
  • Runnable 把任务和线程分开,线程池里复用的都是 Runnable/Callable 任务
  • 继承 Thread 容易在不经意间重写掉默认行为,产生隐蔽 bug

Callable 和 Runnable 的区别也值得放在这里一起说。Runnable 的 run() 没有返回值,也不能抛受检异常;Callable 的 call() 可以有返回值,可以抛异常。如果你想在线程里跑一段逻辑,然后把计算结果交回主线程,用 Callable + FutureTask 是标准姿势:

Callable<Integer> task = () -> computeResult(); FutureTask<Integer> futureTask = new FutureTask<>(task); new Thread(futureTask).start(); Integer result = futureTask.get(); // 阻塞等待结果就绪

这种方式在并发计算、批量任务拆解时非常实用。Runnable 更像“只做事不汇报”,Callable 则是“做完事要交差”。现代 Java 里主推的是通过线程池提交 Callable 任务,返回 Future 对象,后续我们从源码层面聊 AbstractExecutorService 的时候再展开。

6. volatile 的底层内存屏障:从字节码到 CPU 指令的完整链路

前面说 volatile 保证可见性和有序性,但这是 JMM 规范层面的表述。要深入理解 volatile,必须看它到底在底层做了什么:内存屏障。

用 javap 反编译一段 volatile 字段的赋值代码,你会看到它对应的字节码和普通字段赋值几乎一样(都是 putfield),但 JVM 在生成机器指令时,会在 volatile 写操作的后面插入一个StoreLoad 屏障,在 volatile 读操作的后面插入一个LoadLoad + LoadStore 屏障。

这里的关键是 CPU 的内存屏障指令,x86 平台下的经典组合是lock前缀指令。lock; addl $0, 0(%%rsp)这类空操作在 x86 上是一个全功能的内存屏障。它的效果有两层:

  • 强制将对缓存的修改立即写回主内存,并让其他核心里缓存的对应数据失效
  • 禁止屏障两侧的指令越过屏障重排

这也就解释了为什么 volatile 能保证有序性——不是因为 volatile 变量本身不被重排,而是 JVM 通过内存屏障限制住了它周围的指令排序。拿经典的DCL(双重检查锁)单例来说:

public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); } } } return instance; } }

如果不加 volatile,instance = new Singleton()这行会被编译器拆成三步:分配内存、调用构造器初始化、把引用赋值给 instance。指令重排后,可能先把引用赋给了 instance(此时内存还没初始化完成),另一个线程第一次检查时发现 instance != null,直接 return 拿走了半成品对象。volatile 在这里限制了“引用赋值”不能越过“构造器初始化”被提前执行。

读 volatile 变量的内存屏障同样重要。你读一个 volatile 变量时,后续的普通读操作都会被限制在屏障之后执行,这样就能保证:先读到共享变量的最新值,再去读其他相关变量时,不会因为本地工作内存里的旧副本而错乱。

这里要顺带提一个很实用的排查技巧:你可以通过 JDK 自带的-XX:+PrintAssembly参数查看 volatile 相关指令的汇编码,但前提是本地安装有 hsdis 插件。如果没有,也可以用-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly组合观察。不过实际工作中我更推荐用 jcstress 这个并发正确性测试工具,它可以在各种 CPU 架构和 JVM 配置下自动跑并发压力测试,帮助验证 volatile 或者 CAS 的用法是否正确。很多并发 bug 在一种架构上表现出来,在另一种架构上可能几周都不触发,手写多线程测试很难覆盖全面。

7. CAS 与自旋:乐观锁思想在并发底层的第一次落地

如果说 synchronized 是 Java 并发世界里的“悲观锁之王”,那 CAS(Compare And Swap)就是“乐观锁鼻祖”。理解 CAS,你才真正理解 JUC 里一大票原子类、无锁数据结构的设计起源。

CAS 的操作语义非常朴素:比较并交换。给定一个内存地址 V,一个期望值 A,一个新值 B,只有当 V 中当前值等于 A 时,才把 V 更新为 B;否则不做任何操作。整个比较和交换的过程是硬件级别的一条原子指令,所以它能做到不加锁却安全地更新共享变量。

Java 层面,Unsafe类提供了compareAndSwapInt、compareAndSwapLong、compareAndSwapObject等 native 方法,JUC 的原子类(AtomicInteger、AtomicLong、AtomicReference 等)全部封装在这之上。以 AtomicInteger 的incrementAndGet()为例,它的底层就是死循环 + CAS:

public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) { return next; } } }

如果 compareAndSet 失败,说明在读取 current 和更新之间,有其他线程抢先改了这个值,那么循环重试,重新读取最新值,再尝试更新。这个“失败就重新试”的过程就是自旋。它的优点是线程永远不会被阻塞,适合锁竞争时间短的场景;缺点是如果竞争很激烈,CPU 空转会造成大量性能损耗,这就是传说中的“ABA 问题”之外最需要考虑的性能瓶颈。

ABA 问题是另一个面试必考点:变量从 A 变成 B 再变回 A,CAS 检查当前值是 A,就认为没有变化,于是更新成功。但实际这个值已经被改了两次,可能产生了副作用。解决办法是用版本号或时间戳来区分,JUC 包里的AtomicStampedReference就是为此设计的。

在实际项目中,我会优先考虑一个思路:能用原子类解决的不要用 synchronized,能用 synchronized 解决的不要自己画蛇添足去手写 CAS。原因不难理解:

  • 原子变量的自旋在低竞争场景下性能远高于锁
  • 但是高竞争场景,锁(尤其是 synchronized 的分代锁优化)会让阻塞线程进入挂起状态,不消耗 CPU,此时原子类的自旋反而会因为不断重试而让 CPU 飙满

什么时候用 CAS?典型如统计请求量、全局唯一 ID 生成器、某些无锁栈和队列的实现。什么时候别硬用?比如临界区很长、操作步骤很多、业务需要多变量一致性保护时,老老实实加锁。

我记得之前做一个网关系统,单机每秒上万的流量统计,当时用了 AtomicLong 做计数器,跑得很稳。后来业务升级,加了一个“统计后按阈值告警”的逻辑,这个判断动作加上一个辅助计数,用原子变量组合起来就非常别扭,最后改成了 LongAdder 才解决问题。大家如果做高并发统计类需求,可以多了解一下 LongAdder,它把热点分散到多个 Cell 上,在高竞争下比 AtomicLong 的 CAS 自旋表现好得多。

另外,CAS 还有一个重要应用面是并发容器和锁框架:ConcurrentLinkedQueue基于 CAS 实现入队出队,AbstractQueuedSynchronizer(AQS)里更新 state 状态也是 CAS。可以说从这层往下,JUC 的整个骨架都是 CAS + volatile 撑起来的。

8. 并发基础自测清单:用这几道题检查你到底是真懂还是假懂

写到这里,我整理了 10 道并发基础的题目,都是面试高频和实际编码中容易翻车的点。建议你遮住答案,先自己回答一遍,再看解析。

问题关键考察点易错点
1.i++原子吗?原子性的定义很多人体感上觉得“一行代码是原子的”,实际不是
2. volatile 能替代 synchronized 吗?各自适用范围经常有人把“可见性”误当“原子性”
3. sleep 持有的锁会释放吗?Thread.sleep 与 wait 的区别容易混淆为“sleep 会释放所有锁”
4. 什么时候必须用 synchronized?多步操作保护想不起复合操作也需要锁保护
5. JMM 里 happens-before 是什么?JMM 内存模型理解背了名字但解释不清作用
6. DCL 单例为什么要 volatile?指令重排的危害从没想过 new 对象是三步操作
7. CAS 一定会导致 ABA 吗?CAS 的局限性误以为每次都必然发生 ABA
8. start() 直接执行了 run() 吗?线程启动链路以为 start0 直接回调 run
9. interrupt() 能立即终止线程吗?协作式中断机制以为 interrupt 是强杀线程
10. 锁竞争激烈时,自旋一定更快吗?原子类与锁取舍忘了自旋会消耗 CPU

我大致说一下第 4 题和第 9 题,因为这两题的实际指导意义最强。

第 4 题:如果操作只有一个共享变量的单步写入或读取,volatile 通常够;如果操作是“读-改-写”模式(比如计数器累加)、或者多个变量需要保持一致性、或者一段代码不允许并发进入,那一定得用锁。这个判断标准简单干脆,能覆盖绝大多数业务场景。

第 9 题:interrupt()不是强杀线程。它只是发了一个“请中断”的信号,目标线程如果正阻塞在wait/sleep/join,就会抛出 InterruptedException;否则需要代码主动检查中断状态。所以生产环境里优雅停机一定要在业务代码中配合响应中断,否则线程池shutdownNow()很难立刻见效。

这套题你如果能不看资料回答清楚,并且能在真实代码中解释出为什么这样用,那么并发基础这一关就基本稳了。后续再往深走,就可以开始啃 AQS、ReentrantLock 的源码,体会同步队列、条件队列的具体实现了。

9. 我总结的几条并发编码习惯

最后聊点实践沉淀。并发不像其他编程方向,出问题不一定复现,跑几天才蹦出一个偶发 bug,排查成本非常高。所以我这些年养成了几条习惯,分享给大家。

第一,共享变量越少越好。能用局部变量不用成员变量,能用 ThreadLocal 不用共享可变对象,能在同一个事务里操作完就释放的不要长时间持有。并发问题的根源几乎都出在共享可变状态上,减少共享,等于直接消灭问题。

第二,写同步代码时先把“不变式”写清楚。比如你写一个转账方法,你要清楚什么是不允许出现的中间状态;你写一个发布订阅模型,要清楚哪些数据在什么阶段必须对读者可见。先把这些注释写明白,再写代码,后面对照检查会轻松非常多。

第三,锁的范围宁小勿大,但不要为了减小锁范围而引入更复杂的新问题。锁范围大,阻塞时间长,吞吐量下降;锁范围小到几个方法各自为战,又容易破坏逻辑一致性。我见过一套代码里把“检查库存、扣减库存、创建订单、清空购物车”四步各自用锁,结果并发下单时库存超卖,就是因为“检查+扣减”本来是原子语义,硬被拆开了。

第四,下单压测之前先做一次线程快照检查。养成用jstack看线程状态的习惯,尤其是看是否有大量 WAITING、BLOCKED。这比事后看监控指标更直接,能快速发现锁竞争位置和死锁线索。也可以启一个-XX:+PrintGC之类 JVM 参数观察是否出现严重的 STW 停顿,排除 GC 因素对延迟的影响。

并发编程入门难,但一旦把原子性、可见性、有序性这些底层逻辑和 JUC 工具之间的关系理顺,后面看 AQS、ReentrantLock、ConcurrentHashMap 的源码就会顺畅很多。这篇文章适合作为你并发学习路线上的第一块基石,建议配合一个简单的多线程压测 Demo 边跑边看,不用急着啃完所有源码,先把几个核心关键字和原子类用熟悉,下一步再往深处走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询