Skip to content

哈喽,大家好呀,我是呼噜噜,好久没有更新了,在现代计算机中,为了极致的压榨多核CPU的性能,提高程序的响应速度和处理效率,多线程是一种必不可少的优化手段。但是当我们在享受多线程带来性能提升的同时,也面临了一系列复杂的问题,其中最著名也最棘手就是死锁Deadlock问题

本文带你深入理解死锁,并通过实际案例解析,学会如何在实际开发中避免它

什么是多线程?

首先我们得明白进程线程的概念,在计算机的世界中,进程Process是操作系统中资源分配的基本单位进程之间的资源是独立隔离的,能很好的进行资源管理和保护。

线程(Thread)则是进程中的一个执行任务(控制单元),负责当前进程中程序的执行。线程是资源调度的基本单位,我们可以将一个进程比作一个厨房,那么线程就是厨房里的厨师。一个进程可以包含多个线程,至少包含一个线程,与进程不同的是多个线程之间资源数据是共享的


多线程的优势:

  1. CPU单核时代,多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率,当一个线程被 IO 阻塞,其他线程还可以继续使用 CPU
  2. CPU多核时代,多线程主要是为了提高进程利用多核 CPU 的能力,多个线程可以被映射到底层多个 CPU核心 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高。

多线程之间的资源共享

在多线程编程中,线程之间的协作和资源共享是常见的需求。虽然多线程可以协作,但当多个线程需要同时访问或修改同一个共享资源(比如一个共享变量、一个文件)时,问题就来了。这就像厨房里只有一口锅,两个厨师都想用它来炒菜,如果不加协调,场面就会一片混乱,菜也炒不好。

这个时候,我们往往会引入(Lock)来保护共享资源,它也叫互斥锁 Mutex,它能确保在任何时刻,只有一个线程可以访问被保护的资源

当一个线程需要访问资源时,它必须先获取锁。如果锁已经被其他线程持有,那么该线程就会被阻塞(进入等待状态),直到持有锁的线程释放锁

我们来看一个例子(java版):

java
// 模拟一个电商购物系统
class ECommerce {
    private double totalAmount; // 共享资源:订单总金额
    private int stock;          // 共享资源:商品库存

    // 构造函数,初始化库存和金额
    public ECommerce(int initialStock) {
        this.totalAmount = 0.0;
        this.stock = initialStock;
    }

    // 下单方法(线程安全)
    // 每次下单会减少库存,并累加订单金额
    public synchronized void placeOrder(int quantity, double pricePerItem) {
        System.out.println(Thread.currentThread().getName() + " 尝试下单: 数量=" + quantity + " 单价=" + pricePerItem);

        // 判断库存是否足够
        if (stock >= quantity) {
            // 扣减库存
            stock -= quantity;
            // 累加订单总金额
            double orderAmount = quantity * pricePerItem;
            totalAmount += orderAmount;

            System.out.println(Thread.currentThread().getName() + " 下单成功,总金额增加 " + orderAmount
                               + ",剩余库存=" + stock + ",累计金额=" + totalAmount);
        } else {
            System.out.println(Thread.currentThread().getName() + " 下单失败,库存不足!");
        }
    }

    // 获取当前总金额
    public synchronized double getTotalAmount() {
        return totalAmount;
    }

    // 获取剩余库存
    public synchronized int getStock() {
        return stock;
    }
}

public class ECommerceExample {
    public static void main(String[] args) throws InterruptedException {
        // 假设系统初始有 10 件商品
        ECommerce shop = new ECommerce(10);

        // 模拟 3 个用户同时下单
        Thread user1 = new Thread(() -> shop.placeOrder(3, 100.0), "用户A");
        Thread user2 = new Thread(() -> shop.placeOrder(5, 100.0), "用户B");
        Thread user3 = new Thread(() -> shop.placeOrder(4, 100.0), "用户C");

        // 启动线程
        user1.start();
        user2.start();
        user3.start();

        // 等待所有线程完成
        user1.join();
        user2.join();
        user3.join();

        // 打印最终统计
        System.out.println("操作结束,最终库存=" + shop.getStock() + ",最终金额=" + shop.getTotalAmount());
    }
}

