缘博客
发布日期

Java多线程(三):线程安全、同步机制、Lock与死锁

作者
  • 姓名
    社交账号

前言

多个线程能够同时访问数据,是多线程高效的原因,也是错误的来源。卖票程序明明只有 100 张票,却可能打印重复票号;两个线程各自执行一万次自增,最终结果却小于两万。这类问题不能靠“多运行几次看起来没错”来证明安全,因为线程调度的时机具有不确定性。

本篇从一个卖票案例出发,分析线程安全问题出现的条件,依次介绍同步代码块、同步方法和 Lock,最后讨论死锁的形成与预防。

一、线程安全问题是怎样发生的

先看一个没有加锁的卖票任务:

public class TicketTask implements Runnable {
    private int tickets = 100;

    @Override
    public void run() {
        while (true) {
            if (tickets <= 0) {
                break;
            }

            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }

            System.out.println(
                    Thread.currentThread().getName()
                            + " 卖出第 " + tickets-- + " 张票"
            );
        }
    }
}
public class TicketDemo {
    public static void main(String[] args) {
        TicketTask task = new TicketTask();
        new Thread(task, "窗口一").start();
        new Thread(task, "窗口二").start();
        new Thread(task, "窗口三").start();
    }
}

tickets-- 看起来是一行代码,但它并不是不可分割的整体。它至少包含读取当前值、计算减一、写回新值等步骤。线程 A 读取到 10 后可能暂停,线程 B 也读取到 10,然后两者分别写回 9,于是同一张票就可能被重复处理。

通常,当下面三个条件同时出现时,就要警惕线程安全问题:

  1. 存在多个线程。
  2. 多个线程访问同一份共享数据。
  3. 对共享数据的操作不是原子的。

解决思路不是禁止多线程,而是把必须保持一致的一组操作保护起来,让同一时刻只有一个线程能够执行这段临界区代码。

二、同步代码块

Java 可以使用 synchronized 为一段代码加上内置锁:

synchronized (锁对象) {
    // 操作共享数据的临界区
}

改造卖票程序:

public class SafeTicketTask implements Runnable {
    private int tickets = 100;

    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                if (tickets <= 0) {
                    break;
                }

                System.out.println(
                        Thread.currentThread().getName()
                                + " 卖出第 " + tickets-- + " 张票"
                );
            }
        }
    }
}

每个 Java 对象都关联着一个内置锁,也叫监视器锁。线程进入同步代码块前必须先获得指定对象的锁;一个线程持有锁时,其他竞争同一把锁的线程只能等待。线程离开代码块时,无论正常结束还是抛出异常,锁都会被释放。

锁对象必须相同

synchronized 能否生效,关键不在于代码看起来是否一样,而在于线程是否竞争同一个锁对象。下面的写法是错误的:

synchronized (new Object()) {
    tickets--;
}

每次循环都创建新对象,相当于每个线程拿着不同的钥匙,彼此无法互斥。

如果多个线程共享同一个 Runnable 对象,使用 this 通常能够锁住同一个任务实例。如果共享数据是静态变量,常使用当前类的字节码对象:

synchronized (SafeTicketTask.class) {
    // 操作静态共享数据
}

锁的范围应当完整但尽量小

检查票数和扣减票数必须放在同一个同步区域,否则判断完成后仍可能被其他线程抢先修改。另一方面,与共享数据无关的耗时操作不应随意放进锁中,否则所有线程都会排队,降低并发能力。

临界区的边界应遵循一句话:保证共享状态不变式所需的代码必须一起加锁,与共享状态无关的工作尽量移到锁外。

三、同步方法

如果一个方法的全部逻辑都需要被锁保护,可以在方法上使用 synchronized

public synchronized boolean sellOne() {
    if (tickets <= 0) {
        return false;
    }

    System.out.println(
            Thread.currentThread().getName()
                    + " 卖出第 " + tickets-- + " 张票"
    );
    return true;
}

普通同步方法锁住的是当前实例 this;静态同步方法锁住的是当前类的 Class 对象。

public static synchronized void updateGlobalData() {
    // 锁对象是当前类的 Class 对象
}

同步方法写法更简洁,同步代码块则可以精确控制锁的对象和范围。两者没有绝对优劣,应根据临界区边界选择。

还要注意,Java 内置锁是可重入的。同一个线程已经持有某对象的锁时,可以再次进入由同一把锁保护的方法或代码块,而不会把自己阻塞住。

四、synchronized 解决的不只是“排队”

很多人把 synchronized 仅理解为“一次只让一个线程执行”,其实它还承担内存可见性语义。一个线程释放某把监视器锁之前的写操作,对后续获得同一把锁的线程可见。

因此,加锁既保护复合操作不被交叉干扰,也帮助线程看到共享数据的最新状态。前提仍然是所有相关读写都遵守同一套同步约定;如果写操作加锁、读操作完全绕开锁,程序依然可能出问题。

