线程同步 synchronized
本教程共 100 篇 · 第 97 篇 · 更新于 2026-08-05 · 约 14 分钟阅读
本节目标:理解线程不安全的根源,掌握 synchronized 的三种用法,学会防范死锁。
一个算错的计数器
先看一段看起来完全没问题的代码:
public class UnsafeCounter {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("期望结果:200000");
System.out.println("实际结果:" + count);
}
}
两个线程各加 10 万次,结果应该是 20 万。但实际输出往往是:
期望结果:200000
实际结果:137492
每次运行的数字还不一样。少了几万次,这些操作去哪了?
count++ 不是一步完成的
问题出在 count++ 上。这一个字符看起来是个整体,编译成字节码却是三步:
- 读取
count的当前值到操作数栈 - 值加 1
- 把结果写回
count
三步之间随时可能被调度器打断。
假设 count 当前是 100,两个线程同时执行:
线程A:读到 100
线程B:读到 100 ← A 还没写回,B 读到的是旧值
线程A:算出 101
线程B:算出 101
线程A:写回 101
线程B:写回 101 ← 覆盖了 A 的结果
两次自增,结果只加了 1。丢失的那一次就是这么来的。
这种「一个操作要么全做完、要么没开始,中间不能被打断」的特性叫原子性。count++ 不具备原子性,所以出错。
临界区与互斥锁
会被多个线程同时访问、且涉及共享数据修改的代码,叫临界区。
解决办法是给临界区加互斥锁:同一时刻只允许一个线程进入,其他线程在门口等着。
Java 提供的关键字就是 synchronized。
用法一:同步代码块
在需要保护的代码外面包一层:
synchronized (锁对象) {
// 临界区代码
}
给上面的计数器加锁:
public class SafeCounter {
private static int count = 0;
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
synchronized (LOCK) {
count++;
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("实际结果:" + count);
}
}
输出稳定为:
实际结果:200000
跑一百次都是 20 万。
锁对象必须是同一个
synchronized 的锁是加在对象上的,不是加在代码上的。
两个线程只有拿着同一个锁对象,才会互相排斥。锁对象不同,等于各锁各的门,完全没有同步效果。
// 错误示范:每次循环都 new 一个新对象当锁
for (int i = 0; i < 100_000; i++) {
synchronized (new Object()) { // 每个线程锁的都不是同一个对象
count++;
}
}
这段代码看着像加了锁,实际一点用没有。
Warning锁对象要用
private static final修饰。private防止外部代码拿到它乱加锁,final防止引用被改掉,static保证所有实例共用一把锁(保护静态数据时需要)。
另外别用 String 字面量或包装类型当锁。字符串常量池和 Integer 缓存会让看似无关的两处代码意外共用同一把锁。
用法二:同步实例方法
直接在方法上加 synchronized:
public class BankAccount {
private int balance = 1000;
public synchronized void deposit(int amount) {
balance += amount;
}
public synchronized void withdraw(int amount) {
if (balance >= amount) {
balance -= amount;
}
}
public synchronized int getBalance() {
return balance;
}
}
同步实例方法锁的是 this,也就是当前对象。它等价于:
public void deposit(int amount) {
synchronized (this) {
balance += amount;
}
}
同一个对象的所有同步方法互斥。线程 A 在执行 deposit(),线程 B 就进不了 withdraw(),因为它们抢的是同一把锁。
但不同对象之间互不影响。两个 BankAccount 实例各有各的锁,可以并行操作。
用法三:同步静态方法
静态方法上加 synchronized,锁的是类对象:
public class IdGenerator {
private static int nextId = 1;
public static synchronized int next() {
return nextId++;
}
}
它等价于 synchronized (IdGenerator.class) { ... }。
一个类的 Class 对象在 JVM 中只有一份,所以类锁是全局唯一的。所有线程调用这个静态方法都会排队,跟创建了多少个实例无关。
Note对象锁和类锁是两把不同的锁。一个线程持有对象锁执行实例方法,另一个线程完全可以同时持有类锁执行静态方法,它们不互斥。这一点经常被忽略。
可重入性
synchronized 是可重入锁:一个线程已经持有某把锁,再次请求同一把锁时可以直接进入,不会把自己锁死。
public class Reentrant {
public synchronized void outer() {
System.out.println("进入 outer");
inner(); // 调用另一个同步方法,锁对象都是 this
}
public synchronized void inner() {
System.out.println("进入 inner");
}
public static void main(String[] args) {
new Reentrant().outer();
}
}
输出:
进入 outer
进入 inner
如果锁不可重入,inner() 会等 outer() 释放锁,而 outer() 又要等 inner() 执行完,程序当场卡死。
JVM 内部为每把锁维护一个计数器:同一线程每重入一次加 1,每退出一层减 1,减到 0 才真正释放。
继承场景也依赖这个特性——子类的同步方法里调 super.同步方法(),锁对象都是 this,靠可重入才走得通。
死锁
synchronized 用不好会引出更严重的问题:死锁。
死锁是指两个或多个线程互相持有对方需要的锁,谁也不肯放手,全部永久阻塞。
经典场景是转账:
public class DeadLockDemo {
static class Account {
final String name;
int balance = 1000;
Account(String name) {
this.name = name;
}
}
static void transfer(Account from, Account to, int amount) {
synchronized (from) { // 先锁转出方
System.out.println(Thread.currentThread().getName()
+ " 锁住 " + from.name);
try {
Thread.sleep(100); // 放大问题窗口
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (to) { // 再锁转入方
from.balance -= amount;
to.balance += amount;
System.out.println("转账成功");
}
}
}
public static void main(String[] args) {
Account a = new Account("账户A");
Account b = new Account("账户B");
new Thread(() -> transfer(a, b, 100), "线程1").start();
new Thread(() -> transfer(b, a, 200), "线程2").start();
}
}
输出后程序卡住不退出:
线程1 锁住 账户A
线程2 锁住 账户B
线程1 拿着 A 等 B,线程2 拿着 B 等 A。两边都不放手,程序永远停在这里。
死锁的四个条件
死锁发生需要同时满足四个条件:
- 互斥:资源同一时刻只能被一个线程占用
- 持有并等待:拿着一个资源的同时去申请另一个
- 不可剥夺:不能强行抢走别人手里的锁
- 循环等待:形成 A 等 B、B 等 A 的环
破坏任意一条就能避免死锁。互斥和不可剥夺是 synchronized 的固有特性改不了,实践中主要从后两条入手。
按固定顺序加锁
破坏「循环等待」最简单:规定所有线程都按同一个顺序申请锁。
给账户加一个唯一 ID,永远先锁 ID 小的:
static void safeTransfer(Account from, Account to, int amount) {
Account first = from.name.compareTo(to.name) < 0 ? from : to;
Account second = (first == from) ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amount;
to.balance += amount;
}
}
}
这样两个线程都会先抢同一个账户的锁。抢到的那个继续往下走,没抢到的老实等着,环就不存在了。
其他实用建议:
- 缩小同步范围,只锁真正需要保护的几行
- 同步块里不要调用外部的、行为未知的方法
- 需要限时等待时,改用下一节的
ReentrantLock.tryLock()
Tip程序莫名卡死时,用 JDK 自带的
jstack <进程号>打印线程栈。它会直接提示Found one Java-level deadlock,并指出是哪几个线程、哪几把锁,比人肉排查快得多。
本节小结
count++由三条字节码组成,不具备原子性,多线程下会丢结果。synchronized给临界区加互斥锁,保证同一时刻只有一个线程进入。- 锁加在对象上,两个线程必须用同一个锁对象才有互斥效果。
- 同步实例方法锁
this,同步静态方法锁类.class,两者互不干扰。 - 锁对象用
private static final Object,别用字符串和包装类型。 synchronized可重入,同一线程能重复获取自己持有的锁。- 死锁需要四个条件同时成立,按固定顺序加锁可破坏循环等待。
- 用
jstack排查死锁。