执行结果:

bash
用户A 尝试下单: 数量=3 单价=100.0
用户A 下单成功,总金额增加 300.0,剩余库存=7,累计金额=300.0
用户B 尝试下单: 数量=5 单价=100.0
用户B 下单成功,总金额增加 500.0,剩余库存=2,累计金额=800.0
用户C 尝试下单: 数量=4 单价=100.0
用户C 下单失败,库存不足!
操作结束,最终库存=2,最终金额=800.0

什么是死锁?

凡是有利有弊,如果使用锁不当, 则会引发一个更严重的问题——死锁,它会让程序进入无尽的等待。死锁是指,两个或多个线程相互等待对方释放资源,从而导致程序无法继续执行的情况

比如,线程 A 持有共享资源 X,等待获取共享资源 Y;而线程 B 持有共享资源 Y,等待获取共享资源 X。 此时这两个线程就陷入了死锁状态,无法继续执行。若无外力干涉,它们都将无法推进下去

趁热打铁,我们接着来看一个典型的死锁案例(Java版):

java
public class LockOrderDeadlock {

    // 创建两个对象作为锁资源
    private static final Object lockX = new Object();
    private static final Object lockY = new Object();

    public static void main(String[] args) {
        // 创建线程 A
        Thread threadA = new Thread(() -> {
            // 线程 A 的执行逻辑
            try {
                // 首先,获取 lockX 的锁
                synchronized (lockX) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 lockX 锁");

                    // 暂停 1 秒,确保此时线程 B 有机会启动并获取 lockX
                    Thread.sleep(1000);

                    System.out.println(Thread.currentThread().getName() + " 尝试获取 lockY 锁...");
                    // 尝试获取 lockB 的锁
                    synchronized (lockY) {
                        System.out.println(Thread.currentThread().getName() + " 获得了 lockY 锁");
                    }
                }
            } catch (InterruptedException e) {
                // 处理线程中断异常
                e.printStackTrace();
            }
        }, "线程 A");

        // 创建线程 B
        Thread threadB = new Thread(() -> {
            // 线程 B 的执行逻辑
            try {
                // 首先,获取 lockB 的锁
                synchronized (lockY) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 lockY 锁");

                    // 暂停 1 秒,确保线程 A 能保持持有 lockX
                    Thread.sleep(1000);

                    System.out.println(Thread.currentThread().getName() + " 尝试获取 lockY 锁...");
                    // 尝试获取 lockY 的锁
                    synchronized (lockX) {
                        System.out.println(Thread.currentThread().getName() + " 获得了 lockY 锁");
                    }
                }
            } catch (InterruptedException e) {
                // 处理线程中断异常
                e.printStackTrace();
            }
        }, "线程 B");

        // 启动两个线程
        threadA.start();
        threadB.start();
    }
}

执行结果:

java
线程 A 获得了 lockX 锁
线程 B 获得了 lockY 锁
线程 B 尝试获取 lockY 锁...
线程 A 尝试获取 lockY 锁...

由此我们可以总结出,死锁的四个必要条件

  1. 互斥条件(Mutual Exclusion):一个资源每次只能被一个线程使用。这是锁的基本特性,无法破坏。
  2. 请求与保持条件(Hold and Wait):一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件(No Preemption):线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能在使用完后由自己释放。
  4. 循环等待条件(Circular Wait):若干线程之间形成一种头尾相接的循环等待资源关系。例如,线程A等待线程B的资源,线程B又在等待线程A的资源。

一个死锁的发生,必须同时满足上述四个条件。也就是说只要我们,破坏其中任意一个,就能避免死锁

