synchronized 同步代码块与同步方法 | JavaSE
synchronized 同步代码块与同步方法
一、学习目标
完成本章后,你应该能够:
- 解释线程同步(Synchronization)的作用,以及它与线程安全问题之间的关系。
- 理解
synchronized的基本思想:让竞争同一共享资源的线程互斥进入临界区。 - 独立使用同步代码块解决银行账户取款等线程安全问题。
- 理解同步锁对象为什么必须是竞争线程能够访问到的同一个对象。
- 掌握实例同步方法与静态同步方法分别使用什么对象作为监视器锁。
- 理解同步代码块与同步方法在锁粒度、可读性和使用场景方面的区别。
- 理解
synchronized同时提供的互斥性与内存可见性保证。 - 理解
synchronized是可重入锁。 - 能够识别“锁错对象”“锁范围过大”“锁范围过小”等常见并发错误。
二、核心知识
2.1 从上一章的问题继续
上一章我们得到这样一个账户:
public class Account {
private String cardId;
private double money;
public Account(String cardId, double money) {
this.cardId = cardId;
this.money = money;
}
public void drawMoney(double money) {
String name =
Thread.currentThread().getName();
if (this.money >= money) {
System.out.println(
name + "取钱成功,取出"
+ money + "元"
);
this.money -= money;
System.out.println(
name + "取钱后余额:"
+ this.money
);
} else {
System.out.println(
name + "取钱失败,余额不足"
);
}
}
}
两个线程:
小明线程
小红线程
共同访问:
同一个 Account 对象
账户:
余额 = 100000
两个人都取:
100000
如果线程交错执行:
小明:
检查余额
100000 >= 100000
→ true
↓ 发生线程切换
小红:
检查余额
100000 >= 100000
→ true
↓
两个线程都进入取款分支
问题的核心不是:
if
写错了。
而是:
检查余额
+
修改余额
这组具有完整业务含义的操作,可以被其他线程插入。
我们真正希望实现的是:
线程A:
检查余额
↓
完成扣款
↓
退出
线程B:
这时才能进入
↓
重新检查余额
↓
决定是否扣款
也就是:
同一时刻,只允许一个竞争线程执行这段操作共享账户的核心代码。
Java 最基本的解决机制就是:
synchronized
2.2 什么是线程同步
线程同步(Synchronization)可以先理解为:
当多个线程需要访问同一个共享资源时,通过协调机制控制它们的访问顺序,避免共享数据被并发修改破坏。
原课程中的核心描述是:
让多个线程先后依次访问共享资源
进一步准确理解:
并不是让整个程序全部变成单线程,而是对存在竞争的关键区域进行协调。
例如:
线程A ─┐
线程B ─┼──→ 同一个 Account
线程C ─┘
如果操作账户余额的核心区域被保护:
┌──────────────┐
线程A ─────────→ │ │
│ 临界区 │
线程B ──等待──→ │ 余额检查 │
│ 余额修改 │
线程C ──等待──→ │ │
└──────────────┘
同一时刻只能有一个线程进入。
2.3 什么是临界区
临界区(Critical Section)是:
访问共享可变资源,并且需要避免多个线程同时执行的一段关键代码。
银行案例中真正需要保护的是:
if (this.money >= money) {
this.money -= money;
}
因为这段代码涉及:
读取余额
↓
判断余额
↓
修改余额
多个线程不能随意交错。
因此线程同步的目标不是:
“代码越锁得多越安全”
而应该是:
正确识别共享资源
↓
找到真正需要互斥的临界区
↓
使用正确的同一把锁保护
2.4 synchronized 的基本语法
同步代码块:
synchronized (锁对象) {
// 访问共享资源的核心代码
}
例如:
synchronized (this) {
if (this.money >= money) {
this.money -= money;
}
}
它的核心执行模型是:
线程到达 synchronized
↓
尝试获得锁对象对应的监视器
↓
获得成功?
├── 是 → 进入代码块
│ ↓
│ 执行完毕
│ ↓
│ 自动释放锁
│
└── 否 → 等待锁
2.5 synchronized 锁的到底是什么
Java 中每个对象都关联一个:
监视器(Monitor)
执行:
synchronized (obj) {
}
时,当前线程会尝试取得:
obj 对象关联的 Monitor
的锁。
所以可以暂时理解为:
obj
↓
对应一把内置监视器锁
一个线程获得之后:
线程A持有 obj 的监视器锁
其他线程如果也执行:
synchronized (obj)
就必须等待。
2.6 synchronized 真正比较的是“是不是同一把锁”
例如有:
Object lock = new Object();
两个线程都:
synchronized (lock) {
}
那么:
线程A → lock
线程B → lock
竞争的是:
同一个对象对应的同一把监视器锁
可以实现互斥。
但是:
Object lock1 = new Object();
Object lock2 = new Object();
线程 A:
synchronized (lock1) {
}
线程 B:
synchronized (lock2) {
}
虽然代码都写了:
synchronized
但实际:
A拿lock1
B拿lock2
根本没有竞争同一把锁。
于是:
A可以进入
B也可以进入
线程安全问题依然存在。
因此最重要的一句话是:
多个竞争线程必须使用同一把锁,
synchronized才能形成互斥。
三、使用方法
3.1 使用同步代码块解决取钱问题
原案例中:
public class Account {
private String cardId;
private double money;
public Account(String cardId, double money) {
this.cardId = cardId;
this.money = money;
}
public void drawMoney(double money) {
String name =
Thread.currentThread().getName();
synchronized (this) {
if (this.money >= money) {
System.out.println(
name + "取钱成功,取出了"
+ money + "元"
);
this.money -= money;
System.out.println(
name + "取钱后余额:"
+ this.money
);
} else {
System.out.println(
name + "取钱失败,余额不足"
);
}
}
}
}
为什么:
synchronized (this)
可以?
因为这里:
this
代表:
当前 Account 对象
而:
小明线程
小红线程
调用的是:
同一个 Account 对象
因此:
两个线程
↓
进入同一个 account.drawMoney()
↓
其中的 this 都是同一个 account
↓
竞争同一个 Monitor
于是:
线程A先获得锁
↓
线程B等待
线程A完成检查与扣款
↓
释放锁
线程B再进入
↓
重新读取最新余额
最终业务规则得到保护。
3.2 完整取钱案例
账户:
public class Account {
private final String cardId;
private double money;
public Account(
String cardId,
double money
) {
this.cardId = cardId;
this.money = money;
}
public void drawMoney(double money) {
String name =
Thread.currentThread()
.getName();
synchronized (this) {
if (this.money >= money) {
System.out.println(
name
+ "取钱成功,取出"
+ money
+ "元"
);
this.money -= money;
System.out.println(
name
+ "取钱后余额:"
+ this.money
);
} else {
System.out.println(
name
+ "取钱失败,余额不足"
);
}
}
}
public double getMoney() {
return money;
}
}
线程:
public class DrawThread extends Thread {
private final Account account;
public DrawThread(
String name,
Account account
) {
super(name);
this.account = account;
}
@Override
public void run() {
account.drawMoney(100000);
}
}
测试:
public class SynchronizedDemo {
public static void main(String[] args) {
Account account =
new Account(
"ICBC-001",
100000
);
Thread t1 =
new DrawThread(
"小明",
account
);
Thread t2 =
new DrawThread(
"小红",
account
);
t1.start();
t2.start();
}
}
核心对象关系:
┌── 小明线程
│
│
同一个Account ←─┤
│
└── 小红线程
竞争:
synchronized(this)
this
=
同一个Account对象
所以形成真正互斥。
3.3 锁对象应该怎样选择
原课程给出的基础规范是:
- 对实例方法场景,通常可以使用
this。 - 对静态方法场景,通常使用
类名.class。 - 最关键的是:竞争线程必须拿到同一个锁对象。
例如账户:
synchronized (this)
非常自然,因为:
一个账户
↓
对应自己的一份余额
↓
线程竞争这个账户
锁与受保护资源具有明确对应关系。
3.4 使用专门的私有锁对象
有时也可以:
public class Account {
private final Object lock =
new Object();
private double money;
public void drawMoney(
double money
) {
synchronized (lock) {
// 操作 money
}
}
}
这里:
private final Object lock
具有两个优点:
private
↓
外部代码不能随意拿到这把锁
final
↓
锁对象引用不会在运行中被替换
例如不应该:
private Object lock = new Object();
public void changeLock() {
lock = new Object();
}
否则可能出现:
线程A拿旧lock
线程B拿新lock
导致同步失效。
因此一个稳定的锁对象通常应该:
private final
3.5 为什么不能随便使用一个“全局唯一对象”
假设所有完全无关的业务都锁:
public static final Object GLOBAL_LOCK =
new Object();
然后:
账户取钱 → GLOBAL_LOCK
日志写入 → GLOBAL_LOCK
库存扣减 → GLOBAL_LOCK
文件处理 → GLOBAL_LOCK
虽然可能非常“安全”,但代价是:
大量本来互不冲突的线程
↓
全部争同一把锁
↓
并发能力严重下降
比如:
用户A操作账户001
用户B操作账户999
两个账户之间本来没有共享余额。
如果却都竞争一个全局锁:
A操作账户001
↓
B连账户999都不能操作
这就是:
锁粒度过大
3.6 锁粒度是什么
锁粒度(Lock Granularity)表示:
一把锁所保护的数据和代码范围有多大。
锁粒度过大
例如整个系统:
synchronized (GLOBAL_LOCK) {
// 大量完全无关的业务
}
问题:
安全
但并发能力低
锁粒度过小
例如:
synchronized (this) {
if (this.money >= money) {
}
}
this.money -= money;
虽然判断被锁住了,但是:
真正修改余额
在锁外面。
于是:
检查
和
修改
依然不是完整原子业务。
问题:
锁了
但没有保护完整的不变量
3.7 正确锁范围的原则
锁范围应该覆盖:
维护同一个业务不变量所必须完整执行的一组操作。
银行账户:
判断余额
+
扣减余额
必须处于同一保护区域:
synchronized (this) {
if (this.money >= money) {
this.money -= money;
}
}
不能拆开。
四、原理与进阶
4.1 synchronized 的互斥性
如果:
synchronized (lock) {
}
线程 A 已经持有 lock 的监视器锁:
线程A
↓
进入临界区
此时线程 B 也执行:
synchronized (lock)
B 无法同时取得同一个 Monitor。
所以:
A执行关键代码
B等待
直到 A:
退出 synchronized
↓
释放 Monitor
B 才有机会取得锁。
这就是:
互斥(Mutual Exclusion)
4.2 synchronized 不是“锁住对象的所有代码”
这是非常重要的误区。
假设:
synchronized (account) {
account.setMoney(...);
}
线程 A 获得 account 的 Monitor。
线程 B 此时仍然可以执行一个完全没有同步的普通方法:
account.someUnsafeMethod();
synchronized(account) 并不会神奇地:
“让整个 Account 对象进入冻结状态。”
它真正限制的是:
其他线程想获取同一个 account Monitor
时必须等待。
所以线程安全需要所有相关代码共同遵守:
用同一把锁保护同一份共享状态
4.3 synchronized 自动释放锁
同步代码块:
synchronized (lock) {
// 代码
}
当代码块:
正常执行完成
时,会释放锁。
即使代码块:
因为异常突然结束
Java 也会释放对应 Monitor。
因此不需要自己写:
lockMonitor.unlock();
这和下一章的:
Lock
有明显区别。
4.4 synchronized 是可重入的
可重入(Reentrant)指:
一个线程已经持有一把锁时,可以再次获得同一把锁。
例如:
public synchronized void methodA() {
methodB();
}
public synchronized void methodB() {
System.out.println("B");
}
同一个对象:
methodA 锁 this
然后:
methodA
↓
调用 methodB
↓
methodB 再次锁 this
不会因为:
“这把锁已经被占用了”
而把自己永久卡死。
因为占有者本来就是:
当前线程自己
这种特征叫:
可重入锁
4.5 synchronized 还提供可见性保证
synchronized 不只是解决:
不能同时进入
的问题。
在 Java 内存模型中,对于同一个 Monitor:
线程A释放锁
发生在随后:
线程B成功获得同一把锁
之前。
可以建立模型:
线程A
修改共享数据
↓
退出 synchronized
↓
释放 lock
happens-before
线程B
获得同一个 lock
↓
进入 synchronized
↓
看到之前正确发布的修改
因此正确使用同一把 synchronized 锁:
既提供互斥
又建立线程间内存可见性关系
这是它比:
“我用sleep把两个线程错开”
强得多的根本原因。
五、同步方法
5.1 什么是同步方法
除了:
synchronized (lock) {
}
还可以直接给方法添加:
synchronized
格式:
修饰符 synchronized 返回值类型 方法名(
参数列表
) {
// 操作共享资源的代码
}
例如:
public synchronized void drawMoney(
double money
) {
}
5.2 使用同步方法解决账户问题
可以把:
public void drawMoney(double money) {
synchronized (this) {
// 全部方法核心逻辑
}
}
改成:
public synchronized void drawMoney(
double money
) {
String name =
Thread.currentThread()
.getName();
if (this.money >= money) {
System.out.println(
name + "取钱成功"
);
this.money -= money;
System.out.println(
"余额剩余:" + this.money
);
} else {
System.out.println(
name + "余额不足"
);
}
}
在这个案例中,两者在锁对象上等价:
实例 synchronized 方法
↓
锁 this
5.3 实例同步方法锁谁
例如:
public synchronized void method() {
}
这是:
实例方法
调用:
account.method();
锁的是:
account
也就是方法执行期间的:
this
可以理解为类似:
public void method() {
synchronized (this) {
// 方法体
}
}
5.4 静态同步方法锁谁
例如:
public static synchronized
void method() {
}
静态方法中没有:
this
它锁的是:
当前类对应的 Class 对象
例如:
public class Account {
public static synchronized
void test() {
}
}
对应:
Account.class
可以理解为:
public static void test() {
synchronized (Account.class) {
// 方法体
}
}
因此必须记住:
实例同步方法
→ this
静态同步方法
→ 类名.class
5.5 同步代码块和同步方法怎么选
同步代码块
synchronized (lock) {
// 只锁真正关键部分
}
优点:
锁范围可以精确控制
适合:
方法内部只有部分代码需要同步
同步方法
public synchronized void method() {
}
优点:
写法简洁
语义清晰
可读性较高
适合:
整个方法都属于临界区
所以不能简单说:
同步代码块一定比同步方法好
或者:
同步方法一定比同步代码块好
应该根据:
临界区实际范围
进行选择。
六、常见问题
6.1 加了 synchronized 就一定安全吗?
不一定。
必须保证:
真正存在竞争的线程
↓
使用的是同一把锁
↓
完整保护需要一起执行的共享状态操作
任何一个条件出错都可能失效。
6.2 可以每次 new 一个锁吗?
例如:
synchronized (new Object()) {
}
如果每个线程执行时都创建新对象:
线程A → Object A
线程B → Object B
不是同一把锁。
无法互斥。
6.3 synchronized(this) 一定正确吗?
不是绝对。
关键是:
this 是否正好是所有竞争线程共享的同一对象
账户案例中:
小明、小红共享同一个 Account
所以合理。
如果不同线程调用的是:
两个不同 Account
那么它们的:
this
也不同。
6.4 static synchronized 为什么不用 this?
因为静态方法属于类,不属于某一个具体实例。
所以锁:
类名.class
6.5 同步代码块越大是不是越安全?
不应该这样理解。
过大:
降低并发能力
增加等待
过小:
无法完整保护业务不变量
真正目标:
正确而尽可能合理地保护临界区
6.6 synchronized 会不会自动释放锁?
会。
同步代码块或同步方法退出时,JVM 会完成对应 Monitor 的释放,包括异常退出情况。
6.7 synchronized 是不是只能解决原子性?
不是。
正确的 synchronized 还建立线程间的内存同步关系,涉及:
互斥
原子业务保护
内存可见性
执行顺序关系
6.8 synchronized 会不会让整个程序变成单线程?
不会。
例如:
账户A有自己的锁
账户B有自己的锁
如果两个线程操作的是不同账户:
线程1 → Account A
线程2 → Account B
二者仍然可以并发执行。
同步限制的是:
竞争同一把锁的线程
6.9 synchronized 会不会出现死锁?
可能。
如果程序存在:
多把锁
且不同线程以冲突顺序获取这些锁,就可能发生死锁。
例如:
线程A:
持有lock1
等待lock2
线程B:
持有lock2
等待lock1
形成:
A等B
B等A
谁都无法继续。
死锁将在下一章结合显式 Lock 一起进一步分析。
七、练习与验收
7.1 知识问答
- 什么是线程同步?
- 什么是临界区?
synchronized的基本语法是什么?synchronized(obj)锁定的是什么?- 为什么多个线程必须使用同一把锁?
- 两个线程分别使用不同 Object 作为锁能否形成互斥?
- 银行账户为什么适合使用
this作为锁? - 什么叫锁粒度?
- 锁范围过大有什么问题?
- 锁范围过小有什么问题?
- 同步方法怎么声明?
- 实例同步方法锁谁?
- 静态同步方法锁谁?
- 同步代码块与同步方法应该如何选择?
synchronized是否会自动释放锁?synchronized是否可重入?- 为什么 synchronized 不仅解决互斥,还能提供可见性保证?
- 为什么
sleep()不能替代 synchronized?
7.2 代码阅读
判断下面代码是否真正形成互斥:
class Counter {
private int count;
public void increment() {
synchronized (new Object()) {
count++;
}
}
}
回答:
- 每次调用获得的是不是同一个对象?
- 两个线程是否真正竞争同一把锁?
- 这段同步设计是否可靠?
- 应该怎样修改?
阅读:
class Counter {
private int count;
public synchronized
void increment() {
count++;
}
}
回答:
- 这是实例同步方法还是静态同步方法?
- 锁对象是谁?
- 如果两个线程共享同一个 Counter,是否竞争同一把锁?
- 如果两个线程分别使用两个 Counter,它们是否互斥?
7.3 手写代码
任务一:同步代码块
使用:
Account
DrawThread
重新完成银行取钱案例。
要求:
synchronized (this)
保护:
余额判断
+
扣减余额
完整逻辑。
任务二:同步方法
把上一题改造成:
public synchronized
void drawMoney(...) {
}
比较两种写法。
任务三:银行存钱
账户初始余额:
1000
创建两个线程。
每个线程:
每次存100
循环10次
理论最终余额:
3000
要求使用同步代码块或同步方法确保结果正确。
7.4 Debug
下面程序:
private final Object lock1 =
new Object();
private final Object lock2 =
new Object();
public void taskA() {
synchronized (lock1) {
count++;
}
}
public void taskB() {
synchronized (lock2) {
count++;
}
}
如果:
taskA
taskB
都修改同一个 count:
- 是否安全?
- 为什么两段代码虽然都 synchronized,仍可能存在问题?
- 应该怎样重新设计锁?
分析:
public void drawMoney(double amount) {
synchronized (this) {
if (money >= amount) {
}
}
money -= amount;
}
指出问题:
synchronized是否完整保护了 Check-Then-Act?
7.5 综合训练
设计一个库存系统:
库存 = 100
创建:
线程A
线程B
线程C
三个线程分别持续购买商品。
要求:
- 先写出没有同步的版本。
- 分析竞态条件。
- 使用同步代码块修复。
- 再使用同步方法修复。
- 比较两个版本的锁范围。
- 说明锁对象是什么。
- 说明为什么三个线程必须竞争同一把锁。
7.6 本章验收
能够闭卷完成以下内容即可进入下一章:
synchronized(lock) {
临界区
}
并且准确口述:
竞争线程必须使用同一把锁
能够解释:
实例同步方法 → this
静态同步方法 → 类名.class
能够独立解决:
银行取钱
银行存钱
库存扣减
计数器++
至少一个线程安全案例。
最后能够解释:
锁范围过大影响并发
锁范围过小无法保护业务不变量
就说明已经真正理解了 synchronized 的基础模型。