- 发布日期
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,于是同一张票就可能被重复处理。
通常,当下面三个条件同时出现时,就要警惕线程安全问题:
- 存在多个线程。
- 多个线程访问同一份共享数据。
- 对共享数据的操作不是原子的。
解决思路不是禁止多线程,而是把必须保持一致的一组操作保护起来,让同一时刻只有一个线程能够执行这段临界区代码。
二、同步代码块
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();
}
}
}
可能出现的过程如下:
- 线程一获得
LOCK_A,等待LOCK_B。 - 线程二获得
LOCK_B,等待LOCK_A。 - 两个线程都不肯释放已经持有的锁,也永远等不到另一把锁。
sleep() 只是扩大了示例复现死锁的概率,并不是死锁的根本原因。根本问题是线程以相反顺序嵌套获取多把锁。
七、如何预防死锁
常见策略包括:
1. 统一加锁顺序
如果所有代码都先获取 LOCK_A,再获取 LOCK_B,就不会形成环形等待。这是最常用、也最容易审查的策略。
2. 缩小锁的范围
不要在持有锁时执行无关的耗时 I/O、长时间休眠或不可控的外部调用。持锁时间越长,竞争和死锁风险越高。
3. 避免不必要的嵌套锁
能用一把锁维护的不变式,不要拆成多把互相嵌套的锁。确实需要多把锁时,应把获取顺序写成统一约定。
4. 使用 tryLock 和超时
使用 ReentrantLock.tryLock() 时,获取第二把锁失败可以主动释放第一把锁,等待一段时间后重试,从而避免无限等待。实现时仍要在 finally 中准确释放已经成功获取的锁。
八、常见误区
误区一:加了 synchronized 就一定安全
只有所有相关线程围绕同一份共享数据使用同一把锁,并且临界区范围完整,才能保证互斥。锁对象不同、部分操作绕过锁、先检查后在锁外修改,都会破坏安全性。
误区二:范围越大越安全
直接锁住整个大方法虽然省事,却可能把原本可以并行的工作全部串行化。正确做法是先找出共享状态和不变式,再确定最小的完整临界区。
误区三:线程安全等于执行顺序固定
线程安全保证结果符合约束,并不保证每次由同一个线程先执行。卖票程序加锁以后不会重复售票,但哪个窗口卖出哪一张票仍然不确定。
误区四:sleep 会释放锁
sleep() 只让当前线程暂停,不会释放它已经持有的监视器锁。需要释放锁并等待条件变化时,应使用下一篇要讲的 wait(),或者更高层的并发工具。
九、本篇小结
线程安全问题来源于多个线程对共享数据进行非原子复合操作。同步代码块和同步方法通过同一把监视器锁实现互斥与可见性;ReentrantLock 则提供可中断、可超时和多条件等更灵活的能力。
使用锁时,最关键的不是“把 synchronized 写上去”,而是识别共享数据、选对共同锁对象、覆盖完整临界区,并把锁范围控制在合理大小。涉及多把锁时,还要统一获取顺序,避免死锁。
下一篇将介绍线程之间如何等待和通知,并用生产者—消费者模型理解 wait()、notifyAll() 和阻塞队列。