Java应用中的活锁(Livelock)与饥饿(Starvation)问题:产生原因与避免
大家好,今天我们来深入探讨Java并发编程中两个比较微妙但又可能导致系统性能问题的概念:活锁(Livelock)和饥饿(Starvation)。虽然它们不像死锁那样导致程序完全停滞,但也会严重影响程序的响应性和吞吐量。
一、活锁(Livelock)
活锁描述的是这样一种情况:多个线程为了避免死锁而不断地改变自己的状态,但最终没有一个线程能够取得进展。线程们都在“忙碌”地运行,但实际上都在做无用功,不断重复相同的操作,导致系统资源被浪费,程序性能下降。
1.1 活锁的产生原因
活锁通常发生在以下场景:
- 尝试避免死锁: 为了避免死锁,线程可能会在检测到资源冲突时释放资源,稍后再次尝试获取。
- 重试机制: 当操作失败时,线程会进行重试,但如果重试策略不当,可能导致多个线程同时重试,互相干扰。
- 相互谦让: 线程之间为了避免竞争,互相“谦让”,但这种谦让导致它们永远无法完成任务。
1.2 代码示例
让我们通过一个经典的例子来演示活锁:两个线程试图互相传递一个资源。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LivelockExample {
private final Lock lock1 = new ReentrantLock(true); // 公平锁
private final Lock lock2 = new ReentrantLock(true); // 公平锁
private Person person1;
private Person person2;
public LivelockExample(Person person1, Person person2) {
this.person1 = person1;
this.person2 = person2;
}
public static void main(String[] args) {
Person john = new Person("John");
Person jane = new Person("Jane");
LivelockExample livelock = new LivelockExample(john, jane);
new Thread(() -> {
try {
livelock.person1.tryTrade(livelock.person2, livelock.lock1, livelock.lock2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-John").start();
new Thread(() -> {
try {
livelock.person2.tryTrade(livelock.person1, livelock.lock2, livelock.lock1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-Jane").start();
}
static class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void tryTrade(Person other, Lock lock1, Lock lock2) throws InterruptedException {
while (true) {
if (lock1.tryLock()) {
try {
System.out.println(name + " acquired " + lock1.toString() + " , trying to acquire " + lock2.toString() );
if (lock2.tryLock()) {
try {
System.out.println(name + " : " + " traded with " + other.getName());
return;
} finally {
lock2.unlock();
}
} else {
System.out.println(name + " : " + other.getName() + " is also trying to trade. Releasing " + lock1.toString());
}
} finally {
lock1.unlock();
}
} else {
System.out.println(name + " : " + " is waiting for " + lock1.toString() + " to be released.");
}
// 互相谦让,让出CPU时间片,尝试避免死锁
Thread.sleep(10);
}
}
}
}
在这个例子中,Person 类代表一个人,每个人都试图与另一个人交易。 tryTrade 方法尝试获取两个锁 lock1 和 lock2。如果其中一个锁无法获取,它会释放已获取的锁并稍后重试。由于两个线程都在互相谦让,不断地释放和重试,它们可能永远无法成功交易,从而陷入活锁。 注意这里使用了公平锁,公平锁相对于非公平锁更容易发生活锁,因为线程会严格按照请求顺序获取锁。
1.3 避免活锁的方法
- 引入随机性: 在重试机制中引入随机的等待时间,避免多个线程同时重试。
- 优先级策略: 赋予某些线程更高的优先级,确保它们能够优先获取资源。
- 避免过度谦让: 线程在检测到资源冲突时,不应该立即释放资源,而是应该尝试等待一段时间。
- 使用锁的轮询机制,而不是忙等待: 让线程在等待锁的时候可以执行其他任务,而不是一直占用CPU资源进行重试。
修改上面的代码,加入随机等待时间:
import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LivelockExampleFixed {
private final Lock lock1 = new ReentrantLock(true); // 公平锁
private final Lock lock2 = new ReentrantLock(true); // 公平锁
private final Random random = new Random();
private Person person1;
private Person person2;
public LivelockExampleFixed(Person person1, Person person2) {
this.person1 = person1;
this.person2 = person2;
}
public static void main(String[] args) {
Person john = new Person("John");
Person jane = new Person("Jane");
LivelockExampleFixed livelock = new LivelockExampleFixed(john, jane);
new Thread(() -> {
try {
livelock.person1.tryTrade(livelock.person2, livelock.lock1, livelock.lock2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-John").start();
new Thread(() -> {
try {
livelock.person2.tryTrade(livelock.person1, livelock.lock2, livelock.lock1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-Jane").start();
}
static class Person {
private String name;
private final Random random = new Random();
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void tryTrade(Person other, Lock lock1, Lock lock2) throws InterruptedException {
while (true) {
if (lock1.tryLock()) {
try {
System.out.println(name + " acquired " + lock1.toString() + " , trying to acquire " + lock2.toString() );
if (lock2.tryLock()) {
try {
System.out.println(name + " : " + " traded with " + other.getName());
return;
} finally {
lock2.unlock();
}
} else {
System.out.println(name + " : " + other.getName() + " is also trying to trade. Releasing " + lock1.toString());
}
} finally {
lock1.unlock();
}
} else {
System.out.println(name + " : " + " is waiting for " + lock1.toString() + " to be released.");
}
// 互相谦让,让出CPU时间片,尝试避免死锁
Thread.sleep(random.nextInt(100)); // 随机等待时间
}
}
}
}
二、饥饿(Starvation)
饥饿是指一个或多个线程因为某种原因无法获得所需的资源,导致它们一直无法执行。与死锁不同,饥饿的线程并没有被阻塞,它们只是无法获得执行的机会。
2.1 饥饿的产生原因
- 优先级反转: 低优先级的线程持有一个高优先级线程需要的资源,导致高优先级线程一直等待。
- 不公平的资源分配: 资源分配策略偏向某些线程,导致其他线程长期无法获得资源。例如,非公平锁可能导致某些线程一直无法获取锁。
- 无限循环: 高优先级线程进入无限循环,导致低优先级线程无法获得CPU时间片。
2.2 代码示例
下面是一个使用非公平锁导致饥饿的例子:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class StarvationExample {
private static final Lock lock = new ReentrantLock(false); // 非公平锁
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
lock.lock();
try {
count++;
System.out.println(Thread.currentThread().getName() + " acquired the lock, count = " + count);
// 模拟耗时操作
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}, "Thread-" + i).start();
}
}
}
在这个例子中,多个线程竞争同一个非公平锁。由于非公平锁允许线程在锁释放后立即再次获取锁,因此某些线程可能一直能够获取锁,而其他线程则可能一直无法获取锁,从而导致饥饿。
2.3 避免饥饿的方法
- 使用公平锁: 公平锁按照线程请求锁的顺序分配锁,可以避免某些线程长期无法获取锁。
- 避免优先级反转: 使用优先级继承或优先级天花板协议来避免优先级反转。
- 限制高优先级线程的运行时间: 避免高优先级线程长时间占用CPU资源,让低优先级线程也有机会执行。
- 资源预分配: 在程序启动时,为每个线程预先分配一定的资源,避免线程在运行时竞争资源。
- 使用条件队列(Condition Queue):
Condition接口可以用来实现更细粒度的线程等待和通知机制,避免线程一直处于等待状态。
修改上面的代码,使用公平锁:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class StarvationExampleFixed {
private static final Lock lock = new ReentrantLock(true); // 公平锁
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
while (true) {
lock.lock();
try {
count++;
System.out.println(Thread.currentThread().getName() + " acquired the lock, count = " + count);
// 模拟耗时操作
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}, "Thread-" + i).start();
}
}
}
三、活锁与饥饿的对比
| 特性 | 活锁 (Livelock) | 饥饿 (Starvation) |
|---|---|---|
| 线程状态 | 线程持续运行,但无法取得进展 | 线程可以运行,但无法获得所需资源 |
| 资源占用 | 线程可能频繁释放和重新获取资源 | 线程可能长期等待资源 |
| 根本原因 | 线程为了避免死锁而进行的无效操作 | 资源分配不公平或优先级反转等 |
| 解决方案 | 引入随机性、避免过度谦让 | 使用公平锁、避免优先级反转、限制高优先级线程的运行时间 |
| 是否阻塞 | 不阻塞,线程持续尝试 | 不阻塞,线程等待资源 |
四、总结与建议
活锁和饥饿是并发编程中常见的性能问题,它们不像死锁那样容易被发现,但同样会对系统的稳定性和响应性产生负面影响。 理解它们产生的原因,并采取相应的措施,可以有效地避免这些问题。
- 活锁: 是线程不断尝试避让,但始终无法取得进展的情况。 引入随机性,避免过度谦让可以避免活锁。
- 饥饿: 是线程因资源分配不公,长期无法获得所需资源的情况。 使用公平锁,避免优先级反转可以避免饥饿。
希望今天的讲解对大家有所帮助,谢谢!