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 知识问答

  1. 什么是线程同步?
  2. 什么是临界区?
  3. synchronized 的基本语法是什么?
  4. synchronized(obj) 锁定的是什么?
  5. 为什么多个线程必须使用同一把锁?
  6. 两个线程分别使用不同 Object 作为锁能否形成互斥?
  7. 银行账户为什么适合使用 this 作为锁?
  8. 什么叫锁粒度?
  9. 锁范围过大有什么问题?
  10. 锁范围过小有什么问题?
  11. 同步方法怎么声明?
  12. 实例同步方法锁谁?
  13. 静态同步方法锁谁?
  14. 同步代码块与同步方法应该如何选择?
  15. synchronized 是否会自动释放锁?
  16. synchronized 是否可重入?
  17. 为什么 synchronized 不仅解决互斥,还能提供可见性保证?
  18. 为什么 sleep() 不能替代 synchronized?

7.2 代码阅读

判断下面代码是否真正形成互斥:

class Counter {

    private int count;

    public void increment() {

        synchronized (new Object()) {
            count++;
        }
    }
}

回答:

  1. 每次调用获得的是不是同一个对象?
  2. 两个线程是否真正竞争同一把锁?
  3. 这段同步设计是否可靠?
  4. 应该怎样修改?

阅读:

class Counter {

    private int count;

    public synchronized
    void increment() {
        count++;
    }
}

回答:

  1. 这是实例同步方法还是静态同步方法?
  2. 锁对象是谁?
  3. 如果两个线程共享同一个 Counter,是否竞争同一把锁?
  4. 如果两个线程分别使用两个 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

  1. 是否安全?
  2. 为什么两段代码虽然都 synchronized,仍可能存在问题?
  3. 应该怎样重新设计锁?

分析:

public void drawMoney(double amount) {

    synchronized (this) {

        if (money >= amount) {
        }
    }

    money -= amount;
}

指出问题:

synchronized 是否完整保护了 Check-Then-Act?

7.5 综合训练

设计一个库存系统:

库存 = 100

创建:

线程A
线程B
线程C

三个线程分别持续购买商品。

要求:

  1. 先写出没有同步的版本。
  2. 分析竞态条件。
  3. 使用同步代码块修复。
  4. 再使用同步方法修复。
  5. 比较两个版本的锁范围。
  6. 说明锁对象是什么。
  7. 说明为什么三个线程必须竞争同一把锁。

7.6 本章验收

能够闭卷完成以下内容即可进入下一章:

synchronized(lock) {
    临界区
}

并且准确口述:

竞争线程必须使用同一把锁

能够解释:

实例同步方法 → this
静态同步方法 → 类名.class

能够独立解决:

银行取钱
银行存钱
库存扣减
计数器++

至少一个线程安全案例。

最后能够解释:

锁范围过大影响并发
锁范围过小无法保护业务不变量

就说明已经真正理解了 synchronized 的基础模型。