Java并发编程中的饥饿与活锁问题解决
大家好,今天我们来深入探讨Java并发编程中两个比较隐蔽但又可能严重影响系统性能和稳定性的问题:饥饿(Starvation)和活锁(Livelock)。理解这些问题,并掌握相应的解决方案,对于编写健壮、高效的并发程序至关重要。
1. 饥饿(Starvation)
1.1 什么是饥饿?
饥饿指的是线程因无法获得所需的资源(例如CPU时间、锁)而长时间阻塞,导致无法执行任务的情况。 虽然线程仍然存活,但它实际上被“饿死”了,无法取得任何进展。
导致饥饿的常见原因:
- 优先级反转: 低优先级线程持有高优先级线程所需的锁,导致高优先级线程长时间等待。
- 不公平的锁: 某些锁(例如
synchronized
)是非公平的,可能导致某些线程总是无法获得锁。 - 无限循环/死循环: 某个线程进入无限循环或死循环,占用大量CPU资源,导致其他线程无法获得足够的CPU时间。
- 资源分配不均: 系统资源分配策略不公平,某些线程总是被优先分配资源。
1.2 饥饿的例子
考虑以下使用synchronized
锁的例子:
public class StarvationExample {
private final Object lock = new Object();
public void executeTask(String threadName) {
synchronized (lock) {
System.out.println(threadName + " acquired the lock.");
try {
// 模拟耗时操作
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(threadName + " finished executing.");
}
}
public static void main(String[] args) throws InterruptedException {
StarvationExample example = new StarvationExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
example.executeTask("Thread-1");
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
example.executeTask("Thread-2");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("All threads finished.");
}
}
在这个例子中,两个线程都试图获得lock
对象的锁。由于synchronized
是非公平锁,可能导致某个线程总是能够先获得锁,而另一个线程则需要等待更长时间。虽然两个线程最终都能完成任务,但某个线程可能会经历更长时间的等待,从而表现出饥饿的现象。
1.3 解决饥饿的方法
解决饥饿问题通常需要从以下几个方面入手:
- 使用公平锁: 使用
ReentrantLock
并将其设置为公平模式可以有效避免因锁竞争导致的饥饿。 - 避免优先级反转: 尽量避免使用线程优先级,或者使用优先级继承等技术来解决优先级反转问题。
- 资源监控和调整: 监控系统资源的使用情况,并根据实际情况调整资源分配策略,确保所有线程都能获得足够的资源。
- 避免长时间持有锁: 尽量缩短持有锁的时间,减少其他线程的等待时间。
- 合理设计并发策略: 重新审视并发策略,考虑是否可以使用其他并发工具或算法来避免锁竞争,例如使用无锁数据结构或CAS操作。
1.4 使用公平锁解决饥饿
import java.util.concurrent.locks.ReentrantLock;
public class FairLockExample {
private final ReentrantLock lock = new ReentrantLock(true); // 设置为公平锁
public void executeTask(String threadName) {
lock.lock();
try {
System.out.println(threadName + " acquired the lock.");
try {
// 模拟耗时操作
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(threadName + " finished executing.");
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
FairLockExample example = new FairLockExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
example.executeTask("Thread-1");
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
example.executeTask("Thread-2");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("All threads finished.");
}
}
在这个例子中,我们将ReentrantLock
设置为公平模式。这意味着等待时间最长的线程将优先获得锁。 虽然公平锁可能会降低一定的吞吐量,但可以有效避免饥饿现象。
公平锁和非公平锁的对比:
特性 | 非公平锁(例如synchronized ) |
公平锁(例如ReentrantLock(true) ) |
---|---|---|
效率 | 通常更高 | 通常较低 |
公平性 | 不保证公平 | 保证等待时间最长的线程优先获得锁 |
适用场景 | 竞争不激烈,追求高吞吐量 | 竞争激烈,需要保证公平性 |
2. 活锁(Livelock)
2.1 什么是活锁?
活锁是指多个线程为了避免死锁,不断尝试获取资源,但由于某种原因总是失败,导致线程一直处于活跃状态,但没有任何进展的情况。 与死锁不同,活锁中的线程并没有阻塞,而是不断地在尝试,但始终无法成功。
活锁的形成原因:
活锁通常是由于线程在检测到冲突时,采取了退避重试的策略,但退避策略不当,导致线程不断地在重试中循环。
活锁与死锁的区别:
特性 | 死锁(Deadlock) | 活锁(Livelock) |
---|---|---|
线程状态 | 线程阻塞,相互等待对方释放资源 | 线程活跃,不断尝试获取资源,但总是失败 |
资源状态 | 线程持有的资源无法释放 | 线程持有的资源会被释放,但又立即被抢占 |
进展 | 系统完全停止进展 | 系统持续运行,但没有任何进展 |
2.2 活锁的例子
考虑以下转账的例子,两个线程试图将资金从一个账户转移到另一个账户:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LivelockExample {
private final Account account1 = new Account(1000);
private final Account account2 = new Account(1000);
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void transfer(Account fromAccount, Account toAccount, Lock fromLock, Lock toLock, int amount) {
while (true) {
try {
if (fromLock.tryLock()) {
try {
if (toLock.tryLock()) {
try {
if (fromAccount.getBalance() < amount) {
System.out.println("Insufficient balance. Cannot transfer.");
return;
}
fromAccount.debit(amount);
toAccount.credit(amount);
System.out.println(Thread.currentThread().getName() + " transferred $" + amount + " from " + fromAccount.getAccountId() + " to " + toAccount.getAccountId());
return;
} finally {
toLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " failed to acquire toLock. Releasing fromLock.");
}
} finally {
fromLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " failed to acquire fromLock. Retrying.");
}
// 避免CPU空转,稍微等待一段时间
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
public static void main(String[] args) throws InterruptedException {
LivelockExample example = new LivelockExample();
Thread t1 = new Thread(() -> example.transfer(example.account1, example.account2, example.lock1, example.lock2, 100), "Thread-1");
Thread t2 = new Thread(() -> example.transfer(example.account2, example.account1, example.lock2, example.lock1, 100), "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Account 1 balance: " + example.account1.getBalance());
System.out.println("Account 2 balance: " + example.account2.getBalance());
}
}
class Account {
private int balance;
private final int accountId;
public Account(int accountId) {
this.accountId = accountId;
}
public Account(int balance, int accountId) {
this.balance = balance;
this.accountId = accountId;
}
public int getBalance() {
return balance;
}
public int getAccountId() {
return accountId;
}
public void debit(int amount) {
balance -= amount;
}
public void credit(int amount) {
balance += amount;
}
}
在这个例子中,Thread-1
试图将资金从account1
转移到account2
,而Thread-2
试图将资金从account2
转移到account1
。 每个线程都需要同时获得两个锁才能完成转账操作。 如果两个线程同时尝试获取锁,并且都成功获得了第一个锁,但无法获得第二个锁,它们会释放已经获得的锁,并重新开始尝试。 这种情况下,两个线程会不断地释放和重新获取锁,但始终无法完成转账操作,从而形成活锁。
2.3 解决活锁的方法
解决活锁问题的关键是引入随机性,避免线程总是以相同的顺序尝试获取资源。
- 随机退避: 在重试之前,引入随机的退避时间,避免线程总是同时尝试获取资源。
- 优先级调整: 如果活锁是由于某些线程总是被其他线程抢占资源导致的,可以尝试调整线程的优先级,或者使用其他调度策略。
- 引入全局协调者: 引入一个全局协调者来控制线程的执行顺序,避免线程之间的冲突。
2.4 使用随机退避解决活锁
import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LivelockSolutionExample {
private final Account account1 = new Account(1000, 1);
private final Account account2 = new Account(1000, 2);
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
private final Random random = new Random();
public void transfer(Account fromAccount, Account toAccount, Lock fromLock, Lock toLock, int amount) {
while (true) {
try {
if (fromLock.tryLock()) {
try {
if (toLock.tryLock()) {
try {
if (fromAccount.getBalance() < amount) {
System.out.println("Insufficient balance. Cannot transfer.");
return;
}
fromAccount.debit(amount);
toAccount.credit(amount);
System.out.println(Thread.currentThread().getName() + " transferred $" + amount + " from " + fromAccount.getAccountId() + " to " + toAccount.getAccountId());
return;
} finally {
toLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " failed to acquire toLock. Releasing fromLock.");
}
} finally {
fromLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " failed to acquire fromLock. Retrying.");
}
// 引入随机退避时间
Thread.sleep(random.nextInt(100));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
public static void main(String[] args) throws InterruptedException {
LivelockSolutionExample example = new LivelockSolutionExample();
Thread t1 = new Thread(() -> example.transfer(example.account1, example.account2, example.lock1, example.lock2, 100), "Thread-1");
Thread t2 = new Thread(() -> example.transfer(example.account2, example.account1, example.lock2, example.lock1, 100), "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Account 1 balance: " + example.account1.getBalance());
System.out.println("Account 2 balance: " + example.account2.getBalance());
}
}
在这个例子中,我们在每次重试之前引入了一个随机的退避时间。 这样可以避免两个线程总是同时尝试获取锁,从而降低活锁发生的概率。
3. 总结
理解饥饿和活锁的概念,并掌握相应的解决方法,是编写健壮、高效的并发程序的关键。 针对饥饿问题,可以考虑使用公平锁、避免优先级反转等方法; 针对活锁问题,可以考虑引入随机退避等方法。 选择合适的并发策略,并进行充分的测试,才能保证系统的稳定性和性能。