
本文探讨了多线程环境下,尤其是一个长时间运行的线程持有锁时,如何避免其他线程出现饥饿问题。通过分析线程休眠(固定时间与随机时间)的优缺点,以及更高级的`wait/notifyAll`机制(或`Condition`对象),文章旨在提供一套完整的解决方案,帮助开发者优化线程调度,确保共享资源的公平访问。
在并发编程中,多个线程竞争访问共享资源是常见场景。当一个线程长时间持有锁,而其他线程无法及时获取锁来执行其任务时,就会发生线程饥饿。这在存在无限循环或长时间执行任务的线程时尤为突出,可能导致系统响应迟缓或功能异常。本文将深入探讨如何通过线程休眠和更高级的同步机制来有效避免此类问题。
一、通过线程休眠缓解锁饥饿
为了确保持有锁的线程在完成其关键任务后能适时释放CPU资源,给其他等待线程提供获取锁的机会,一种直观的方法是在释放锁后让该线程短暂休眠。
1.1 基本的休眠策略
考虑一个线程在一个无限循环中执行代码,并且每次迭代都需要获取一个ReentrantLock。为了避免其他需要该锁的调度任务或手动触发任务被长时间阻塞,可以在释放锁后让该线程休眠一小段时间。
立即学习“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(); // 确保锁被释放
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Infinite loop thread interrupted.");
break;
} catch (Exception e) {
System.err.println("Error in infinite loop task: " + e.getMessage());
}
// 关键步骤:休眠一小段时间,让其他线程有机会获取锁
ThreadUtil.sleep(10); // 假设 ThreadUtil.sleep 是一个简单的休眠工具方法
}
}
private void doSomething() throws InterruptedException {
// 模拟实际业务逻辑
System.out.println(Thread.currentThread().getName() + " acquired lock and doing something.");
Thread.sleep(50); // 模拟业务执行时间
}
// 假设其他线程(如调度任务)也会调用此方法
public void scheduledTask() {
try {
if (writeLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName() + " acquired lock for scheduled task.");
// 执行调度任务
} finally {
writeLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " failed to acquire lock for scheduled task.");
}
} 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();
}
}
// 简单的 ThreadUtil 类,用于模拟休眠
class ThreadUtil {
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}在这种两线程(或少量线程)竞争的简单场景下,固定时间的休眠(如ThreadUtil.sleep(10))是有效的。它能确保无限循环线程在每次迭代后释放CPU,允许其他线程尝试获取锁。其他线程通过tryLock(timeout, TimeUnit)尝试获取锁,如果锁被占用,它们会等待一小段时间,如果仍未获取到,则会放弃并稍后重试。
1.2 随机休眠的益处
当竞争锁的线程数量增加(例如,三个或更多线程:一个无限循环线程A,两个竞争线程B和C),且它们的调度是可预测时,固定休眠时间可能会导致新的饥饿问题。例如,如果线程A释放锁后,线程B总是比线程C先尝试获取锁,并且每次都能成功,那么线程C将永远无法获取到锁。
为了打破这种可预测性并确保所有竞争线程都有机会获取锁,引入随机休眠时间变得有益。例如,ThreadUtil.sleep(RandomUtil.nextInt(5, 100))。通过让线程在不同的时间长度内休眠,可以增加线程A释放锁后,线程B和线程C竞争的随机性,从而降低特定线程持续饥饿的风险。
注意事项:
- 性能开销: 无论固定休眠还是随机休眠,都会引入一定的性能开销,因为线程在休眠期间不执行任何有效工作。对于对延迟敏感的系统,需要仔细权衡。
- tryLock与公平性: 使用ReentrantLock(true)可以创建一个公平锁,它会尝试按照请求的顺序授予锁,这在一定程度上可以缓解饥饿。然而,tryLock本身并不保证公平性,因为它只是尝试一次,不参与公平队列。
二、使用wait()和notifyAll()(或Condition)进行高级同步
虽然线程休眠可以作为一种简单的缓解策略,但它并非最优雅或最高效的解决方案。Java提供了更强大的同步机制——Object.wait()和Object.notifyAll(),或者与ReentrantLock配合使用的Condition对象,它们能提供更细粒度的控制和更高的效率。
2.1 wait()和notifyAll()机制
wait()和notifyAll()是基于对象监视器(monitor)的机制。当一个线程调用wait()时,它会释放当前持有的对象锁,并进入等待状态,直到被notify()或notifyAll()唤醒。
2.2 Condition对象与ReentrantLock
对于ReentrantLock,其等价的机制是Condition对象。Condition对象允许线程在特定条件不满足时等待,并在条件满足时被唤醒。它提供了await()(类似于wait())、signal()(类似于notify())和signalAll()(类似于notifyAll())方法。
这种机制的优势在于:
- 避免忙等待: 线程不是通过不断尝试获取锁(tryLock循环)或盲目休眠来等待,而是进入休眠状态,直到明确的条件满足时才被唤醒,从而节省CPU资源。
- JVM调度优化: JVM在管理wait/notify或await/signal的线程时,会比手动sleep提供更优的调度,有助于避免饥饿。
- 更清晰的逻辑: 同步逻辑更加明确,线程等待的是某个条件,而不是一个不确定的时间。
使用Condition的示例:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionSynchronizationExample {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean resourceReady = false; // 共享资源状态,表示资源是否可用
// 生产者线程(无限循环任务)
public void producerTask() {
while (true) {
lock.lock(); // 获取锁
try {
// 模拟生产资源
System.out.println(Thread.currentThread().getName() + " is producing resource...");
ThreadUtil.sleep(100); // 模拟生产时间
resourceReady = true; // 资源已准备好
condition.signalAll(); // 通知所有等待的消费者线程
} finally {
lock.unlock(); // 释放锁
}
ThreadUtil.sleep(50); // 生产者线程稍作休息,避免过于频繁地生产
}
}
// 消费者线程(调度任务或手动触发任务)
public void consumerTask(String taskName) {
lock.lock(); // 获取锁
try {
while (!resourceReady) { // 如果资源未准备好,则等待
System.out.println(taskName + " is waiting for resource...");
condition.await(); // 释放锁并等待,直到被signalAll唤醒
}
// 资源已准备好,消费资源
System.out.println(taskName + " consumed resource!");
resourceReady = false; // 重置状态,等待下一次生产
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(taskName + " interrupted while waiting.");
} finally {
lock.unlock(); // 释放锁
}
}
public static void main(String[] args) {
ConditionSynchronizationExample example = new ConditionSynchronizationExample();
new Thread(example::producerTask, "ProducerThread").start();
new Thread(() -> example.consumerTask("ConsumerTask-1"), "ConsumerThread-1").start();
new Thread(() -> example.consumerTask("ConsumerTask-2"), "ConsumerThread-2").start();
}
}在这个例子中,producerTask在完成其工作后,通过condition.signalAll()通知所有在condition.await()上等待的consumerTask。消费者线程在获取到锁并发现resourceReady为false时,会调用await(),从而释放锁并进入等待状态,而不是忙循环或休眠。当资源准备好时,它们会被唤醒并再次尝试获取锁。
三、总结与最佳实践
- 理解tryLock的局限性: 即使其他线程使用tryLock(timeout, TimeUnit),如果主循环线程不主动让出CPU或锁,它们仍然可能因超时而频繁失败,导致“事实上的饥饿”。
- 休眠作为简单缓解: 在线程数量较少且对性能要求不极致的场景下,固定时间休眠可以作为一种简单有效的饥饿缓解手段。对于多于两个竞争线程的情况,引入随机休眠可以增加公平性。
- 优先使用高级同步机制: 对于复杂的并发场景和对资源效率有更高要求的系统,wait/notifyAll(或Condition)是更优的选择。它们通过条件等待而非忙等待或盲目休眠,提高了CPU利用率和线程调度的公平性。
- 公平锁的考虑: 使用ReentrantLock(true)可以创建公平锁,这有助于按照请求顺序授予锁,进一步减少饥饿的可能性。然而,公平锁通常比非公平锁有更高的吞吐量开销。
- 避免裸sleep: 在处理锁竞争和线程调度时,应尽量避免仅依靠Thread.sleep()来解决问题,因为这通常会导致不精确的调度和潜在的饥饿问题。
选择哪种策略取决于具体的应用场景、性能要求以及并发的复杂性。通常,推荐使用Condition对象与ReentrantLock结合的方式,因为它提供了最强大、最灵活且最符合Java并发编程范式的解决方案。










