1. 为什么要同步:从一起改同一个变量说起
1.1 一个典型的并发 Bug 现场
先讲个真实案例。有一次线上功能出现了一个非常诡异的问题:一个统计并发访问量的功能,每天凌晨跑定时任务汇总,结果发现数据总是偏小,有时候甚至差了三分之一。最初排查方向放在数据库上,以为是批量插入丢了数据,后来加了日志一查,才发现问题出在内存里的一个计数器上。
当时的代码长这样:
java复制public class Counter {
private int count = 0;
public void increment() {
count++; // 这里看起来没问题?
}
public int getCount() {
return count;
}
}
单线程环境下这段代码完全没有问题,但在多线程环境下,count++ 这短短一行字,实际上包含三个独立的步骤:读取 count 的当前值、将值加 1、将新值写回内存。两个线程同时执行这行代码时,可能出现如下交错:
- 线程 A 读取 count = 5
- 线程 B 读取 count = 5
- 线程 A 计算 5 + 1 = 6,写回
- 线程 B 计算 5 + 1 = 6,写回
于是,两个线程各加了一次,count 却只从 5 变成了 6。这就是并发修改共享资源时最经典的原子性问题,Java 中同步和互斥机制要解决的核心问题就在这。
1.2 内存模型里的可见性陷阱
原子性问题只是冰山一角,还有一个更容易被忽视的可见性问题,我当年第一次踩到的时候排查了整整一个下午。
看这段代码:
java复制public class VisibilityDemo {
private boolean running = true;
public void stop() {
running = false; // 主线程调用
}
public void worker() {
while (running) {
// 业务逻辑
}
System.out.println("线程停止");
}
}
直觉上,主线程调用 stop() 之后,worker 线程的 while 循环会立刻退出,但实际上不一定。Java 内存模型(JMM)规定,每个线程有自己的工作内存(可以理解为 CPU 缓存),线程操作变量时,需要把变量从主内存拷贝到自己的工作内存,操作完再同步回主内存。
问题就出在这个"再同步回主内存"上:如果 worker 线程一直占着 CPU,运行时完全没有机会把主内存中的新值同步到自己的工作内存中,它就会一直读取"旧值",导致循环永远不退出。这套机制是 CPU 为了性能做的缓存优化,但也是并发 bug 的重要来源。
如果你问为什么需要同步,这三条必须说清楚:
- 原子性:一个或多个操作在 CPU 执行过程中不被中断的特性。
- 可见性:一个线程对共享变量的修改,能及时让其他线程看到。
- 有序性:程序执行的顺序按照代码的先后顺序执行,指令重排要有限制。
同步和互斥,本质上就是为了在这三个层面保证多线程访问共享数据时的安全性。
1.3 同步和互斥到底是两个什么概念
很多人把同步和互斥混为一谈,面试的时候也常常拎不清这两个词。
- 互斥(Mutual Exclusion):多个线程不能同时访问同一个共享资源,同一时刻只允许一个线程访问临界区。
- 同步(Synchronization):多个线程之间可以通过一定机制协调彼此的执行顺序,保证协作的合理性。
通俗点说,互斥管的是"能不能同时用",同步管的是"轮到谁用、什么时候能用"。比如生产者消费者模型,生产者往队列里塞数据、消费者从队列里拿数据,队列本身需要互斥保护,防止同时读写导致数据错乱;而队列为空时消费者必须等着、队列满了生产者必须等着,这部分就是同步。两者常常同时出现,配合使用,这也是 Java 并发编程最核心的内容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized:JVM 内置锁的完整进化史
2.1 三种用法和字节码本质
synchronized 是 Java 提供的最基本的互斥手段,也是面试中最常被问到的关键字。它有三种使用方式:
java复制public class SyncDemo {
// 1. 修饰实例方法:锁的是当前实例对象 this
public synchronized void instanceMethod() {
// 临界区
}
// 2. 修饰静态方法:锁的是当前类的 Class 对象
public static synchronized void staticMethod() {
// 临界区
}
// 3. 修饰代码块:锁的是指定的对象
public void blockMethod(Object lock) {
synchronized (lock) {
// 临界区
}
}
}
从 JVM 字节码层面看,synchronized 方法通过 ACC_SYNCHRONIZED 标志标记,synchronized 代码块则依赖 monitorenter 和 monitorexit 两条指令实现。每个 Java 对象头里都有一把"监视器锁"(Monitor),线程进入同步代码块前必须拿到这把锁,出来时再释放掉。同一条线程可重入同一个锁,这个特性叫可重入,后面讲的 ReentrantLock 也借鉴了这个设计。
2.2 锁升级机制:从偏向锁到重量级锁
早期 JDK 的 synchronized 就是"重量级锁",性能很糟糕。Java 6 开始做的重大优化是引入锁升级机制,让 synchronized 变成"越用越重"的智能锁。整个过程可以分成四个阶段:
- 无锁状态:没有任何线程抢锁。
- 偏向锁:只有一个线程访问,锁会记录这个线程的 ID,之后该线程再次进入时直接放行,不需要做任何同步操作。相当于你去餐厅,服务员记住了你的脸,第二次来直接领位,不用看证件。
- 轻量级锁:第二个线程开始竞争时,偏向锁撤销,升级为轻量级锁。此时线程通过 CAS 自旋的方式尝试抢占锁,抢不到就快速自旋几次,不会立刻让出 CPU。
- 重量级锁:如果自旋一定次数仍然没抢到,或者竞争非常激烈,锁升级为重量级锁。线程需要真正进入阻塞状态,由操作系统进行线程调度,涉及用户态和内核态的切换,开销最大。
这就是为什么现代 Java 源码里,synchronized 的性能其实并没有以前大家说的那么差。很多时候用"性能差"来劝退 synchronized 的言论都是旧时代的经验,实际项目里优化锁的关键还是锁粒度,而不是动不动换 ReentrantLock。
2.3 使用 synchronized 踩过的那些坑
我在实际项目中总结了几条比较常见的坑,写在这里给后来人提个醒。
第一,锁对象被修改导致锁失效。如果锁对象是一个字段,而且这个字段在运行过程中被重新赋值了,新旧对象是两把不同的锁,并发控制就完全失效了。正确做法是把锁对象设计成 final。
java复制// 错误写法
private Object lock = new Object();
public void changeLock() {
lock = new Object(); // 锁变了,前面的竞争控制全白搭
}
// 正确写法
private final Object lock = new Object();
第二,synchronized 锁字符串常量容易出问题。多个不相关的模块如果都拿同一个字符串常量当锁,可能产生意想不到的竞争。尤其注意,字符串字面量在 JVM 字符串常量池里是同一个对象,直接拿它做锁非常危险。
第三,锁粒度太粗会导致性能浪费。比如一个方法有几百行代码,但真正需要互斥的只有中间两行,如果整个方法都用 synchronized 修饰,其他无关操作也全部串行化了。我见过不少案例,把 synchronized 从方法级别改为代码块级别,耗时直接减少了 70% 以上。
还有个很多人忽略的点:synchronized 的锁不可中断,也不支持超时。如果一个线程阻塞在等待锁的过程中,其他线程永远释放不了锁,这个线程就会一直等下去,没有退路。这也是后来 Lock 接口出现的原因之一。
3. volatile:不锁也能同步的另一种思路
3.1 volatile 承诺的两件事:可见性和有序性
synchronized 是重量级方案,但有些场景其实不需要互斥,只需要可见性和有序性,这时候可以选用 volatile。
volatile 关键字的作用有两个:
- 保证可见性:每次都从主内存读取最新值,写操作也立即同步到主内存。
- 禁止指令重排序:通过插入内存屏障(Memory Barrier)实现,禁止编译器或 CPU 对 volatile 前后的指令进行重排序。
但注意,volatile 不保证原子性。回到最开始的 count++ 案例,就算把 count 声明为 volatile,由于"读-改-写"三步不是原子的,并发环境下依然会丢数据。这是面试中最常见的坑点,搞混 volatile 和原子性概念的人特别多。
3.2 双检锁单例里的 volatile:必考点
volatile 最经典的实战场景是双重检查锁定(Double-Checked Locking,DCL)单例模式,这里不重点讲设计模式,但必须解释为什么必须加 volatile。
java复制public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
new Singleton() 这行代码在汇编层面不是原子操作,它大致分成三步:分配内存、调用构造函数初始化对象、将引用赋值给 instance。如果不加 volatile,第二步和第三步可能被指令重排,导致一个线程已经完成了"赋值",但另一个线程拿到的是一个还没初始化完成的半成品对象。加 volatile 禁止重排序之后,就能保证对象完全初始化后才暴露给其他线程。
很多面试官问 DCL 单例为什么加 volatile,其实考的就是对指令重排和内存屏障的理解,同时把 synchronized 和 volatile 做了对比。
3.3 volatile 适合的场景和翻车案例
基于 volatile 的特性,它适合以下场景:
- 多个线程共享一个 boolean 状态标志,比如开关、中断标志。
- 状态标志不依赖其他共享变量,也不存在"读-改-写"复合操作。
- 一个线程写,多个线程读,写线程不依赖读线程当前的值。
翻车案例也很多,最常见的是把 volatile 变量用于计数器或累加器。之前就见过一个同学写多线程统计订单数,volatile 修饰 int 变量,结果线上数据统计偏差了,排查半天才发现问题。所以这里再强调一次:看到复合操作就是 CAS 或加锁,volatile 解决不了。
4. Lock 体系:显式锁的高级玩法
4.1 ReentrantLock 的四个独有能力
synchronized 满足大部分场景,但它没有超时、不可中断、无法判断锁状态等先天不足,所以 JDK 5 引入了 Lock 接口和实现类 ReentrantLock。
ReentrantLock 与 synchronized 相比,多了四个关键能力:
- 响应中断:等待锁的线程可以被
interrupt()方法打断,不会死等。 - 支持超时:用
tryLock(timeout, TimeUnit)最多等多久,等不到就放弃。 - 公平锁:通过构造参数指定,让等待时间最长的线程先拿到锁,避免"线程饿死"。
- 多个条件队列:通过
newCondition()创建多个等待条件,实现更精细的线程协调。
基本用法如下:
java复制ReentrantLock lock = new ReentrantLock();
try {
lock.lock();
// 临界区
} finally {
lock.unlock(); // 必须手动释放
}
用 ReentrantLock 最容易犯的错误就是忘掉 unlock,我建议不管代码多简单,都是 lock 之后立刻写 try-finally,把 unlock 放在 finally 里,养成肌肉记忆。
4.2 读写锁和乐观锁:ReadWriteLock 与 StampedLock
真实业务场景中,共享数据的访问往往读多写少。如果用互斥锁全保护,读线程之间也互相排队,白白浪费了并发能力。这时候可以用 ReentrantReadWriteLock,它把读锁和写锁分开,读读不互斥、读写互斥、写写互斥。
还有一个比 ReadWriteLock 更激进的选择是 StampedLock,它支持三种模式:写锁、悲观读锁、乐观读。乐观读不加锁,直接读数据,读完之后检查版本号判断是否有写线程穿插,如果没有,这次读操作就是成功的。在高并发读多写少的场景下,StampedLock 的性能更亮眼,但使用复杂度更高,读线程拿到数据后必须用 state 值校验。
说一下我的经验:分布式中间件、缓存客户端这类组件里,读写锁使用非常多;业务代码层面,如果你们项目读多写少,优先尝试 ReadWriteLock。只有确认它仍然是性能瓶颈,再考虑引入 StampedLock 做极致优化,因为这个锁的可读性确实差一些,写错了不好排查。
4.3 到底该选 synchronized 还是 ReentrantLock
这个问题是 Java 面试必考题,我直接给出一张对比表,方便记忆:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁的获取和释放 | 自动,异常自动释放 | 手动,必须 finally 解锁 |
| 是否可中断 | 否 | 是 |
| 是否支持超时 | 否 | 是 |
| 是否支持公平队列 | 否 | 是 |
| 条件变量支持 | 通过 wait/notify 实现 | 支持多个 Condition |
| 性能(JDK 6+) | 不断优化,差距很小 | 高并发下有优势 |
实际项目里怎么选?我的原则很简单:能用 synchronized 先用 synchronized,只有当遇到中断、超时、公平锁、多条件队列这类特殊需求时,才切换到 ReentrantLock。因为 synchronized 代码量少、不会漏释锁、可读性高,这些都是生产环境非常看重的特性。
5. CAS 与原子类
5.1 CAS 的基本原理
前面提到的 count++ 问题,除了加锁,还有一种无锁的解决方式:CAS(Compare And Swap,比较并交换)。
CAS 有三个操作数:内存地址 V、旧的预期值 A、新值 B。只有当 V 上存储的值等于 A 时,才把 V 上的值更新为 B,否则不做任何操作。整个比较和交换是一个原子操作,由 CPU 指令直接支持。
Java 中原子类的实现思路就是基于 CAS,比如 AtomicInteger:
java复制AtomicInteger count = new AtomicInteger(0);
public void add() {
count.incrementAndGet();
}
incrementAndGet() 内部就是用 CAS 循环实现的:不断读取当前值,尝试用 CAS 更新为当前值 + 1,如果更新失败说明有其他线程抢了先,重试直到成功为止。
相比互斥锁,CAS 的最大的优势是省去了线程阻塞和唤醒的系统调用开销,在竞争不那么激烈时性能非常好。但竞争激烈时会有大量线程同时自旋,白白消耗 CPU 资源,这也是它的短板。
5.2 ABA 问题和解决方案
CAS 有一个经典问题叫 ABA 问题。简单说,就是线程 A 读取到值是 1,线程 B 把值改成 2 又改回 1,线程 A 再次 CAS 时发现值还是 1,就认为没人改过,于是更新成功。但实际上变量已经被改过两次了,中间的"状态变化"丢失了。
解决 ABA 问题的办法是加版本号:AtomicStampedReference 会额外维护一个版本戳,每次修改版本戳也跟着更新,CAS 时同时检查值和版本戳,版本号对不上就认为数据被改过。还有一个 AtomicMarkableReference,只关心是否被改过,不关心次数。
实际项目中 ABA 问题出现的概率并不高,但一旦发生,往往就是那种很难复现的诡异 bug。比如你有余额扣减逻辑,用户先充值 100 又消费 100,余额恰好回到初始值,此时如果并发叠加,就可能出现重复扣减的风险。所以对状态敏感的变量,一定要考虑加版本号。
5.3 原子类家族和 LongAdder 的取舍
JDK 的原子类不止一个,常用的是这几种:
AtomicBoolean:原子更新布尔值。AtomicInteger/AtomicLong:原子更新整数。AtomicReference:原子更新对象引用。AtomicIntegerArray/AtomicLongArray:原子更新数组元素。LongAdder/LongAccumulator:高并发计数下的优化类。
LongAdder 的设计思路值得单独说两句。它内部维护了一个 base 变量和一组 Cell 数组。多个线程竞争一个 base 时,不会像 AtomicLong 那样所有线程都失败重试,而是把每个线程分散到不同的 Cell 上去累加,最后需要总数时把所有 Cell 的值和 base 汇总起来。这相当于把"一个热点"拆成了"多个热点",进一步降低竞争。实测数据也表明,并发线程数越多,LongAdder 相对 AtomicLong 的性能提升越明显。
工作中的应用场景很清晰:统计 QPS、访问量这种"只求和、不关心中间值"的数据,直接用 LongAdder;需要拿当前值做判断,比如"达到阈值就触发操作",用 AtomicLong。
6. 线程间的同步协作
6.1 wait、notify 和锁的关系
互斥解决了"同时访问"的问题,同步还要解决"线程之间怎么协调顺序"的问题。经典的 wait() 和 notify() 就是干这个的。
注意,这两个方法必须在持有锁的代码块里调用,否则会抛出 IllegalMonitorStateException。它们和锁的关系是:
wait():当前线程释放锁,进入等待队列,让出 CPU。notify():唤醒等待队列中的一个线程,被唤醒的线程需要重新抢锁。notifyAll():唤醒等待队列中的所有线程。
写一个最朴素的生产者消费者例子:
java复制public class ProducerConsumer {
private final List<Integer> queue = new LinkedList<>();
private static final int MAX_SIZE = 10;
public synchronized void produce(int item) throws InterruptedException {
while (queue.size() == MAX_SIZE) {
wait(); // 队列满了,等待消费者消费
}
queue.add(item);
notifyAll(); // 通知消费者可以取了
}
public synchronized int consume() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 队列空了,等待生产者生产
}
int item = queue.remove(0);
notifyAll(); // 通知生产者可以继续放
return item;
}
}
这个例子里,wait() 和 notifyAll() 配合 synchronized 同时实现了互斥和同步:生产者放物品时消费者不能取,队列满时生产者等待,队列空时消费者等待。
6.2 条件队列的进阶:Condition
用 wait/notify 有个明显局限:只有一个隐含的局面,想精确唤醒"某个特定条件的线程"做不到,只能 notifyAll 全部喊醒,让被唤醒的线程自己判断是不是自己要的条件,不是就继续等。
Condition 接口解决的就是这个问题。可以把 Condition 理解成一把锁下面挂了多个独立的"等待队列",每个队列对应一个等待条件。典型场景是 ArrayBlockingQueue 内部的实现,它有一个锁、两个条件(notEmpty 和 notFull),分别控制"队列空"和"队列满"两种情况。
java复制ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
public void put(Object item) throws InterruptedException {
lock.lock();
try {
while (count == capacity) {
notFull.await(); // 队列满,生产者等
}
// 入队
notEmpty.signal(); // 唤醒消费者
} finally {
lock.unlock();
}
}
实际业务中,分布式任务调度框架、连接池的获取连接流程,都大量用到 Condition。相比 wait/notify,Condition 的优势就是细粒度、可控性强,尤其在复杂的并发协作场景里,多条件队列能显著减少无意义唤醒带来的资源消耗。
6.3 虚假唤醒与 while 循环的判断
线程通信中有个非常反直觉的点:wait() 可能会假醒,也就是线程无缘无故从等待状态醒来,即使没有任何线程调用 notify() 或 notifyAll()。这是操作系统的底层行为,JVM 不保证 wait() 一定只能被显式唤醒。
解决虚假唤醒的方法只有一个:把 wait() 放在 while 循环里,而不是 if 里。循环里每一次醒来都要重新检查条件,条件不满足就继续 wait。这是官方 Java 并发教程里的经典建议,也是我在代码评审中必提的一点。
java复制// 正确写法
synchronized (obj) {
while (conditionIsNotMet) {
obj.wait();
}
// 条件满足,执行操作
}
就拿上面生产者消费者的例子来说,如果用 if 代替 while,如果消费者被假醒,队列恰好又是空的,它就会直接执行移除操作,抛异常或者返回 null。用 while 能保证每次醒来都检查一遍,把假醒当成普通事件处理,逻辑就永远安全。
7. 面试高频题与线上排障实录
7.1 几个必背的八股题
聊 Java 同步互斥,面试题从初级到高级基本绕不开这些,我整理成表格供参考:
| 问题 | 核心答法 |
|---|---|
| synchronized 和 ReentrantLock 的区别 | 内置锁 vs 显式锁;自动释放 vs 手动释放;是否可中断/超时/公平锁定;Condition |
| volatile 和 synchronized 的区别 | volatile 解决可见性和有序性,synchronized 还解决原子性;volatile 无锁、开销小 |
| 什么是原子类,它一定线程安全吗 | 用了 CAS+轻量级竞争,安全的前提是操作本身是原子的,复合操作仍需加锁 |
| 什么是锁升级 | 偏向锁→轻量级锁→重量级锁,核心是降低锁开销 |
| 线程安全的单例怎么写 | 多种方案,DCL+volatile 或枚举方式 |
| 为什么 wait 必须在 synchronized 里 | wait 依赖 monitor 机制,需要先持有锁,否则无法进入等待队列,休眠时也能释放锁 |
| 什么是 ABA 问题 | CAS 中被改回原值无法识别,用版本号解决 |
面试时切忌只背结论,要能把"为什么"讲出来。比如回答 lock 的区别时,要顺带提一下"JVM 优化后 synchronized 不再像以前那么慢",并说到锁升级、自旋、偏向锁释放等机制,这样才有区分度。
7.2 一次真实的死锁排查过程
死锁是并发编程里最有代表性的问题,也是线上事故的高发点。下面这个案例可以说相当典型:
两个线程分别持有锁 A 和锁 B,线程 1 等待锁 B,线程 2 等待锁 A,两个线程互相等待,就形成了死锁。
排查步骤大致是:
- 找到 Java 进程 ID:
jps -l - 执行
jstack <pid> > dump.txt - 在 dump 文件里搜索
deadlock关键字,JVM 会自动检测死锁链路 - 根据线程栈定位代码行,看加锁顺序,要么统一按同一个顺序获取锁,要么用
tryLock超时回退
我遇到过一次非常难排查的死锁,原因是团队里两个模块分别使用不同顺序获取同一组数据库连接池锁。当时看起来两个模块的代码都没有问题,但线上并发高的时候就死锁了。后来通过 jstack 一查,发现死锁原因就是锁的顺序不一致。解决办法也很简单:约定所有地方按固定顺序加锁,再加一个全局锁顺序规范,问题彻底消失。
7.3 项目实战中的几点经验
最后聊几点我在实际项目中反复验证过的心得。
第一,锁粒度要因场景而变,不能一刀切。对于读多写少的场景,优先考虑读写锁或者 CopyOnWrite 容器;对于写多读少的场景,老老实实使用互斥锁,不要为了"高性能"硬套乐观锁。我见过有人为了追求极致的"无锁",给自己埋了一堆坑,最后性能反而更差。
第二,持有锁期间尽量不要做耗时操作,尤其不要做网络 IO、数据库查询。锁的定位是保护共享数据的短暂临界区,不是包办一切。如果临界区里有耗时的外部调用,可以把两种方案结合:先把共享状态读出来,释放锁,再做外部调用,最后再加锁写回结果。当然,方案得根据业务一致性要求来。
第三,用工具验证并发逻辑,不要靠眼睛看。jstack、jvisualvm、JMC、Java Flight Recorder 都能帮你了解锁的竞争情况。你要是怀疑某个共享资源响应慢,跑一跑火焰图,看看哪里锁竞争最强烈,比盲猜效率高太多了。
第四,压测数据永远比理论推演重要。曾经有过一个案例,按理说 synchronized 和 ReentrantLock 的性能差距已经很小了,但我们线上用 synchronized 的临界区在 500 QPS 下锁竞争激烈程度远超预期。后来把锁降级、拆分热点、用 LongAdder 代替 AtomicLong,效果立竿见影。性能优化要从实际负载出发,理论只是起点。
我自己在实际项目中还有一个习惯:并发相关的代码,必须优先保证正确性,再考虑性能优化。同步互斥方案选型的顺序是"先安全、后高效",凡是共享可变状态,默认用最稳妥的实现,只有保证不会在并发环境下出问题,才考虑用 CAS、StampedLock 这种更激进的手段。一个线上并发 bug 带来的损失,往往比性能提升创造的价值大得多。
