并发应用程序的“活跃度”指的是它及时执行并完成任务的能力。活跃性问题则是指程序无法最终得到预期的运行结果。相比于线程安全问题,存活性问题可能会导致更严重的后果。例如,死锁会使程序完全停滞,导致无法继续执行。
常见的活跃性问题包括以下三种:
1. 死锁(Deadlock)
死锁发生在多个线程相互等待对方释放资源,导致所有线程都无法继续执行。就像两个人在一条窄路上相遇,彼此都不肯让路,于是两个人都停在原地,无法前进。
假设有两个线程 A 和 B,分别需要资源 R1 和 R2:
- 线程 A:先锁定资源 R1,然后试图获取资源 R2。
- 线程 B:先锁定资源 R2,然后试图获取资源 R1。
如果线程 A 锁住 R1 后等待 R2,而线程 B 锁住 R2 后等待 R1,它们就会相互等待,谁也无法释放资源,导致程序卡死——这就是死锁。
2. 活锁(Livelock)
线程不断地改变状态以响应其他线程,但由于不断的状态变化,线程无法进入最终的完成状态。
活锁的情况就像两个人试图在狭窄的走道里互相避开,结果两个人都不断变换位置以避免碰撞,却始终无法通过。
假设有两个线程 A 和 B,它们都需要访问一个共享资源。每次线程 A 试图获取资源时,它发现线程 B 也在试图获取资源,于是 A 主动让步。而此时线程 B 也做出了相同的判断,选择让步。结果两个线程都一直在互相让步,但从未成功获取资源。这种不停地相互让步的行为就导致了活锁。
3. 饥饿(Starvation)
饥饿发生在一些线程长时间得不到资源来执行它们的任务,因为系统资源总是被其他线程优先占用。
假设有一个线程 T1 和多个高优先级线程 T2、T3、T4 等等。如果调度器总是优先安排高优先级的线程执行,低优先级的线程 T1 就很难获得 CPU 时间片来执行自己的任务。这样,T1 就会“饿死”,无法得到运行的机会。
死锁
最常见的活跃性问题是死锁。当两个线程相互等待对方释放资源,但同时都不释放自己已持有的资源时,就会发生死锁。这种情况会导致两个线程都无法继续执行,最终造成程序永久阻塞。
下面是一个死锁的代码示例:
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 successfully started execution");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
synchronized (lock2) {
Thread.sleep(500);
synchronized (lock1) {
System.out.println("Thread 2 successfully started execution");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
//输出:
Acquired lock1, trying to acquire lock2.
Acquired lok2, trying to acquire lock1.
一旦程序开始运行,你会发现它一直在执行,但线程1和线程2始终没有输出结果。这说明这两个线程都被卡住了,彼此相互等待对方释放资源。如果不强制结束进程,它们将持续等待。
代码中的synchronized关键字我们将在后续部分详细介绍。目前,你只需要了解它的作用是确保在任何时刻只有一个线程能够执行同步代码,并持有相应的锁,从而控制并发安全性。
死锁的必要条件
根据前面的例子,我们可以分析出死锁发生的必要条件。
- 互斥条件:某个资源在同一时刻只能被一个进程或线程使用。例如,如果一个线程获得了锁,其他线程必须等待,直到锁被释放。
- 请求和保持条件:一个线程在持有某个资源(如锁)的同时,继续请求其他资源。例如,线程1在持有锁A的同时请求锁B。
- 无干扰条件:资源的占用状态不会被外部干扰夺走。换句话说,没有外部机制强行结束或中断死锁状态。
- 循环等待条件:多个线程之间形成一个循环等待的状态。例如,线程A等待线程B释放资源,线程B等待线程C释放资源,线程C等待线程D释放资源,依此类推,直到线程F等待线程A释放资源,形成一个无休止的等待循环。
要产生死锁,以上四个条件缺一不可,因此你只需要阻止以上四个条件的任意一个发生,就不会发生死锁!!
如何防止死锁
如果一个线程每次只能获得一个锁,那么就不会发生死锁。虽然这种方法不太实际,但它确实可以从根本上避免死锁问题。
然而,对于更实际的解决方案,我们可以采取以下预防措施来避免死锁:
1. 按一定顺序获取锁
如果必须获取多个锁,则在设计时需要充分考虑不同线程获取锁的顺序。对于上面的例子,两个线程获取锁的时序如下:
Thread1 -----> Trying to acquire lock1 -----> Trying to acquire lock2 -----> Deadlock;
Thread2 -----> Trying to acquire lock2 -----> Trying to acquire lock1 -----> Deadlock;
其实这里只要保证两个线程获取锁的顺序一致就可以避免出现死锁了。
Thread1 -----> Trying to acquire lock1 -----> Trying to acquire lock2 -----> Enter execution;
Thread2 -----> Trying to acquire lock1 -----> Trying to acquire lock2 -----> Enter execution;
2. 超时后放弃
使用 synchronized 关键字提供的内置锁时,如果一个线程无法获取到锁,它会一直等待下去,这可能不是你想要的效果。你可能希望线程在等待一段时间后如果还无法获取锁,就放弃等待。
这种情况下,你可以使用 Lock 接口提供的 tryLock(long time, TimeUnit unit) 方法,它允许你设置一个指定的等待时间来获取锁。
tryLock 方法的使用方式如下:线程尝试在指定的时间内获取锁。如果在这个时间内成功获取到锁,它会返回 true,并且继续执行。如果在指定的时间内没有获取到锁,它会返回 false,线程可以选择放弃等待,从而避免了长期等待可能引发的死锁问题。
通过这种方式,线程可以在超时后主动释放之前获取的所有锁,从而有效地避免了死锁。
活锁
什么是活锁?
第二种活跃度问题是活锁。活锁与死锁非常相似,都是指程序永远得不到最终结果,但它们的表现形式有所不同。
活锁中的线程不会像在死锁中那样完全卡住,而是不断地尝试某些操作,但这些操作会导致它们无法继续前进。
比如,在路上我们相遇,都会礼貌地让路,结果你往右走,我往左走,又撞在一起,最后谁也过不去。情况是这样的:
为了演示活锁的情况,我们可以对之前的死锁示例代码进行一些修改。以下是修改后的逻辑:类似地,两个线程都需要两个锁才能完成工作。每个线程都获取了第一个锁,但发现第二个锁不可用。因此,为了让另一个线程先完成,每个线程都会释放第一个锁并重试。
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("Acquired lock1, trying to acquire lock2.");
sleep(50); //Simulate the time required for business operation
if (lock2.tryLock()) {
System.out.println("Acquired lock2.");
} else {
System.out.println("Unable to acquire lock2, releasing lock1.");
lock1.unlock();
continue;
}
System.out.println("Executing operation1.");
break;
}
lock2.unlock();
lock1.unlock();
}
public void operation2() {
while (true) {
lock2.tryLock();
System.out.println("Acquired lock2, trying to acquire lock1.");
sleep(50); //Simulate the time required for business operation
if (lock1.tryLock()) {
System.out.println("Acquired lock1.");
} else {
System.out.println("Unable to acquire lock1, releasing lock2.");
lock2.unlock();
continue;
}
System.out.println("Executing operation2.");
break;
}
lock1.unlock();
lock2.unlock();
}
private void sleep(long sleepTime) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//输出:
Acquired lock1, trying to acquire lock2.
Acquired lock2, trying to acquire lock1.
Unable to acquire lock2, releasing lock1.
Acquired lock1, trying to acquire lock2.
Unable to acquire lock1, releasing lock2.
Acquired lock2, trying to acquire lock1.
...
从输出日志中,我们可以观察到两个线程不断地获取和释放锁。这种行为导致两个线程都无法完成其操作,因为它们总是在重复地尝试获取锁,却始终未能成功。
需要注意的是,由于线程调度的不可预测性,两个线程获取和释放锁的操作并不总是同步进行的。因此,在某些情况下,这种活锁现象可能会在运行一段时间后自动解决。然而,这并不影响我们对活锁概念的理解。
另外,关于 ReentrantLock 的更多细节将在后续文章中详细讲解。目前,你只需要了解,它也是用来实现并发安全控制的工具。相比于 synchronized,ReentrantLock 提供了更高的灵活性。
如何防止活锁
程序中出现活锁的原因,其实就是因为两个线程同时获取锁、释放锁。
为了避免活锁,可以在尝试获取锁时引入随机等待时间。这样,线程释放锁的时机会有所不同,因为等待时间是不确定的。这种随机性使得当一个线程释放锁时,另一个线程可能仍在尝试获取锁,从而提高了成功获取锁的可能性,进而打破活锁。
我们来修改一下刚刚写的代码:
public class Livelock {
private final Lock lock1 = new ReentrantLock(true);
private final 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) {
if (lock1.tryLock()) {
System.out.println("T1: Acquired lock1, trying to acquire lock2.");
sleep(50); // Simulate the time required for business operation
if (lock2.tryLock()) {
System.out.println("T1: Acquired lock2.");
// Execute the operation
System.out.println("T1: Executing operation1.");
lock2.unlock();
lock1.unlock();
break;
} else {
System.out.println("T1: Unable to acquire lock2, releasing lock1.");
lock1.unlock();
// Random wait before retrying
sleep(ThreadLocalRandom.current().nextInt(10, 100));
}
}
}
}
public void operation2() {
while (true) {
if (lock2.tryLock()) {
System.out.println("T2: Acquired lock2, trying to acquire lock1.");
sleep(50); // Simulate the time required for business operation
if (lock1.tryLock()) {
System.out.println("T2: Acquired lock1.");
// Execute the operation
System.out.println("T2: Executing operation2.");
lock1.unlock();
lock2.unlock();
break;
} else {
System.out.println("T2: Unable to acquire lock1, releasing lock2.");
lock2.unlock();
// Random wait before retrying
sleep(ThreadLocalRandom.current().nextInt(10, 100));
}
}
}
}
private void sleep(long sleepTime) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
}
//输出:
T1: Acquired lock1, trying to acquire lock2.
T2: Acquired lock2, trying to acquire lock1.
T1: Unable to acquire lock2, releasing lock1.
T2: Unable to acquire lock1, releasing lock2.
T1: Acquired lock1, trying to acquire lock2.
T2: Acquired lock2, trying to acquire lock1.
T1: Unable to acquire lock2, releasing lock1.
T1: Acquired lock1, trying to acquire lock2.
T2: Unable to acquire lock1, releasing lock2.
T2: Acquired lock2, trying to acquire lock1.
T1: Unable to acquire lock2, releasing lock1.
T2: Acquired lock1.
T2: Executing operation2.
T1: Acquired lock1, trying to acquire lock2.
T1: Acquired lock2.
T1: Executing operation1.
修改后的代码中,在每次尝试获取锁失败时,使用 ThreadLocalRandom.current().nextInt(10, 100) 生成一个 10 到 100 毫秒之间的随机等待时间。线程会在释放锁后,随机等待一段时间,再次尝试获取锁。这种随机性打破了线程之间的争抢,减少了活锁发生的概率。
从输出结果可以看出,活锁的情况基本不会发生了。
活锁的一个典型例子就是在消息系统中。假设有一个消息队列,其中包含各种需要处理的消息。某一时刻,一个错误的消息被意外地加入了队列。每当这条消息被处理时,它都会报错,随后队列的重试机制会将其重新放回队列的前面,以便优先处理。
然而,这条消息无论经过多少次尝试,都无法被正确处理。每次出错后,它都会被放回队列的前端,等待再次处理。这样,处理线程不断地尝试处理这条无法成功的消息,导致线程始终忙碌而程序却永远无法取得正确结果,从而产生活锁问题。
有两种方法可以解决这个问题:
- 直接将错误信息放在队列末尾,延迟其执行;
- 设置重试次数限制。失败次数过多的消息将被丢弃或放入特殊队列以进行特殊处理。
3.饥饿
什么是饥饿?
饥饿是指线程始终无法获得某些资源,特别是 CPU 资源,从而导致线程无法运行的情况。这种问题通常发生在以下场景:
- 如果线程的优先级设置得太低,可能会导致该线程永远不会被分配CPU资源,导致没有机会运行。
- 如果一个线程持有锁,并进入无限循环而不释放锁,则会导致其他线程长时间等待;例如,某个程序始终占用某个文件的写锁,其他想要修改该文件的线程必须先获得该锁。这可能会导致想要修改该文件的线程陷入饥饿状态,很长时间无法运行。
饥饿会带来什么影响?
饥饿可能导致系统的响应性变差。例如,在浏览器中,一个线程负责处理前端响应(如打开收藏夹),而另一个后台线程负责下载图片、文件以及计算和渲染等任务。如果后台线程占用了所有的 CPU 资源,那么前端线程将无法正常执行。这会导致用户无法及时看到更新,从而严重影响用户体验。
如何预防饥饿
- 注意程序中的锁使用:确保在程序中使用的锁能够被正确释放。锁的持有时间过长或不释放锁的逻辑错误,都会导致其他线程长时间等待,从而引发饥饿问题。
- 设置合理的优先级,或者尽量不要为线程设置优先级,而是依赖系统的默认调度策略。