如何避免死锁

  1. **按固定顺序获取锁 **

按固定顺序获取锁,这样可以破坏循环等待条件确保所有线程都以相同的顺序来请求锁,这是我们在实际开发中,最常用也是最有效的避免死锁的方法。

比如上面死锁的例子,我们调整2个线程获取锁的顺序一致,都是必须先获取 lockX,再获取 lockY

java
public class FixedLockOrder {

    // 创建两个对象作为锁资源
    private static final Object lockX = new Object();
    private static final Object lockY = new Object();

    public static void main(String[] args) {
        // 创建线程 A
        Thread threadA = new Thread(() -> {
            // 线程 A 的执行逻辑
            try {
                // 首先,获取 lockX 的锁
                synchronized (lockX) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 lockX 锁");

                    // 暂停 1 秒,模拟一些耗时操作
                    Thread.sleep(1000);

                    System.out.println(Thread.currentThread().getName() + " 尝试获取 lockY 锁...");
                    // 尝试获取 lockY 的锁
                    synchronized (lockY) {
                        System.out.println(Thread.currentThread().getName() + " 获得了 lockY 锁");
                    }
                }
            } catch (InterruptedException e) {
                // 处理线程中断异常
                e.printStackTrace();
            }
        }, "线程 A");

        // 创建线程 B
        Thread threadB = new Thread(() -> {
            // 线程 B 的执行逻辑
            try {
                // *** 修改点:同样先获取 lockX 的锁,保持与线程 A 一致的顺序 ***
                synchronized (lockX) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 lockX 锁");

                    // 暂停 1 秒,模拟一些耗时操作
                    Thread.sleep(1000);

                    System.out.println(Thread.currentThread().getName() + " 尝试获取 lockY 锁...");
                    // 接着尝试获取 lockY 的锁
                    synchronized (lockY) {
                        System.out.println(Thread.currentThread().getName() + " 获得了 lockY 锁");
                    }
                }
            } catch (InterruptedException e) {
                // 处理线程中断异常
                e.printStackTrace();
            }
        }, "线程 B");

        // 启动两个线程
        threadA.start();
        threadB.start();
    }
}

输出结果:

java
线程 A 获得了 lockX 锁
线程 A 尝试获取 lockY 锁...
线程 A 获得了 lockY 锁
线程 B 获得了 lockX 锁
线程 B 尝试获取 lockY 锁...
线程 B 获得了 lockY 锁

这样线程A线程B都能先后获取到锁lockXlockY,没有发生死锁

  1. 一次性获取所有锁

一次性获取所有锁,这样就破坏请求与保持条件,不持有任何锁的同时,一次性地尝试获取所有需要的锁。如果能全部获取成功,则执行;如果有一个获取失败,则释放所有已经获取到的锁,然后等待一段时间再重试。这通常需要使用 java.util.concurrent.locks.Lock 接口的 tryLock() 方法来实现

我们来看一个例子:

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

public class AvoidDeadlockByTryLock {

    // 使用 ReentrantLock 替代 synchronized
    private static final Lock lockX = new ReentrantLock();
    private static final Lock lockY = new ReentrantLock();

    public static void main(String[] args) {
        // 线程 A:先尝试获取 lockX,再尝试获取 lockY
        Thread threadA = new Thread(() -> doWork(lockX, lockY), "线程 A");

        // 线程 B:先尝试获取 lockY,再尝试获取 lockX
        Thread threadB = new Thread(() -> doWork(lockY, lockX), "线程 B");

        threadA.start();
        threadB.start();
    }

