Java并发编程:线程活跃性问题:死锁、活锁与饥饿

开发 前端
活跃性问题意味着程序永远无法得到运行的最终结果。与之前提到的线程安全问题导致的程序错误相比,活跃性问题的后果可能更严重。例如,若发生死锁,程序会完全卡死无法运行。

活跃性问题意味着程序永远无法得到运行的最终结果。与之前提到的线程安全问题导致的程序错误相比,活跃性问题的后果可能更严重。例如,若发生死锁,程序会完全卡死无法运行。

最典型的三种活跃性问题是死锁(Deadlock)、活锁(Livelock)和饥饿(Starvation)。下面逐一介绍。

1. 死锁(Deadlock)

最常见的活跃性问题是死锁。当两个线程互相等待对方持有的资源,且都不释放自己已持有的资源时,就会导致永久阻塞。

代码示例:

public class DeadLock {

    static Object lock1 = new Object();
    static Object lock2 = new Object();

    public static void main(String[] args) {

        new Thread(() -> {
            try {
                synchronized (lock1) {
                    Thread.sleep(500);
                    synchronized (lock2) {
                        System.out.println("Thread 1 成功执行");
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                synchronized (lock2) {
                    Thread.sleep(500);
                    synchronized (lock1) {
                        System.out.println("Thread 2 成功执行");
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

输出结果:

Acquired lock1, trying to acquire lock2.  
Acquired lock2, trying to acquire lock1.

启动程序后会发现,程序一直在运行,但永远无法输出线程 1 和线程 2 的执行结果,说明两者都被卡住了。如果不强制终止进程,它们将永远等待。

注意:后续章节会详细讲解synchronized关键字,目前只需知道它能确保同一时刻最多一个线程执行代码(需持有对应锁),以控制并发安全。

死锁的必要条件

根据上述示例,可以分析死锁发生的四个必要条件:

  • 互斥条件:资源一次只能被一个进程或线程使用。例如,锁被某个线程持有后,其他线程无法获取,直到释放。
  • 请求与保持条件:线程在持有第一个锁的同时请求第二个锁。例如,线程 1 持有锁 A 后尝试获取锁 B,且不释放锁 A。
  • 不可剥夺条件:锁不会被外部强制剥夺。即没有外界干预来终止死锁。
  • 循环等待条件:多个线程形成环形等待链。例如,线程 A 等线程 B 释放资源,线程 B 等线程 A 释放资源;或多个线程形成 A→B→C→A 的循环等待链。

⚠️以上四个条件缺一不可!只要破坏任意一个条件,即可避免死锁!

如何预防死锁

如果线程一次只能获取一个锁,则不会发生死锁。虽然不太实用,但这是最彻底的解决方案。

以下是两种常用预防方法:

  • 按固定顺序获取锁
    如果必须获取多个锁,设计时需要确保所有线程按相同顺序获取锁。例如修改上述代码:
// 线程 1 和线程 2 均按 lock1 → lock2 顺序获取
Thread1--> 获取lock1--> 获取lock2--> 执行成功;
Thread2--> 获取lock1--> 获取lock2--> 执行成功;
  • 超时放弃
    使用synchronized内置锁时,线程会无限等待。而Lock接口的tryLock(long time, TimeUnit unit)方法允许设置等待时间。若超时未获锁,线程可主动释放已持有的锁,从而避免死锁。

2. 活锁(Livelock)

什么是活锁

活锁是第二种活跃性问题。与死锁类似,程序无法得到最终结果,但线程并非完全阻塞,而是不断尝试执行却无法推进。

例如:两人迎面相遇,互相让路,结果你往右我往左,再次相撞,最终谁也无法通过。

代码示例:

public class Livelock {

    private Lock lock1 = new ReentrantLock(true);
    private Lock lock2 = new ReentrantLock(true);

    public static void main(String[] args) {
        Livelock livelock = new Livelock();
        new Thread(livelock::operation1, "T1").start();
        new Thread(livelock::operation2, "T2").start();
    }

    public void operation1() {
        while (true) {
            lock1.tryLock();
            System.out.println("获取 lock1,尝试获取 lock2");
            sleep(50);  // 模拟业务耗时

            if (lock2.tryLock()) {
                System.out.println("获取 lock2");
            } else {
                System.out.println("无法获取 lock2,释放 lock1");
                lock1.unlock();
                continue;
            }

            System.out.println("执行 operation1");
            break;
        }
        lock2.unlock();
        lock1.unlock();
    }

    public void operation2() {
        while (true) {
            lock2.tryLock();
            System.out.println("获取 lock2,尝试获取 lock1");
            sleep(50);

            if (lock1.tryLock()) {
                System.out.println("获取 lock1");
            } else {
                System.out.println("无法获取 lock1,释放 lock2");
                lock2.unlock();
                continue;
            }

            System.out.println("执行 operation2");
            break;
        }
        lock1.unlock();
        lock2.unlock();
    }

    private void sleep(long sleepTime) {
        try {
            Thread.sleep(sleepTime);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果:

获取lock1,尝试获取lock2  
获取lock2,尝试获取lock1  
无法获取lock2,释放lock1  
获取lock2,尝试获取lock1  
无法获取lock1,释放lock2  
...(循环)

从日志可见,两个线程不断获取和释放锁,但都无法完成操作。

注意:由于线程调度,此示例可能在运行一段时间后自动解除活锁,但不影响理解其原理。

如何预防活锁

活锁的根源在于线程同时释放锁并重试。解决方法是为锁获取设置随机等待时间,打破同步释放的节奏:

修改代码:

// 在 sleep 方法中增加随机等待时间
private void sleep(long sleepTime) {
    try {
        Thread.sleep(sleepTime + (long)(Math.random() * 100));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

修改后运行结果:

获取lock1,尝试获取lock2  
获取lock2,尝试获取lock1  
无法获取lock1,释放lock2  
获取lock2  
执行operation1  
获取lock2,尝试获取lock1  
获取lock1  
执行operation2

此时活锁问题基本消失。

典型场景:消息队列中某个错误消息反复重试,导致线程忙但无结果。解决方法:

  1. 将错误消息移至队列尾部延迟处理;
  2. 限制重试次数,超过后丢弃或特殊处理。

3. 饥饿(Starvation)

什么是饥饿

饥饿指线程长期无法获取资源(如 CPU 时间),导致无法运行。常见场景:

  • 线程优先级过低,长期得不到调度;
  • 某线程持有锁且不释放(如无限循环),其他线程长期等待。

饥饿的影响

导致程序响应性差。例如,浏览器前端线程因后台线程占用 CPU 无法响应操作。

如何预防饥饿

  • 确保逻辑正确,及时释放锁;
  • 合理设置线程优先级(或不设置优先级)。
责任编辑:武晓燕 来源: 程序猿技术充电站
相关推荐

2023-06-29 08:18:27

Java显示锁显示条件队列

2023-10-08 09:34:11

Java编程

2021-03-26 10:40:16

MySQL锁等待死锁

2024-09-27 09:31:25

2024-02-26 08:33:51

并发编程活跃性安全性

2011-12-29 13:31:15

Java

2025-02-19 00:05:18

Java并发编程

2025-02-17 00:00:25

Java并发编程

2023-07-05 08:18:54

Atomic类乐观锁悲观锁

2009-06-17 11:23:00

Java多线程

2018-10-25 15:55:44

Java多线程锁优化

2019-04-12 15:14:44

Python线程

2020-07-06 08:03:32

Java悲观锁乐观锁

2022-07-10 20:49:57

javaVolatile线程

2010-05-24 14:04:48

JavaSwing多线程

2023-08-25 09:36:43

Java编程

2025-01-10 07:10:00

2025-02-06 03:14:38

2017-05-03 16:26:24

MySQL并发死锁

2024-04-02 11:22:01

死锁Java并发
点赞
收藏

51CTO技术栈公众号