并发 - 死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)

Java并发编程:死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)

并发编程中,线程间资源竞争和协作可能导致三种程序无法正常推进的状态:死锁、活锁和饥饿。理解它们是编写健壮多线程代码的关键。

1. 死锁 (Deadlock)

1.1 什么是死锁?

定义:死锁是指两个或多个线程在执行过程中,因争夺共享资源而造成的一种互相等待的僵局。在这种状态下,若无外力干预,所有相关线程都将无法继续执行。

生活中的例子:两个人过一座很窄的独木桥,在桥中间相遇。A想让B先退回去,B想让A先退回去,但谁也不肯让步,结果两个人都被卡在桥上,无法前进也无法后退。

1.2 死锁产生的四个必要条件

一个死锁的发生,必须同时满足以下四个条件。只要破坏其中任意一个,就能有效预防死锁。

  1. 互斥条件 (Mutual Exclusion)

    • 含义:一个资源在同一时刻只能被一个线程占有。如果其他线程请求该资源,则必须等待,直到资源被释放。
    • 例子:就像独木桥一次只能过一个人。
  2. 请求与保持条件 (Hold and Wait)

    • 含义:一个线程在请求其他资源而被阻塞时,并不会释放自己已经占有的资源。
    • 例子:A占着桥的左半边,同时请求B让出右半边,但他自己不肯退回左边。
  3. 不剥夺条件 (No Preemption)

    • 含义:线程已经获得的资源,在未使用完毕之前,不能被其他线程强行剥夺,只能由持有该资源的线程自己主动释放。
    • 例子:不能强行把A从桥上推下去。
  4. 循环等待条件 (Circular Wait)

    • 含义:存在一个线程资源的循环等待链。线程T1等待线程T2的资源,线程T2等待线程T3的资源,...,而线程Tn等待线程T1的资源,形成一个闭环。
    • 例子:A等着B后退,B等着A后退,形成循环等待。

1.3 如何处理死锁?

主要有三种策略:预防、避免、检测与解除。

1.3.1 预防死锁 (Deadlock Prevention)

通过破坏四个必要条件中的一个或多个来预防死锁的发生。

  • 破坏“请求与保持”:可以采用“一次性申请”策略,即线程在运行前一次性申请所有需要的资源。但这会降低资源利用率,并可能导致“饥饿”。
  • 破坏“不剥夺”:如果一个线程请求新资源得不到满足,它必须释放已持有的资源。实现起来比较复杂。
  • 破坏“循环等待”:这是最常用、最有效的预防策略。通过按序加锁来实现。规定所有线程必须按照相同的顺序来请求资源(例如,总是先申请锁A,再申请锁B)。这样就不会形成循环等待链。

1.3.2 避免死锁 (Deadlock Avoidance)

这是一种更动态的方法,在运行时判断资源分配是否会将系统带入“不安全状态”。最著名的算法是银行家算法,但它要求预知每个线程所需的最大资源量,在实际系统中应用较少。

1.3.3 检测与解除死锁 (Deadlock Detection & Recovery)

允许死锁发生,但系统会持续监控,一旦检测到死锁,就采取措施解除。

  • 如何检测:在Java中,可以通过 jstack 命令行工具或 ThreadMXBean API来检测死锁。jstack <pid> 命令会打印出JVM的线程快照,如果存在死锁,会明确地指出。
  • 如何解除:一旦检测到死-锁,最常见的解除方式是中断其中一个或多个线程(强制释放资源),或者直接重启服务。

1.4 死锁代码示例

下面是一个经典的两个线程互相等待对方锁的死锁示例:

public class DeadlockExample {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        // 线程1:先锁A,再尝试锁B
        new Thread(() -> {
            synchronized (lockA) {
                System.out.println("线程1: 持有锁A,尝试获取锁B...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("线程1: 同时持有锁A和锁B");
                }
            }
        }).start();

        // 线程2:先锁B,再尝试锁A
        new Thread(() -> {
            synchronized (lockB) {
                System.out.println("线程2: 持有锁B,尝试获取锁A...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("线程2: 同时持有锁B和锁A");
                }
            }
        }).start();
    }
}