    /**
     * 通用的任务执行逻辑,避免重复代码
     */
    private static void doWork(Lock firstLock, Lock secondLock) {
        boolean gotFirst = false;
        boolean gotSecond = false;

        try {
            while (true) {
                // 尝试获取第一个锁
                gotFirst = firstLock.tryLock();
                if (gotFirst) {
                    System.out.println(Thread.currentThread().getName() + " 成功获取第一个锁");

                    // 尝试获取第二个锁
                    gotSecond = secondLock.tryLock();
                    if (gotSecond) {
                        System.out.println(Thread.currentThread().getName() + " 成功获取第二个锁");
                        break; // 两个锁都获取成功,退出循环
                    } else {
                        System.out.println(Thread.currentThread().getName() + " 获取第二个锁失败,释放第一个锁");
                        firstLock.unlock();
                    }
                }
                // 避免活锁:随机等待一小段时间再重试
                Thread.sleep((long) (Math.random() * 10));
            }

            // 在这里执行需要两个锁保护的业务逻辑
            System.out.println(Thread.currentThread().getName() + " 执行业务逻辑...");

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 确保释放已持有的锁
            if (gotSecond) {
                secondLock.unlock();
            }
            if (gotFirst) {
                firstLock.unlock();
            }
        }
    }
}

这样是没有死锁了,但它实现复杂度高,如果需要同时获取的锁数量很多,那我们就必须写一个循环去逐个 tryLock(),一旦有一个失败,就要释放已经获得的锁。另外需要额外的代码来保证“部分成功时回滚”(释放已拿到的锁),否则容易导致锁泄漏。

另外是性能损耗大、容易出现活锁、可扩展性差

活锁是指线程并未阻塞,它们都在持续运行,但却无法取得任何进展。它们不断地改变自身状态以响应其他线程的状态变化,但最终导致谁也无法完成任务。

  1. 使用带超时的锁

使用带超时的锁,这样破坏不可剥夺条件,线程在尝试获取锁时,如果超过一定时间仍未获取到,则放弃本次请求,并释放自己,已经获取的所有锁,然后重试或执行其他逻辑。这是一个非常经典的兜底策略

java
public class AvoidDeadlockByTimeoutLockUpdated {

    // 创建两个 ReentrantLock 实例作为锁资源
    private static final ReentrantLock lockX = new ReentrantLock();
    private static final ReentrantLock lockY = new ReentrantLock();

    public static void main(String[] args) {
        // 创建并启动线程 A
        Thread threadA = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " 启动,准备获取锁...");

