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

Java 入门教程

线程同步 synchronized

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

JavaJava 入门教程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);
    }
}

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

期望结果: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 的结果

两次自增,结果只加了 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。两边都不放手,程序永远停在这里。

死锁的四个条件

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

  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;
        }
    }
}

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

其他实用建议:

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

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

本节小结

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