在这个例子中,线程1持有锁A等待锁B,而线程2持有锁B等待锁A,形成了循环等待,导致死锁。

1.5 预防死锁实战:按序加锁 (以转账为例)

在实际编程中,最常用、最有效的预防死锁策略是破坏“循环等待”条件。我们通过强制所有线程按照相同的、预先定义好的顺序来获取锁,从而避免形成循环等待链。

1.5.1 可能导致死锁的转账实现

场景:线程1从账户A转账给账户B,同时线程2从账户B转账给账户A。

// 辅助类
class Account {
    private String name;
    public Account(String name) { this.name = name; }
    public String getName() { return this.name; }
    // ... 其他方法如 withdraw, deposit
}

// 错误的转账实现
public void transfer(Account from, Account to, double amount) {
    // 先锁定转出账户
    synchronized (from) {
        System.out.println(Thread.currentThread().getName() + ": 锁定了 " + from.getName());
        try { Thread.sleep(100); } catch (InterruptedException e) {} // 模拟业务耗时

        // 再锁定转入账户
        synchronized (to) {
            System.out.println(Thread.currentThread().getName() + ": 锁定了 " + to.getName());
            // from.withdraw(amount);
            // to.deposit(amount);
        }
    }
}

死锁如何发生:

  1. 线程1调用 transfer(accountA, accountB, ...),获得 accountA 的锁。
  2. 同时,线程2调用 transfer(accountB, accountA, ...),获得 accountB 的锁。
  3. 接下来,线程1尝试获取accountB的锁(被线程2持有),进入等待。
  4. 同时,线程2尝试获取accountA的锁(被线程1持有),也进入等待。
  5. 线程1等待线程2,线程2等待线程1,形成循环等待,死锁发生。

1.5.2 正确的预防策略:按序加锁

为了破坏循环等待,我们可以规定:无论从哪个账户转到哪个账户,在获取锁时,必须按照一个固定的顺序。例如,我们可以根据对象的哈希值从小到大的顺序来加锁。

// 改进后的安全转账实现
public void safeTransfer(Account from, Account to, double amount) {
    Account firstLock = from;
    Account secondLock = to;

    // 通过比较对象的哈希值来确定加锁顺序,这是一个简单有效的方法
    if (System.identityHashCode(from) > System.identityHashCode(to)) {
        firstLock = to;
        secondLock = from;
    }

    // 始终先锁定 firstLock,再锁定 secondLock
    synchronized (firstLock) {
        System.out.println(Thread.currentThread().getName() + ": 锁定了 " + firstLock.getName());
        try { Thread.sleep(100); } catch (InterruptedException e) {}

        synchronized (secondLock) {
            System.out.println(Thread.currentThread().getName() + ": 锁定了 " + secondLock.getName());
            // from.withdraw(amount);
            // to.deposit(amount);
        }
    }
}

为什么这样能预防死锁?

  • 当线程1调用 safeTransfer(accountA, accountB, ...) 时,它会根据哈希值确定加锁顺序。
  • 当线程2调用 safeTransfer(accountB, accountA, ...) 时,它也会根据相同的哈希值比较逻辑,得出完全相同的加锁顺序。

这样一来,所有线程都将遵循同一个加锁顺序,获取锁的顺序变得线性化,循环等待的条件被破坏,死锁就永远不会发生了。

1.6 实战排查:使用 jstack

当线上应用发生疑似死锁的卡顿时,jstack 是最有效的诊断工具。

  1. 获取进程ID:使用 jps 或 ps -ef | grep java 命令找到你的Java应用的进程ID(pid)。
  2. 执行jstack:运行命令 jstack <pid>。
  3. 分析输出:如果存在死锁,jstack 会在输出的末尾明确地报告出来,并包含以下关键信息:
Found 1 deadlock.
===================

