活跃性问题意味着程序永远无法得到运行的最终结果。与之前提到的线程安全问题导致的程序错误相比,活跃性问题的后果可能更严重。例如,若发生死锁,程序会完全卡死无法运行。
最典型的三种活跃性问题是死锁(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
此时活锁问题基本消失。
典型场景:消息队列中某个错误消息反复重试,导致线程忙但无结果。解决方法:
- 将错误消息移至队列尾部延迟处理;
- 限制重试次数,超过后丢弃或特殊处理。
3. 饥饿(Starvation)
什么是饥饿
饥饿指线程长期无法获取资源(如 CPU 时间),导致无法运行。常见场景:
- 线程优先级过低,长期得不到调度;
- 某线程持有锁且不释放(如无限循环),其他线程长期等待。
饥饿的影响
导致程序响应性差。例如,浏览器前端线程因后台线程占用 CPU 无法响应操作。
如何预防饥饿
- 确保逻辑正确,及时释放锁;
- 合理设置线程优先级(或不设置优先级)。