首页 / Java 入门教程 / 线程同步 synchronized

Java 入门教程

线程同步 synchronized

本教程共 100 篇 · 第 97 篇 · 更新于 2026-08-05 · 约 14 分钟阅读

JavaJava 入门教程synchronized线程安全对象锁死锁

97. 线程同步 synchronized

本节目标:理解线程不安全的根源,掌握 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);
    }
}
```text

两个线程各加 10 万次,结果应该是 20 万。但实际输出往往是:

```text
期望结果:200000
实际结果:137492

每次运行的数字还不一样。少了几万次,这些操作去哪了?

count++ 不是一步完成的

问题出在 count++ 上。这一个字符看起来是个整体,编译成字节码却是三步:

  1. 读取 count 的当前值到操作数栈
  2. 值加 1
  3. 把结果写回 count

三步之间随时可能被调度器打断。

假设 count 当前是 100,两个线程同时执行:

线程A:读到 100
线程B:读到 100        ← A 还没写回,B 读到的是旧值
线程A:算出 101
线程B:算出 101
线程A:写回 101
线程B:写回 101        ← 覆盖了 A 的结果
```bash

两次自增,结果只加了 1。丢失的那一次就是这么来的。

这种「一个操作要么全做完、要么没开始,中间不能被打断」的特性叫**原子性**。`count++` 不具备原子性,所以出错。

## 临界区与互斥锁

会被多个线程同时访问、且涉及共享数据修改的代码,叫**临界区**。

解决办法是给临界区加**互斥锁**:同一时刻只允许一个线程进入,其他线程在门口等着。

Java 提供的关键字就是 `synchronized`。

## 用法一:同步代码块

在需要保护的代码外面包一层:

```java
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);
    }
}
```text

输出稳定为:

```text
实际结果:200000

跑一百次都是 20 万。

锁对象必须是同一个

synchronized 的锁是加在对象上的,不是加在代码上的。

两个线程只有拿着同一个锁对象,才会互相排斥。锁对象不同,等于各锁各的门,完全没有同步效果。

// 错误示范:每次循环都 new 一个新对象当锁
for (int i = 0; i < 100_000; i++) {
    synchronized (new Object()) {  // 每个线程锁的都不是同一个对象
        count++;
    }
}
```bash

这段代码看着像加了锁,实际一点用没有。

> [!WARNING]
> 锁对象要用 `private static final` 修饰。`private` 防止外部代码拿到它乱加锁,`final` 防止引用被改掉,`static` 保证所有实例共用一把锁(保护静态数据时需要)。

另外别用 `String` 字面量或包装类型当锁。字符串常量池和 `Integer` 缓存会让看似无关的两处代码意外共用同一把锁。

## 用法二:同步实例方法

直接在方法上加 `synchronized`:

```java
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;
    }
}
```bash

**同一个对象的所有同步方法互斥**。线程 A 在执行 `deposit()`,线程 B 就进不了 `withdraw()`,因为它们抢的是同一把锁。

但不同对象之间互不影响。两个 `BankAccount` 实例各有各的锁,可以并行操作。

## 用法三:同步静态方法

静态方法上加 `synchronized`,锁的是**类对象**

```java
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();
    }
}
```text

输出:

```text
进入 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();
    }
}
```text

输出后程序卡住不退出:

```text
线程1 锁住 账户A
线程2 锁住 账户B

线程1 拿着 A 等 B,线程2 拿着 B 等 A。两边都不放手,程序永远停在这里。

死锁的四个条件

死锁发生需要同时满足四个条件:

  1. 互斥:资源同一时刻只能被一个线程占用
  2. 持有并等待:拿着一个资源的同时去申请另一个
  3. 不可剥夺:不能强行抢走别人手里的锁
  4. 循环等待:形成 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;
        }
    }
}
```bash

这样两个线程都会先抢同一个账户的锁。抢到的那个继续往下走,没抢到的老实等着,环就不存在了。

其他实用建议:

- 缩小同步范围,只锁真正需要保护的几行
- 同步块里不要调用外部的、行为未知的方法
- 需要限时等待时,改用下一节的 `ReentrantLock.tryLock()`

> [!TIP]
> 程序莫名卡死时,用 JDK 自带的 `jstack <进程号>` 打印线程栈。它会直接提示 `Found one Java-level deadlock`,并指出是哪几个线程、哪几把锁,比人肉排查快得多。

## 本节小结

- `count++` 由三条字节码组成,不具备原子性,多线程下会丢结果。
- `synchronized` 给临界区加互斥锁,保证同一时刻只有一个线程进入。
- 锁加在对象上,两个线程必须用同一个锁对象才有互斥效果。
- 同步实例方法锁 `this`,同步静态方法锁 `类.class`,两者互不干扰。
- 锁对象用 `private static final Object`,别用字符串和包装类型。
- `synchronized` 可重入,同一线程能重复获取自己持有的锁。
- 死锁需要四个条件同时成立,按固定顺序加锁可破坏循环等待。
- 用 `jstack` 排查死锁。