39、死锁、活锁、饥饿
在前面的几节中我们讲解了各种锁,比如:synchronized 或 Lock 悲观锁、自旋 + CAS 乐观锁
锁的使用可以解决多线程安全问题,但同时也会带来性能问题(加锁、解锁耗时)
实际上除了性能问题之外,锁的使用还会导致其他问题,比如:死锁、活锁、饥饿,本节我们就来讲讲,锁的使用导致的死锁、活锁、饥饿问题
1、活跃性问题
我们把死锁、活锁、饥饿这三个问题,统称为活跃性问题
实际上活跃性问题是一个常见的问题,多线程、多进程、多机(分布式系统)请求共享资源(不一定是锁),都有可能出现活跃性问题
不过在本专栏中,我们只关注多线程请求锁时的活跃性问题
2、死锁
我们先来看下活跃性问题中的死锁问题,死锁指的是:多个线程互相等待对方持有的资源,而导致线程无法继续执行的问题
2.1、示例
我们举例来解释一下,示例代码如下所示
- 如果线程 t1 执行 Demo 对象中的 f() 函数,线程 t2 执行同一个 Demo 对象中的 g() 函数,两个线程就有可能会发生死锁
- 线程 t1 持有 lock1 锁,同时请求 lock2 锁,线程 t2 持有 lock2 锁,同时请求 lock1 锁
- 两个线程将会一直阻塞等待对方持有的锁,无法继续执行
public class Demo {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void f() {
synchronized (lock1) {
synchronized (lock2) {
// ... 业务逻辑 ...
}
}
}
public void g() {
synchronized (lock2) {
synchronized (lock1) {
// ... 业务逻辑 ...
}
}
}
}
2.2、解释
对于上面这个例子,线程持有锁以及等待锁的情况非常清晰,我们一眼就能看出代码是否存在死锁,但是很多情况下,死锁的检测并非如此简单
当多个线程竞争多个锁时,如果线程持有锁以及等待锁的情况非常复杂,那么计算机就需要一定的算法来检测死锁
死锁检测算法有很多,这里我们介绍一种比较简单的
- 我们将线程竞争锁的场景,抽象为有向图这种数据结构,线程抽象为图中的顶点
- 如果线程 A 等待线程 B 持有的锁,那么我们就在线程 A 和线程 B 对应的节点之间添加一条有向边
- 在有向图构建好之后,我们只需要使用 DFS 算法,检测有向图中是否存在环即可
- 如果有向图中存在环,那么这就说明多个线程之间存在锁的循环等待,即存在死锁
- 相反,如果有向图中不存在环,那么就说明不存在死锁
示例如下图所示,以下情况存在死锁
我们知道,大部分数据库都实现了死锁检测和解决
当数据库检测到死锁时,会终止循环等待锁的线程中的其中一个线程,让其释放所持有的锁,这样循环等待就被打破,其他线程便可以获取到锁,顺利往下执行
不过死锁检测本身并不属于 Java 语言层面的工作,甚至 JUC 也并没有提供相应的解决方案
如果我们希望程序能自动进行死锁检测和解决,那么需要我们自己来编写相应的代码
尽管在语言层面无法实现死锁的自动检测和解决,但是当发生死锁时
我们仍然可以通过类似 jstack 这类工具,将线程的运行状态等重要信息打印出来,以便分析死锁发生的原因,并对代码做及时的调整
关于如何使用 jstack 等工具分析死锁,我们在后面的章节中详细讲解
实际上,尽量避免编写引发死锁的代码,才是解决死锁问题的最根本方法,那么如何避免死锁呢?常用的方法有以下两个
- 统一线程请求锁的顺序
- 避免线程持有锁并等待锁
2.3、统一线程请求锁的顺序
实际上,上述示例代码发生死锁的原因,是两个线程采用不同的加锁顺序,如果两个线程采用相同的加锁顺序,那么就可以避免发生死锁
如下代码所示,两个线程分别同时执行 f() 函数和 g() 函数,只有一个线程成功获取 lock1 锁,获取 lock1 锁的线程,进而又会成功获取 lock2 锁,因此不会发生死锁
public class Demo {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void f() {
synchronized (lock1) {
synchronized (lock2) {
// ...业务逻辑 ...
}
}
}
public void g() {
synchronized (lock1) {
synchronized (lock2) {
// ... 业务逻辑 ...
}
}
}
}
以上加锁顺序的调整比较简单,但有些情况会稍微复杂,如下代码所示,如果多线程执行 transfer() 函数,那么是否会存在死锁问题呢?
public class Transaction {
public static void transfer(Account from, Account to, double transAmount) {
synchronized (from) {
synchronized (to) {
from.amount -= transAmount;
to.amount += transAmount;
}
}
}
}
我们先对上述代码的加锁方式做些解释:"+="、"-=" 操作跟自增、自减操作类似,都是非原子操作,并发执行会存在线程安全问题
为了解决线程安全问题,最简单的方法是给 transfer() 函数添加类锁,但类锁粒度太大,会导致所有的转账业务均无法并发执行,严重影响转账业务的执行效率
实际上如下示例所示,尽管同一组账户(A<->B 与 A<->B)之间不可以同时转账,但是两组不同的账户(A<->B 与 C<->D)之间是可以同时转账的
因此我们只需要使用细粒度的对象锁,只对转账的双方账户加锁,避免多个线程同时对同一组账户转账,这样就可以保证 transfer() 函数的线程安全性
对 transfer() 函数的加锁方式有了清晰了解之后,我们再来看多线程并发执行 transfer() 函数的死锁问题
两个线程执行并发执行 transfer() 函数时,看似加锁顺序是一致的:先对 from 加锁,再对 to 加锁,但是问题在于 from 和 to 并不是确定的
- 假设有两个 Account 对象,分别为 accoutA 和 accountB
- 一个线程执行 transfer(accountA, accountB, 15),对应的加锁顺序为:先对 accountA 加锁,再对 accountB 加锁
- 一个线程执行 transfer(accountB, accountA, 25),对应的加锁顺序为:先对 accountB 加锁,再对 accountA 加锁
- 显然两个线程的加锁顺序正好相反,并发执行就有可能发生死锁
为了解决上述问题,我们需要重新定义加锁的顺序,因为 Account 中包含一些 "可比较大小的、不变的" 成员变量,比如 id,我们可以基于 id 来定义加锁的顺序
如下代码所示,假设 accountA 的 id 小于 accountB 的 id,执行 transfer() 函数
不管是从 accountA 到 accountB 转账,还是从 accountB 到 accountA 转账,transfer() 函数总是先对 accountA 加锁,再对 accountB 加锁,从而避免了死锁
public class Transaction {
public static void transfer(Account from, Account to, double transAmount) {
Account lockFirst = from;
Account lockSecond = to;
if (from.id > to.id) {
lockFirst = to;
lockSecond = from;
}
synchronized (lockFirst) {
synchronized (lockSecond) {
from.amount -= transAmount;
to.amount += transAmount;
}
}
}
}
2.4、避免线程持有锁并等待锁
从死锁的定义我们可以发现:线程持有一个锁的同时等待另一个锁,是产生死锁的重要原因
如果我们避免线程持有一个锁的同时等待另一个锁,便可以有效的解决死锁问题
具体的做法是:如果一个线程请求一个锁时,这个锁已经被其他线程持有,那么这个线程不再阻塞等待这个锁,而是释放掉所持有的所有锁,让其他线程先执行
上述解决思路对应的代码实现如下所示,需要注意的是:synchronized 无法像 Lock 那样支持非阻塞的加锁方式,因此这种死锁的避免方式只能使用 Lock 来实现
public class Demo {
private Lock lock1 = new ReentrantLock();
private Lock lock2 = new ReentrantLock();
public void f() {
for (; ; ) {
lock1.lock(); // lock1 阻塞
try {
boolean locked = lock2.tryLock(); // lock2 非阻塞
// 两个锁都获取了
if (locked) {
try {
// ... 业务逻辑 ...
return;
} finally {
lock2.unlock();
}
}
// else 没有获取 lock2 锁, 执行 finally, 释放 lock1 锁
} finally {
lock1.unlock();
}
}
}
public void g() {
for (; ; ) {
lock2.lock(); // lock2 阻塞
try {
boolean locked = lock1.tryLock(); // lock1 非阻塞
// 两个锁都获取了
if (locked) {
try {
// ... 业务逻辑 ...
return;
} finally {
lock1.unlock();
}
}
// else 没有获取 lock1 锁, 执行 finally, 释放 lock2 锁
} finally {
lock2.unlock();
}
}
}
}
如下图所示,假设两个线程 t1 和 t2 分别同时执行 f() 函数和 g() 函数
线程 t1 获取到 lock1 锁,线程 t2 获取到 lock2 锁,线程 t1 尝试获取 lock2 锁失败,则会将已经持有的 lock1 锁释放
此时线程 t2 尝试获取 lock1 锁成功,线程 t2 同时获取到 lock2 锁和 lock1 锁,便可以顺利执行业务逻辑
在业务逻辑完之后,线程 t2 将 lock2 锁和 lock1 锁释放,此时线程 t1 便可成功获取 lock1 锁和 lock2 锁,然后执行相应的业务逻辑
3、活锁
3.1、什么是活锁
实际上避免死锁的方式二(避免持有锁并等待锁)是存在问题的
如下图所示,在极端情况下,两个线程循环反复执行以下逻辑:线程 t1 获取 lock1 锁,线程 t2 获取到 lock2 锁
线程 t1 尝试获取 lock2 锁失败之后,释放掉 lock1 锁,与此同时,线程 t2 尝试获取 lock1 锁失败之后,释放掉 lock2 锁
然后线程 t1 和 t2 再重复执行上述逻辑,这就导致线程 t1 和 t2 一直循环加锁、尝试加锁、释放锁,我们把这种情况叫做活锁
3.2、解释
死锁和活锁的区别有两点
- 第一点:处于死锁状态的两个线程均处于阻塞状态,不消耗 CPU 资源
相反,处于活锁状态的两个线程,仍然在不停的执行加锁、尝试加锁、释放锁等代码逻辑,消耗 CPU 资源,比起死锁,活锁的性能损耗更大 - 第二点:死锁发生的概率很低,在高并发情况下,两个线程频繁竞争执行临界区,才有可能发生死锁
不过相对于死锁,活锁发生的概率更低
发生活锁,除了具备死锁发生的条件之外,还需要两个线程的执行过程非常同步,才能保证 for 循环一直不退出
对于死锁和活锁的区别,我们举一个生活中例子,再形象地解释一下
在一个比较窄但允许两人同行的马路上,两个人相对而行,如果两个人相撞
- 死锁的处理思路是:两个人僵持不动,谁都无法往前走
- 活锁的处理思路是:两个人都很客气的让路给对方,但是两人同时移动到另一侧,又继续相撞,再移动回来又相撞,一直这样持续下去,那么就会发生活锁
从这个例子上我们可以看出:活锁发生的概率是非常非常低的,两人的移动必须一直保持完全同步才可以,不然很快就可以解锁
3.3、解决
尽管活锁出现的概率比较低,但一旦出现活锁,便会导致比较严重的问题:线程持续做无用功,业务代码无法执行,白白浪费 CPU 资源,因此我们需要想办法解决活锁问题
实际上解决的办法也很简单,我们只需要让线程在执行的过程中,暂停随机的一小段时间,打破两个线程的持续同步即可
对于代码实现方法来说,我们既可以在 tryLock() 之后添加 sleep() 语句,也可以将 tryLock() 函数改为带超时时间的 tryLock() 函数
示例代码如下所示,我们只给出了 f() 函数修改之后的代码,对于 g() 函数,修改方法类似,留给你自己实现
public void f() {
Random r = new Random();
for (; ; ) {
lock1.lock(); // lock1 阻塞
try {
boolean locked = false;
try {
locked = lock2.tryLock(r.nextLong() % 10, TimeUnit.MILLISECONDS); // lock2 可超时锁
} catch (InterruptedException e) {
// log error and return
}
// 两个锁都获取了
if (locked) {
try {
// ... 业务逻辑 ...
return;
} finally {
lock2.unlock();
}
}
// else 没有获取 lock2 锁, 执行 finally, 释放 lock1 锁
} finally {
lock1.unlock();
}
}
}
4、饥饿
死锁和活锁是两个或两个以上线程的整体状态,饥饿则是一个线程的状态
死锁和活锁都会导致线程处于饥饿状态,迟迟获取不到锁,不过导致线程饥饿的情况还有很多
在前面的章节中,我们也介绍过一些发生线程饥饿的情况
- 非公平锁:因为新来的线程不需要排队,就可以 "插队" 直接竞争锁
如果一直都有新来的线程竞争锁,那么排队的线程就有可能一直没有机会获取到锁,就会出现饥饿现象 - 自旋 + CAS 也算是非公平锁:源源不断的线程执行自旋 + CAS,有可能会导致有些线程一直无法成功执行 CAS 而持续自旋,从而出现饥饿的现象
- 读写锁:如果等待队列的队首线程在等待写锁,即便其他线程持有读锁,新来请求读锁的线程也会直接排队,而非直接获取读锁
这样做就是为了避免等待写锁的线程迟迟获取不到写锁而饥饿
实际上,死锁、活锁、饥饿这些活跃性问题,大部分情况下都发生在高并发的极端情况下,比如:流量突增
这也是活跃性问题很难测试、很难发现、很难重现的主要原因
活跃性问题一旦发生,就会导致非常严重的结果,比如:Tomcat 等 Web 服务器采用线程池来多线程执行用户请求
- 当多个线程发生死锁、活锁、饥饿等活跃性问题时,这些线程处理的请求将会超时,并且这些线程迟迟无法响应其他请求
- 当发生活跃性问题的线程越来越多时,线程池中的可用线程就越来越少,大量用户请求需要排队等待线程执行,最终导致请求大量超时
不过话又说回来,在平时的业务开发中,我们很少会编写多线程代码,也很少会用到线程级的锁,比如:对于前面讲到的 transfer() 函数
- 在真实的业务开发中,数据一般存储在数据库中
transfer() 函数直接操作数据库中的 account 表中的记录,实现两个账户中金额的加减 - 当 transfer() 函数操作 Account 表时,其他进程也有可能正在操作 Account 表中相同的记录
而单单使用线程级的锁,并不能解决进程间的互斥问题
针对这种情况,我们一般会使用数据库锁(可以看做是进程级的锁)来解决
当执行转账业务时,transfer() 函数使用数据库锁,先后锁定 Account 表中的转账所涉及的两行记录,以免其他进程或线程操作这两行记录
实际上相比线程级的锁,进程级的锁更容易引发死锁
这是因为:两个进程对应的程序有可能是两个不同的程序员开发的,甚至有可能是两个不同的团队开发的
一个团队并不不知道另一个团队所开发的程序,到底都用了哪些锁,以及如何使用的这些锁
因此协调好统一的加锁顺序是比较困难的,这也是我在过往的开发中经常遇到的死锁的情况
5、课后思考题
哲学家进餐问题是死锁问题的经典例子:n 个哲学家围坐在餐桌旁,n 只筷子分别摆放在任意两个哲学家中间
哲学家每次都先从左边拿筷子,再从右边拿筷子,两个筷子都拿到之后才可以进餐,进完餐之后放下两个筷子
请编程模拟以上哲学家的进餐过程,并且指出死锁的原因以及相应的解决方案
模拟代码如下所示:每个哲学家都持有编号为 (id - 1 + n) % n 的筷子,并请求编号为 id 的筷子,那么这就会导致死锁
解决方案有多种,其中一种是将 lock() 改为 tryLock(),并在获取锁失败之后随机等待一段时间再重新请求所示
public class DinningPhilosophers {
public static class Philosopher implements Runnable {
private int id;
public Philosopher(int id) {
this.id = id;
}
@Override
public void run() {
chopsticks[(this.id - 1 + n) % n].lock();
chopsticks[this.id].lock();
System.out.println("philosopher " + this.id + " eat.");
chopsticks[this.id].unlock();
chopsticks[(this.id - 1 + n) % n].unlock();
}
}
private static int n = 10;
private static Lock[] chopsticks = new ReentrantLock[n]; // 筷子
private static Philosopher[] philosophers = new Philosopher[n]; // 哲学家
static {
for (int i = 0; i < n; i++) {
philosophers[i] = new Philosopher(i);
chopsticks[i] = new ReentrantLock();
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[n];
for (int i = 0; i < n; i++) {
threads[i] = new Thread(philosophers[i]);
threads[i].start();
}
for (int i = 0; i < n; i++) {
threads[i].join();
}
System.out.println("All philosophers eat.");
}
}
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17489274.html