五、显式锁 Lock

java.util.concurrent.locks.Lock 提供了比 synchronized 更灵活的锁操作,最常用实现是 ReentrantLock

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTicketTask implements Runnable {
    private int tickets = 100;
    private final Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            lock.lock();
            try {
                if (tickets <= 0) {
                    return;
                }

                System.out.println(
                        Thread.currentThread().getName()
                                + " 卖出第 " + tickets-- + " 张票"
                );
            } finally {
                lock.unlock();
            }
        }
    }
}

最重要的规范是:获取锁后,必须在 finally 中释放锁。如果业务代码抛出异常而没有执行 unlock(),其他线程可能永远拿不到锁。

ReentrantLock 同样可重入,还提供了更丰富的能力:

  • tryLock():立即尝试获取锁,失败时可以执行备用逻辑。
  • tryLock(time, unit):在限定时间内尝试获取锁。
  • lockInterruptibly():等待锁的过程可以响应中断。
  • newCondition():创建条件对象,实现更精细的等待与唤醒。
  • 可选公平策略:按等待顺序倾向于让等待更久的线程先获得锁,但通常会牺牲吞吐量。

synchronized 与 Lock 如何选择

对于结构简单、进入和退出范围清楚的互斥逻辑,synchronized 更简洁,也不容易忘记释放。需要限时获取、可中断获取、多个条件队列等高级能力时,再考虑 Lock

锁并非越高级越好。选择能清楚表达需求、最不容易写错的工具通常更重要。

六、死锁

死锁是指两个或多个线程永久等待对方持有的资源,彼此都无法继续执行。

public class DeadlockDemo {
    private static final Object LOCK_A = new Object();
    private static final Object LOCK_B = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK_A) {
                sleepBriefly();
                synchronized (LOCK_B) {
                    System.out.println("线程一执行完成");
                }
            }
        }, "线程一");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK_B) {
                sleepBriefly();
                synchronized (LOCK_A) {
                    System.out.println("线程二执行完成");
                }
            }
        }, "线程二");

        t1.start();
        t2.start();
    }

    private static void sleepBriefly() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

可能出现的过程如下:

  1. 线程一获得 LOCK_A,等待 LOCK_B
  2. 线程二获得 LOCK_B,等待 LOCK_A
  3. 两个线程都不肯释放已经持有的锁,也永远等不到另一把锁。

sleep() 只是扩大了示例复现死锁的概率,并不是死锁的根本原因。根本问题是线程以相反顺序嵌套获取多把锁。

七、如何预防死锁

常见策略包括:

1. 统一加锁顺序

如果所有代码都先获取 LOCK_A,再获取 LOCK_B,就不会形成环形等待。这是最常用、也最容易审查的策略。

2. 缩小锁的范围

不要在持有锁时执行无关的耗时 I/O、长时间休眠或不可控的外部调用。持锁时间越长,竞争和死锁风险越高。

3. 避免不必要的嵌套锁

能用一把锁维护的不变式,不要拆成多把互相嵌套的锁。确实需要多把锁时,应把获取顺序写成统一约定。

4. 使用 tryLock 和超时

使用 ReentrantLock.tryLock() 时,获取第二把锁失败可以主动释放第一把锁,等待一段时间后重试,从而避免无限等待。实现时仍要在 finally 中准确释放已经成功获取的锁。

八、常见误区

误区一:加了 synchronized 就一定安全

只有所有相关线程围绕同一份共享数据使用同一把锁,并且临界区范围完整,才能保证互斥。锁对象不同、部分操作绕过锁、先检查后在锁外修改,都会破坏安全性。

误区二:范围越大越安全

直接锁住整个大方法虽然省事,却可能把原本可以并行的工作全部串行化。正确做法是先找出共享状态和不变式,再确定最小的完整临界区。

误区三:线程安全等于执行顺序固定

线程安全保证结果符合约束,并不保证每次由同一个线程先执行。卖票程序加锁以后不会重复售票,但哪个窗口卖出哪一张票仍然不确定。

误区四:sleep 会释放锁

sleep() 只让当前线程暂停,不会释放它已经持有的监视器锁。需要释放锁并等待条件变化时,应使用下一篇要讲的 wait(),或者更高层的并发工具。

九、本篇小结

线程安全问题来源于多个线程对共享数据进行非原子复合操作。同步代码块和同步方法通过同一把监视器锁实现互斥与可见性;ReentrantLock 则提供可中断、可超时和多条件等更灵活的能力。

使用锁时,最关键的不是“把 synchronized 写上去”,而是识别共享数据、选对共同锁对象、覆盖完整临界区,并把锁范围控制在合理大小。涉及多把锁时,还要统一获取顺序,避免死锁。

下一篇将介绍线程之间如何等待和通知,并用生产者—消费者模型理解 wait()notifyAll() 和阻塞队列。

参考资料