"Thread-1":
  waiting to lock monitor 0x00007f8b1c003e88 (a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007f8b1c003e78 (a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at DeadlockExample.lambda$main$1(DeadlockExample.java:28)
        - waiting to lock <0x000000076b8b8f78> (a java.lang.Object)
        - locked <0x000000076b8b8f88> (a java.lang.Object)
        ...
"Thread-0":
        at DeadlockExample.lambda$main$0(DeadlockExample.java:17)
        - waiting to lock <0x000000076b8b8f88> (a java.lang.Object)
        - locked <0x000000076b8b8f78> (a java.lang.Object)
        ...

关键信息解读:

  • Found 1 deadlock.: 最直接的证据。
  • waiting to lock ... which is held by ...: 清晰地展示了线程间的等待关系。
  • locked <...>: 显示了每个线程当前已经持有的锁。
  • 代码行号: at DeadlockExample.lambda$main$1(DeadlockExample.java:28) 精确地指出了线程停在代码的哪一行。

通过这些信息,可以快速定位导致死锁的代码逻辑并进行修复。

2. 活锁 (Livelock)

2.1 什么是活锁?

定义:活锁是指两个或多个线程互相礼让,都在尝试避免死锁,但因为过于“积极”地响应对方的状态,导致它们持续地改变自己的状态,却无法取得任何实质性的进展。线程没有被阻塞,CPU 资源仍在消耗,但任务却无法完成。

生活中的例子:两个人在狭窄的走廊相遇,都想给对方让路。A向左移,B也向左移;A向右移,B也向右移。他们不断重复这个动作,谁也无法通过。

2.2 活锁与死锁的区别

  • 死锁:线程被阻塞,无法继续执行,等待的资源永远不会被释放。
  • 活锁:线程没有被阻塞,CPU 资源仍在消耗,但它们不断地重试并改变状态,却无法完成任务。

2.3 如何避免活锁?

  • 引入随机性:在重试之前,引入随机的等待时间。
  • 优先级:给予其中一个线程更高的优先级,使其能优先执行完成。

3. 饥饿 (Starvation)

3.1 什么是饥饿?

定义:饥饿是指一个或多个线程由于无法获得所需的 CPU 时间或其他资源,导致它们长时间甚至永远无法执行,最终无法完成任务。与死锁和活锁不同,饥饿是单向的,只有一个线程或少数线程受到影响,其他线程可能正常执行。

生活中的例子:在一个非常繁忙的餐厅,如果服务员总是优先服务那些“嗓门大”或“关系好”的顾客,那么那些礼貌、不催促的顾客可能会等很久,甚至永远等不到服务。

3.2 饥饿的产生原因

  1. 线程优先级不当:高优先级的线程持续占用 CPU,导致低优先级的线程长时间得不到执行机会。
  2. 非公平锁:锁机制不保证等待线程的顺序。新来的线程可能比等待已久的线程更容易获得锁。
  3. 资源耗尽:一个线程持续占用或请求大量资源,导致其他线程无法获得所需资源。
  4. 死循环:某个线程进入死循环,长时间不释放 CPU。

3.3 如何避免饥饿?

  1. 使用公平锁:ReentrantLock 可以通过构造函数 new ReentrantLock(true) 创建公平锁,确保线程按照请求的顺序获取锁。
  2. 避免设置过高的线程优先级:合理使用线程优先级,或尽量不依赖线程优先级来保证程序正确性。
  3. 避免长时间占用共享资源:设计算法时,确保线程能及时释放其持有的资源。
  4. 资源分配公平性:确保所有线程都有机会获得所需的资源。

4. 死锁、活锁、饥饿的总结对比

特性 死锁 (Deadlock) 活锁 (Livelock) 饥饿 (Starvation)
定义 线程互相等待资源,都无法推进,阻塞。 线程互相礼让,不断改变状态,但无法推进,不阻塞,忙循环。 线程长时间或永远无法获取所需资源,被无限推迟。
状态 阻塞 (Blocked) 活跃 (Active) 但无效 等待 (Waiting) 但无望
资源消耗 无 CPU 消耗 (线程阻塞) 有 CPU 消耗 (线程忙循环) 可能有 CPU 消耗 (线程不断尝试)
解决办法 破坏四个必要条件 (按序加锁等) 引入随机性,调整优先级 使用公平锁,避免高优先级独占,合理分配资源
生活比喻 独木桥相遇,互不相让 狭窄走廊互相让路,谁也过不去 餐厅里老实排队,但总被插队
posted @ 2026-01-21 16:00  我是刘瘦瘦  阅读(218)  评论(0)    收藏  举报