Java并发编程:避免锁饥饿与公平性策略
大家好,今天我们来聊聊Java并发编程中一个比较棘手的问题:锁饥饿(Starvation),以及如何通过公平性策略来缓解或避免它。锁饥饿是指线程因无法获得其需要的锁而持续阻塞的情况。这会导致线程无法执行其任务,严重影响程序的性能和响应速度。
一、锁饥饿的成因与危害
锁饥饿的根本原因在于某些线程总是能够优先获得锁,导致其他线程长时间甚至永远无法获得锁。这种现象通常发生在以下几种场景:
-
非公平锁的竞争激烈: Java提供的默认锁(
synchronized和ReentrantLock默认构造函数)是非公平锁。非公平锁允许线程“插队”,即即使有等待队列,线程也可能在释放锁后立即再次获得锁,从而导致等待队列中的线程长时间无法获得锁。 -
线程优先级差异: 优先级较高的线程可能更容易获得锁,导致优先级较低的线程饥饿。虽然Java允许设置线程优先级,但依赖线程优先级来解决并发问题通常是不靠谱的,因为它受到操作系统调度策略的影响,不同平台表现不一致。
-
长时间持有锁: 如果一个线程长时间持有锁不释放,其他需要该锁的线程就会一直阻塞等待,增加了饥饿的风险。
锁饥饿的危害是显而易见的:
- 性能下降: 无法获得锁的线程会一直阻塞,浪费CPU资源。
- 响应迟缓: 线程长时间等待锁会导致其任务无法及时完成,影响程序的响应速度。
- 死锁风险: 在某些情况下,锁饥饿可能导致死锁。例如,线程A持有锁1,等待锁2;线程B持有锁2,由于线程A一直持有锁1,线程B无法获得锁1,导致两者相互等待,形成死锁。
二、非公平锁的饥饿演示
我们通过一个简单的例子来演示非公平锁可能导致的饥饿现象。
import java.util.concurrent.locks.ReentrantLock;
public class NonFairLockStarvation {
private static final ReentrantLock lock = new ReentrantLock(false); // 非公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取锁");
//模拟临界区操作
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}, "Thread-" + i).start();
}
}
}
在这个例子中,我们创建了5个线程,每个线程尝试获取锁100次。由于使用的是非公平锁,线程有可能在释放锁后立即再次获得锁,导致其他线程长时间无法获得锁。运行结果可能会出现某个线程连续多次获得锁,而其他线程等待时间过长的情况。
三、公平锁:缓解饥饿的策略
为了缓解锁饥饿,Java提供了公平锁。公平锁保证线程按照请求锁的顺序获得锁,即先到先得。ReentrantLock可以通过构造函数指定为公平锁:
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
我们将上面的例子修改为使用公平锁:
import java.util.concurrent.locks.ReentrantLock;
public class FairLockDemo {
private static final ReentrantLock lock = new ReentrantLock(true); // 公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取锁");
//模拟临界区操作
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}, "Thread-" + i).start();
}
}
}
使用公平锁后,线程获得锁的顺序会更加公平,每个线程都有机会获得锁,从而缓解了饥饿现象。运行结果会更均匀,每个线程获取锁的次数接近。
四、公平锁的代价
虽然公平锁可以缓解饥饿,但它也带来了性能上的代价。
- 上下文切换增加: 公平锁需要维护等待队列,每次释放锁时都需要从队列中选择下一个线程来唤醒,这增加了上下文切换的次数。
- 吞吐量降低: 非公平锁允许线程“插队”,可以减少上下文切换,提高吞吐量。公平锁则必须按照顺序来,限制了这种优化。
因此,选择使用公平锁还是非公平锁需要权衡饥饿风险和性能需求。如果饥饿是一个严重的问题,或者对公平性有较高要求,那么应该选择公平锁。如果性能是首要考虑因素,并且饥饿风险较低,那么可以选择非公平锁。
五、其他避免锁饥饿的策略
除了使用公平锁,还可以通过以下策略来避免锁饥饿:
-
减少锁的持有时间: 尽量缩短线程持有锁的时间,减少其他线程的等待时间。可以将临界区内的代码进行优化,减少不必要的计算和IO操作。
-
避免长时间阻塞的操作: 在临界区内避免进行长时间的阻塞操作,如IO操作、网络请求等。如果必须进行,可以考虑使用异步IO或者将操作移到临界区外。
-
使用读写锁分离读写操作: 如果读操作远多于写操作,可以使用读写锁
ReentrantReadWriteLock来提高并发性能。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作,从而减少了写操作的阻塞时间。 -
使用无锁数据结构: 某些情况下,可以使用无锁数据结构(如
ConcurrentHashMap、AtomicInteger)来代替锁,从而避免锁竞争和饥饿。无锁数据结构通常使用CAS(Compare and Swap)操作来实现线程安全。 -
合理设置线程优先级: 虽然不推荐依赖线程优先级来解决并发问题,但在某些特定情况下,可以适当调整线程优先级,以缓解饥饿现象。但需要注意,线程优先级受到操作系统调度策略的影响,不同平台表现不一致,因此需要谨慎使用。
六、代码示例:读写锁的应用
假设有一个共享的缓存,多个线程可以读取缓存,但只有一个线程可以更新缓存。我们可以使用读写锁来实现这个缓存的并发控制。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
private final Map<String, Object> cache = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public Object get(String key) {
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 读取缓存 key: " + key);
return cache.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, Object value) {
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 写入缓存 key: " + key + ", value: " + value);
cache.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) {
Cache cache = new Cache();
// 多个线程读取缓存
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 5; j++) {
cache.get("key1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Reader-" + i).start();
}
// 一个线程写入缓存
new Thread(() -> {
for (int i = 0; i < 3; i++) {
cache.put("key1", "value" + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Writer").start();
}
}
在这个例子中,多个线程可以同时读取缓存,但只有一个线程可以写入缓存。读写锁保证了读操作的并发性,同时避免了写操作的冲突,提高了程序的性能。
七、表格总结:锁饥饿的解决方案
| 解决方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 使用公平锁 | 保证线程按照请求顺序获得锁,缓解饥饿风险 | 上下文切换增加,吞吐量降低 | 饥饿是一个严重的问题,或者对公平性有较高要求 |
| 减少锁的持有时间 | 减少其他线程的等待时间 | 需要优化临界区代码,可能增加开发成本 | 临界区代码可以优化,减少不必要的计算和IO操作 |
| 避免长时间阻塞的操作 | 减少线程阻塞时间 | 需要使用异步IO或者将操作移到临界区外,增加复杂性 | 临界区内有长时间阻塞的操作,如IO操作、网络请求等 |
| 使用读写锁 | 提高读操作的并发性 | 只能用于读多写少的场景 | 读操作远多于写操作的场景 |
| 使用无锁数据结构 | 避免锁竞争和饥饿 | 实现复杂,需要保证原子性 | 可以使用无锁数据结构代替锁的场景,如计数器、缓存等 |
| 合理设置线程优先级 | 缓解饥饿现象 | 依赖操作系统调度策略,不同平台表现不一致 | 某些特定情况下,可以适当调整线程优先级,但需要谨慎使用,并充分测试不同平台上的表现 |
八、总结: 如何选择适当的策略?
锁饥饿是一个需要认真对待的问题,但解决它并没有一劳永逸的方案。我们需要根据具体的应用场景和性能需求,选择合适的策略。公平锁可以缓解饥饿,但会降低性能;减少锁的持有时间可以提高并发性,但需要优化代码;读写锁适用于读多写少的场景;无锁数据结构可以避免锁竞争,但实现复杂。最终,我们需要在公平性、性能和复杂性之间找到一个平衡点,才能有效地解决锁饥饿问题。