
本文深入探讨了java并发编程中,多线程竞争`reentrantlock`时可能引发的线程饥饿问题。我们将分析通过线程休眠(固定时间或随机时间)来缓解饥饿的策略,并讨论其适用场景及局限性。在此基础上,文章将引入更高效、更公平的解决方案——利用`reentrantlock`的`condition`机制进行线程协作,从而有效避免资源饥饿,提升并发应用的健壮性和响应性。
在Java并发编程中,当多个线程需要访问共享资源时,通常会使用锁机制来保证数据一致性。ReentrantLock是一个常用的选择,它提供了比synchronized关键字更灵活的锁控制。然而,在某些场景下,尤其是一个线程长时间持有锁或以高频率尝试获取锁时,可能会导致其他线程长时间无法获取锁,从而产生线程饥饿(Thread Starvation)问题。
理解线程饥饿问题
考虑一个典型场景:一个无限循环的线程持续执行任务并获取、释放一个ReentrantLock。同时,另有一个或多个调度任务线程(或通过其他方式触发的线程)也需要获取同一个锁来执行其任务。如果无限循环线程执行得过于频繁且不给其他线程留出足够的时间,其他线程可能会因为无法及时获取锁而长时间等待,甚至永远无法执行,这就是线程饥饿。
即使其他线程使用带超时的tryLock(timeout, TimeUnit)方法尝试获取锁,如果锁被持续占用,它们也可能反复超时失败。
缓解饥饿的初步尝试:线程休眠
为了缓解上述饥饿问题,一种直观的解决方案是在无限循环线程释放锁后,让其短暂休眠一段时间。这为其他等待获取锁的线程提供了一个机会窗口。
立即学习“Java免费学习笔记(深入)”;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockContentionExample {
private final Lock writeLock = new ReentrantLock(true); // 使用公平锁,有助于缓解饥饿
public void infiniteLoopTask() {
while (true) {
try {
// 尝试获取锁,设置超时时间以避免无限等待
if (writeLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
doSomething(); // 执行核心业务逻辑
} finally {
writeLock.unlock(); // 确保锁在任何情况下都被释放
}
} else {
// 如果在超时时间内未能获取锁,可以做一些其他事情或直接继续循环
System.out.println(Thread.currentThread().getName() + " - 未能获取锁,重试...");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().getName() + " - 任务中断。");
break;
}
// 释放锁后短暂休眠,给其他线程获取锁的机会
ThreadUtil.sleep(10); // 假设 ThreadUtil.sleep 是一个简单的休眠工具方法
}
}
public void scheduledTask() {
try {
if (writeLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
doScheduledWork();
} finally {
writeLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " - 调度任务未能获取锁。");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().getName() + " - 调度任务中断。");
}
}
private void doSomething() {
System.out.println(Thread.currentThread().getName() + " - 正在执行核心任务...");
// 模拟耗时操作
try {
Thread.sleep(5);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void doScheduledWork() {
System.out.println(Thread.currentThread().getName() + " - 正在执行调度任务...");
// 模拟耗时操作
try {
Thread.sleep(5);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 假设的 ThreadUtil 工具类
static class ThreadUtil {
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
LockContentionExample example = new LockContentionExample();
new Thread(example::infiniteLoopTask, "InfiniteLoopThread").start();
new Thread(example::scheduledTask, "ScheduledTaskThread-1").start();
new Thread(example::scheduledTask, "ScheduledTaskThread-2").start();
}
}常量休眠与随机休眠
在上述例子中,我们使用了固定时间(如10毫秒)的休眠。有人可能会提出使用随机休眠时间,例如 ThreadUtil.sleep(RandomUtil.nextInt(5, 100))。那么,这两种方式有何区别和优劣呢?
-
常量休眠 (ThreadUtil.sleep(10)):
- 优点:简单、可预测。对于只有两个线程(一个无限循环线程和一个竞争线程)的场景,固定休眠通常足以确保竞争线程有机会获取锁。
- 缺点:在存在三个或更多竞争线程的复杂场景中,如果锁的释放和某个特定线程的尝试获取形成了固定的、不利的循环模式,即使有休眠,该线程仍然可能持续饥饿。例如,线程A释放锁,线程B总是先于线程C尝试获取并成功,C就可能一直得不到锁。
-
随机休眠 (ThreadUtil.sleep(RandomUtil.nextInt(5, 100))):
- 优点:引入随机性可以打破潜在的固定竞争模式。在多个线程竞争锁的复杂环境中,随机休眠增加了每个竞争线程在某个时刻成功获取锁的概率,从而更好地避免了特定线程的长期饥饿。
- 缺点:引入了不确定性,可能导致整体性能略有波动。
总结:对于只有两个竞争线程的简单情况,常量休眠通常足够。但如果存在多个竞争线程,随机休眠可以更有效地防止某些线程因固定调度模式而陷入饥饿。
不休眠的后果
如果无限循环线程在释放锁后完全不休眠,其他线程是否仍会最终被执行?答案是:理论上会,但实际上可能会面临长时间的、不确定的延迟,这等同于饥饿。 JVM的线程调度器会尝试公平地分配CPU时间,但如果一个线程频繁且快速地获取、释放锁,并且在锁可用时总能第一时间再次获取,那么其他线程可能需要等待很长时间才能轮到它们获取锁,或者在它们的tryLock超时时间内锁从未被释放。
更优的解决方案:使用 Condition 对象进行线程协作
简单地通过休眠来“让出”CPU时间是一种启发式方法,但它不够精确,且可能导致性能浪费(如果休眠期间锁是空闲的,但没有线程被唤醒去获取)。更专业、更高效的解决方案是利用ReentrantLock提供的Condition对象进行线程间的精确协作。
Condition对象允许线程在特定条件下等待(await()),并在条件满足时被其他线程唤醒(signal()或signalAll())。这比盲目休眠或依赖调度器更加高效和可控。
工作原理:
- lock.newCondition(): 通过ReentrantLock实例创建Condition对象。
- condition.await(): 当一个线程发现条件不满足时,它会调用await()方法。这会导致线程释放它持有的锁,并进入等待状态,直到被signal()或signalAll()唤醒。
- condition.signal() / condition.signalAll(): 当另一个线程改变了条件(例如,释放了资源或完成了某个任务),它会调用signal()(唤醒一个等待线程)或signalAll()(唤醒所有等待线程)来通知等待的线程。被唤醒的线程会重新尝试获取锁并检查条件。
示例代码(概念性):
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class CoordinatedLockAccess {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean resourceAvailable = false; // 共享资源状态
// 生产/提供资源线程
public void producerTask() {
lock.lock();
try {
// 模拟生产资源
System.out.println(Thread.currentThread().getName() + " - 正在生产资源...");
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
resourceAvailable = true; // 资源已准备好
condition.signalAll(); // 通知所有等待的消费者线程
System.out.println(Thread.currentThread().getName() + " - 资源生产完毕,通知消费者。");
} finally {
lock.unlock();
}
}
// 消费/使用资源线程
public void consumerTask() {
lock.lock();
try {
while (!resourceAvailable) { // 循环检查条件,防止虚假唤醒
System.out.println(Thread.currentThread().getName() + " - 资源未就绪,等待...");
try {
condition.await(); // 释放锁并等待
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
// 资源已就绪,进行消费
System.out.println(Thread.currentThread().getName() + " - 资源已就绪,开始消费。");
// 模拟消费资源
try { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
resourceAvailable = false; // 消费后重置状态
System.out.println(Thread.currentThread().getName() + " - 资源消费完毕。");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
CoordinatedLockAccess coordinator = new CoordinatedLockAccess();
// 启动消费者线程
new Thread(coordinator::consumerTask, "Consumer-1").start();
new Thread(coordinator::consumerTask, "Consumer-2").start();
// 稍后启动生产者线程
try { Thread.sleep(100); } catch (InterruptedException e) { }
new Thread(coordinator::producerTask, "Producer").start();
}
}在这个Condition的例子中,消费者线程不会忙循环或盲目休眠,而是当条件不满足时,优雅地进入等待状态,释放锁,直到生产者线程明确地通知它们。这大大提高了资源利用率和线程调度的公平性,有效避免了饥饿。
注意事项与总结
- 公平锁(Fair Lock):ReentrantLock可以构造为公平锁 (new ReentrantLock(true))。公平锁会尽量按照请求的顺序授予锁,这本身就有助于缓解饥饿。然而,公平锁通常比非公平锁有更高的性能开销。在复杂的竞争场景下,即使是公平锁,也可能需要配合其他机制(如Condition)来达到最佳的饥饿避免效果。
- tryLock() 的作用:在上述示例中,tryLock(timeout, TimeUnit)是一个好习惯,它避免了线程无限期地阻塞在lock()方法上,允许线程在无法获取锁时执行其他逻辑或重试。
- finally 块的重要性:无论使用哪种锁机制,务必在finally块中释放锁 (unlock()),以防止因异常导致锁无法释放,从而引发死锁或更严重的饥饿问题。
-
选择合适的策略:
- 对于简单的、低并发的场景,且对性能要求不极致时,通过常量或随机休眠来缓解饥饿可能是一个快速简单的方案。
- 对于高并发、要求高响应性及公平性的复杂场景,或需要线程间精确协作的场景,ReentrantLock配合Condition对象是更推荐和专业的解决方案。它提供了更细粒度的控制,能更有效地管理线程状态,从而彻底避免饥饿。
综上所述,解决ReentrantLock竞争下的线程饥饿问题,需要根据具体场景权衡性能与公平性。从简单的线程休眠到复杂的Condition机制,每种方法都有其适用范围。理解它们的原理和局限性,有助于我们构建健壮、高效的并发应用程序。