                // 1. 尝试在 2 秒内获取 lockX
                if (lockX.tryLock(2, TimeUnit.SECONDS)) {
                    System.out.println(Thread.currentThread().getName() + " 成功获取了 lockX 锁");

                    // 为了让死锁场景更容易复现,在这里暂停 500 毫秒
                    // 这给了线程 B 足够的时间去获取 lockY
                    Thread.sleep(500);

                    System.out.println(Thread.currentThread().getName() + " 准备获取 lockY 锁...");
                    // 2. 尝试在 2 秒内获取 lockY
                    if (lockY.tryLock(2, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + " 成功获取了 lockY 锁");
                        System.out.println(Thread.currentThread().getName() + " ===> 成功完成任务!");
                    } else {
                        System.out.println(Thread.currentThread().getName() + " 获取 lockY 锁超时,任务失败。");
                    }

                } else {
                    System.out.println(Thread.currentThread().getName() + " 获取 lockX 锁超时,任务失败。");
                }
            } catch (InterruptedException e) {
                // 线程在 sleep 或 tryLock 期间被中断时会抛出此异常
                System.err.println(Thread.currentThread().getName() + " 被中断了。");
                Thread.currentThread().interrupt(); // 重新设置中断状态
            } finally {
                // 3. 使用 finally 块确保锁一定会被释放
                // 在释放前,必须检查当前线程是否真的持有了该锁,否则会抛出 IllegalMonitorStateException
                if (lockY.isHeldByCurrentThread()) {
                    lockY.unlock();
                    System.out.println(Thread.currentThread().getName() + " 释放了 lockY 锁。");
                }
                if (lockX.isHeldByCurrentThread()) {
                    lockX.unlock();
                    System.out.println(Thread.currentThread().getName() + " 释放了 lockX 锁。");
                }
            }
        }, "线程 A");

        // 创建并启动线程 B (与线程 A 的加锁顺序相反)
        Thread threadB = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " 启动,准备获取锁...");

                // 1. 尝试在 2 秒内获取 lockY
                if (lockY.tryLock(2, TimeUnit.SECONDS)) {
                    System.out.println(Thread.currentThread().getName() + " 成功获取了 lockY 锁");

                    Thread.sleep(500);

                    System.out.println(Thread.currentThread().getName() + " 准备获取 lockX 锁...");
                    // 2. 尝试在 2 秒内获取 lockX
                    if (lockX.tryLock(2, TimeUnit.SECONDS)) {
                        System.out.println(Thread.currentThread().getName() + " 成功获取了 lockX 锁");
                        System.out.println(Thread.currentThread().getName() + " ===> 成功完成任务!");
                    } else {
                        System.out.println(Thread.currentThread().getName() + " 获取 lockX 锁超时,任务失败。");
                    }

                } else {
                    System.out.println(Thread.currentThread().getName() + " 获取 lockY 锁超时,任务失败。");
                }
            } catch (InterruptedException e) {
                System.err.println(Thread.currentThread().getName() + " 被中断了。");
                Thread.currentThread().interrupt();
            } finally {
                // 3. 确保锁的释放
                if (lockX.isHeldByCurrentThread()) {
                    lockX.unlock();
                    System.out.println(Thread.currentThread().getName() + " 释放了 lockX 锁。");
                }
                if (lockY.isHeldByCurrentThread()) {
                    lockY.unlock();
                    System.out.println(Thread.currentThread().getName() + " 释放了 lockY 锁。");
                }
            }
        }, "线程 B");

        // 启动两个线程
        threadA.start();
        threadB.start();
    }
}

输出结果:

java
线程 A 启动,准备获取锁...
线程 B 启动,准备获取锁...
线程 A 成功获取了 lockX 锁
线程 B 成功获取了 lockY 锁
线程 B 准备获取 lockX 锁...
线程 A 准备获取 lockY 锁...
线程 A 获取 lockY 锁超时,任务失败。
线程 B 获取 lockX 锁超时,任务失败。
线程 B 释放了 lockY 锁。
线程 A 释放了 lockX 锁。

我们可以发现由于过期时间的存在,哪怕无法获取锁,也不会出现死锁

死锁的排查

当我们的程序出现死锁时,我们该如何排查呢?

最常见的就是使用 jdk 自带的线程堆栈分析工具jstack,使用它需要先通过jps -l找到当前正在运行的Java进程的PID。然后执行jstack -l <PID>,来打印指定进程的线程堆栈信息

如果发生死锁,jstack 的输出结果会在末尾明确地提示 Found one deadlock,并详细列出死锁的线程、它们正在等待的锁以及它们已经持有的锁。

另一个就是现在比较火的监控工具,Arthas,这是是阿里开源的Java诊断工具,它直接提供了专门的命令来检测死锁thread -b

尾语

多线程确实能极大的提升我们程序的性能,但它也是把双刃剑,掌握好锁机制,是驾驭它的关键。死锁其实并没有我们想那么可怕。只需牢记,死锁只有同时满足互斥、持有并等待、不可剥夺、循环等待这四个必要条件的时候才会发生,我们只需破坏其中一个,即可避免死锁。最后别忘了利用诊断工具排查具体的线程死锁问题

点赞收藏在看就是对笔者最好的催更!最后如果大家对死锁有自己的见解,欢迎在下方留言,我们下期再见~

参考资料:

聊聊CPU的发展历程之单核、多核、超线程

深入理解与避免Java 死锁_java中 什么是死锁 怎么避免死锁


作者:小牛呼噜噜

本文到这里就结束啦,感谢阅读,关注同名公众号:小牛呼噜噜,防失联+获取更多技术干货