synchronized是 Java 里最基础也最常用的线程同步机制,但你真的掌握它的用法了吗?别急着点头——很多开发者觉得只要加个锁就能保证线程安全,结果程序运行后依然出现数据错误。今天这篇文章就从“写后读”这个核心思想出发,配合真实的错误案例,把synchronized的工作原理和正确使用方式彻底讲清楚。![]()
一、先回顾:volatile 为什么不够?
上一篇我们聊过,volatile 只能确保“读取的那一刻拿到最新数据”,但问题出在时间窗口——从线程读取数据到写回内存之间,其他线程可能已经把原始值修改了,导致当前线程写回时覆盖了别人的结果。来看一个典型示例:
private volatile int count = 0; // 两个线程各执行 10 万次 count++ // 最终结果 ≠ 20 万,仍然错误
根本原因在于:volatile 无法保证“读 → 计算 → 写回”这一复合操作的原子性。 简单来说,它能让线程看到最新的值,但管不住多个线程同时修改带来的冲突。
要真正解决这个问题,就需要请出一个更强大的并发工具——synchronized。
二、什么是“写后读”?——并发准确性的核心思想
在深入理解 synchronized 之前,先建立并发编程中最关键的思想:写后读(Write-Before-Read)。
2.1 定义
写后读:一个线程将数据更新回主内存之后,其他线程才能去读取该数据。
换一种说法:任何线程在读取一个共享变量时,必须确保没有其他线程“已经读过了但还没写回”。
2.2 为什么违反写后读会产生错误?
用图示来说明,假设变量 x = 0,线程A累加10次,线程B累加6次:
正确的执行(写后读):
x=0 → 线程A读x=0,加10,写回x=10 → 线程B读x=10,加6,写回x=16 ✅
错误的执行(违反写后读):
x=0 → 线程A读x=0(还未写回)
线程B也读x=0(A还没写回!)
线程A写回x=10
线程B写回x=6(覆盖了A的结果)
最终 x=6,丢失了线程A的贡献 ❌
只要存在“某个线程已经读完了但还没写回,另一个线程就跑去读”的情况,最终的计算结果就一定是错误的。 这就像两个人同时往一个桶里倒水,但记录水量的本子没有加锁——你倒完一次,他倒一次,本子上的数字早就乱套了。
2.3 写后读是通用法则,跨语言、跨架构
这个思想并不是 Java 语言特有的规则,而是所有并发场景下的普适原则:
| 场景 | 保证准确性的方式 |
|---|---|
| 单机多线程 | 线程A写完,线程B才能读 |
| 单机多进程 | 进程A写完,进程B才能读 |
| 多服务器(分布式) | 服务器A写完,服务器B才能读 |
跟语言无关:Java、Python、Go、C++ 都需要遵循这一思想。编程语言只是工具,并发的底层规律是由计算机体系结构决定的。
跟规模无关:即使100台服务器共同操作数据库中的同一行数据,只要遵循写后读原则,最终的结果就是准确的。
三、synchronized 如何实现写后读?
3.1 synchronized 的霸道之处
synchronized 修饰的方法,在多线程调用时:
- 只允许一个线程竞争加锁成功,加锁成功后才能拷贝方法入栈并执行
- 其他线程想要调用同一个加锁方法,连拷贝方法这一步都会被拒绝
- 只有持有锁的线程执行完方法、写操作全部完成后,锁才会释放
- 锁释放后,其他线程才有资格参与竞争,才能读取到最新的数据
线程1 竞争锁成功 → 拷贝 add() 入栈 → 读 count → 计算 → 写回 count → 出栈 → 释放锁
↓
线程2 才能竞争锁
线程2 竞争锁成功 → 拷贝 add()
→ 读到最新的 count → ...
连读的机会都不给,自然就实现了“写完才能读”——这就是 synchronized 提供的写后读保障机制。 它就像一个独裁者,把门关上,等你把所有操作都干完了,才放别人进去。
3.2 正确示范
public class SafeCounter {
private volatile int count = 0;
// synchronized 修饰整个方法:读 → 计算 → 写,全部在锁保护范围内
public synchronized void add() {
count++; // 等价于:读count → 加1 → 写回count
}
public int getCount() {
return count;
}
}
SafeCounter counter = new SafeCounter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter.add(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter.add(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(counter.getCount()); // 稳定输出 200000 ✅
四、加了 synchronized 还是错?——错误示范深度剖析
很多开发者以为“只要加了 synchronized 就万无一失”,这是一个非常危险的误解。来看一个经典的翻车案例:
4.1 错误代码
public class WrongCounter {
private volatile int flag = 0;
// 读方法加锁
public synchronized int get() {
return flag;
}
// 写方法加锁
public synchronized void set(int value) {
flag = value;
}
}
WrongCounter counter = new WrongCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
int val = counter.get(); // 读
counter.set(val + 1); // 写(两次独立的锁!)
}
});
// ... 两个线程执行后,结果仍然不是 200000 ❌
4.2 错在哪里?
问题就在于 get() 和 set() 是两次独立的加锁操作,它们之间存在一个时间窗口:
线程1 调用 get() → 加锁成功 → 读到 flag=5 → 执行完 get() → 释放锁
↓
此时锁已释放!线程2 可以进来了
线程2 调用 get() → 加锁成功 → 读到 flag=5(线程1 还没写回!)→ 执行完 → 释放锁
线程1 调用 set(6) → 加锁 → 写 flag=6 → 释放锁
线程2 调用 set(6) → 加锁 → 写 flag=6 → 释放锁(覆盖!应该是7)
关键问题:线程1 调用 get() 读完数据后还没有写回,get() 方法就执行完了,锁随之释放。这直接违反了写后读原则——线程2 在线程1 写回之前就读到了数据。
4.3 根本原因
synchronized 保证的是:锁保护范围内的操作是互斥的。
但如果把“读”和“写”拆成两个独立加锁的方法,锁的保护范围就出现了断裂。在两次加锁操作之间,其他线程可以趁虚而入,写后读的保障就被破坏了。你虽然锁住了门,但却开了一条缝,别人还是能溜进来。
五、如何正确使用 synchronized?
核心原则
synchronized 的加锁范围必须包含从“读”到“写回”的完整操作。只有写操作全部完成之后,锁才能释放。
❌ 错误:锁保护了读,但写在锁外
[lock] → 读 → [unlock] → 计算 → 写
✅ 正确:锁保护了读 + 计算 + 写的完整流程
[lock] → 读 → 计算 → 写 → [unlock]
正确写法对比
// ❌ 错误:读和写分离,锁之间有空档
public synchronized int get() { return flag; }
public synchronized void set(int v) { flag = v; }
// 调用时:
int val = counter.get(); // 锁释放了!
counter.set(val + 1); // 又加锁,但中间有空档
// ✅ 正确写法一:把读-改-写放在同一个 synchronized 方法内
public synchronized void increment() {
flag++; // 读+改+写,全在一个锁内
}
// ✅ 正确写法二:用 synchronized 代码块保护完整操作
public void increment() {
synchronized (this) {
flag++; // 读+改+写,全在锁的范围内
}
}
六、synchronized 与 volatile 的对比
| 对比项 | volatile | synchronized |
|---|---|---|
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性(禁止重排序) | ✅ 保证 | ✅ 保证 |
| 原子性 | ❌ 不保证 | ✅ 保证(需正确使用) |
| 性能开销 | 小 | 相对较大 |
| 能修饰方法 | ❌ 不能 | ✅ 能 |
| 能修饰变量 | ✅ 能 | ❌ 不能直接修饰变量 |
| 适合场景 | 状态标志、一写多读 | 复合操作、临界区 |
volatile 只能修饰变量,不能修饰方法;synchronized 只能修饰方法和代码块,不能直接修饰属性(变量)。两者作用互补,不可混用替代。
七、synchronized 的本质:禁止方法被同时拷贝
用一句话总结 synchronized 的实现原理:
synchronized 禁止被它修饰的方法同时被两个线程拷贝入栈。只有当前持有锁的线程执行完毕(写操作完成),锁释放后,其他线程才有资格拷贝该方法。
这种“霸道”的方式,从根源上保证了写后读——你连读都读不到,自然不可能在别人写完之前就去读数据。这就像一家只有一把钥匙的健身房,一个人练完放下器械,下一个人才能进去练——永远不会有两个人同时碰到同一个哑铃的情况。
这也是为什么 synchronized 被称为重量级锁——它的代价比 volatile 大得多,因为它涉及线程的阻塞、唤醒以及上下文切换。
八、写后读的普适性:不仅仅是多线程
写后读思想的适用范围远不止多线程场景:
多线程: 线程A写完 → 线程B才能读 多进程: 进程A写完 → 进程B才能读 分布式: 服务器A写完数据库 → 服务器B才能读数据库
实际案例:电商秒杀系统中,100 台服务器同时操作数据库中的库存数量。只要数据库层面实现了写后读(通过事务锁、乐观锁等手段),最终的库存结果就是准确的,不会出现超卖问题。
这个道理与编程语言无关,跟是不是 Java 也没有关系,它是所有并发系统都必须遵守的铁律。无论你用 Go 的 channel、Python 的 threading,还是其他并发模型,最终都要回归到这一核心原则上。
九、其他问题
Q1:synchronized 是如何保证线程安全的?
synchronized 通过禁止方法被多个线程同时拷贝来实现互斥。加锁成功的线程才能执行方法,其他线程阻塞等待。当加锁线程完成写操作后,锁才会释放,其他线程才能参与竞争。这保证了“写后读”——任何读操作都发生在上一次写操作完成之后,从而确保计算结果的准确性。
Q2:get() 和 set() 都加了 synchronized,为什么还是线程不安全?
因为
get()和set()是两个独立的加锁操作,get()执行完就释放了锁,此时写操作还没有发生,其他线程可以趁这个空档进来读取旧数据。这直接违反了写后读原则。正确的做法是将读-改-写的完整操作放在同一个synchronized块内,确保写完才释放锁。
Q3:如何正确使用 synchronized?
核心原则:锁的保护范围必须覆盖从“读”到“写回”的完整操作。如果只保护了读,或者把读和写拆成两个独立的锁操作,则仍然是不安全的。简单场景使用
synchronized方法(整个方法体在锁内),复杂场景使用synchronized(obj) { }代码块来精确控制范围。
Q4:写后读思想是 Java 独有的吗?
不是。写后读是所有并发场景下的通用原则,与编程语言无关,也不限于多线程——多进程、多服务器分布式场景同样适用。只要并发系统遵循写后读,最终的计算结果就是正确的。
十、小结
并发错误的根本原因:
某线程“已读未写”,其他线程已经读了 → 相互覆盖 → 结果错误
解决方案的核心思想:
写后读 —— 一个线程写完之后,其他线程才能读
synchronized 的实现方式:
禁止方法被同时拷贝 → 写完才释放锁 → 天然保证写后读
正确使用的关键:
锁的范围必须包含完整的“读 → 改 → 写”,缺一不可
