JAVA ReentrantLock 公平锁与非公平锁:性能实测与选择指南
各位来宾,大家好!今天我们来深入探讨 Java 并发编程中一个重要的工具:ReentrantLock。更具体地说,我们将聚焦于 ReentrantLock 的公平锁与非公平锁,通过实际测试来分析它们的性能差异,并提供在不同场景下选择的指导。
1. ReentrantLock 基础与公平性概念
ReentrantLock 是 java.util.concurrent.locks 包中的一个可重入互斥锁,它提供了比 synchronized 关键字更强大的功能。 它可以实现公平锁和非公平锁两种策略。
- 可重入性: 允许同一个线程多次获取同一个锁,而不会被阻塞。这对于递归调用等场景非常重要。
- 互斥性: 保证同一时刻只有一个线程持有锁,从而保护共享资源免受并发访问的破坏。
ReentrantLock 的公平性指的是锁的获取顺序。
- 公平锁: 按照线程请求锁的顺序来分配锁。如果一个线程已经等待了很长时间,它更有可能获得锁。这保证了所有线程都有机会获得锁,避免了饥饿现象。
- 非公平锁: 允许线程“插队”,即如果锁当前空闲,即使有其他线程在等待,当前请求的线程也可能直接获得锁。这通常可以提高吞吐量,但可能导致某些线程长时间无法获得锁。
2. ReentrantLock 的使用
ReentrantLock 的基本用法如下:
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock(); // 默认是非公平锁
//private final ReentrantLock lock = new ReentrantLock(true); // 创建公平锁
public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 释放锁,必须在 finally 块中释放
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
在上面的代码中,lock.lock() 方法尝试获取锁。如果锁当前被其他线程持有,当前线程将被阻塞,直到锁被释放。lock.unlock() 方法释放锁,允许其他线程获取锁。务必将 lock.unlock() 放在 finally 块中,以确保即使在发生异常的情况下也能正确释放锁,避免死锁。
可以通过构造函数 ReentrantLock(boolean fair) 来指定锁的公平性。fair 参数为 true 时创建公平锁,为 false 时创建非公平锁(默认值)。
3. 公平锁与非公平锁的性能测试
为了测试公平锁和非公平锁的性能差异,我们需要设计一个并发场景。我们将创建一个计数器,多个线程并发地对其进行递增操作,并分别使用公平锁和非公平锁来保护计数器。
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class LockPerformanceTest {
private static final int NUM_THREADS = 10;
private static final int NUM_INCREMENTS = 1000000;
static class Counter {
private int count = 0;
private final ReentrantLock lock;
public Counter(boolean fair) {
this.lock = new ReentrantLock(fair);
}
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("Running Fair Lock Test...");
testLockPerformance(true);
System.out.println("nRunning Non-Fair Lock Test...");
testLockPerformance(false);
}
private static void testLockPerformance(boolean fair) throws InterruptedException {
Counter counter = new Counter(fair);
ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);
AtomicInteger completionCounter = new AtomicInteger(0); // track thread completion
long startTime = System.nanoTime();
for (int i = 0; i < NUM_THREADS; i++) {
executor.submit(() -> {
for (int j = 0; j < NUM_INCREMENTS; j++) {
counter.increment();
}
completionCounter.incrementAndGet();
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
long endTime = System.nanoTime();
long duration = (endTime - startTime) / 1000000; // milliseconds
System.out.println("Fairness: " + fair);
System.out.println("Total Count: " + counter.getCount());
System.out.println("Time taken: " + duration + " ms");
System.out.println("Average time per increment: " + (double) duration / (NUM_THREADS * NUM_INCREMENTS) * 1000 + " microseconds");
}
}
在这个测试中:
- 我们创建了一个
Counter类,它使用ReentrantLock来保护count变量。 NUM_THREADS定义了并发线程的数量,NUM_INCREMENTS定义了每个线程递增count的次数。testLockPerformance方法分别使用公平锁和非公平锁来运行测试,并测量执行时间。- 使用
AtomicInteger来准确的判断所有线程完成。
测试结果分析
运行上述代码,我们可以得到公平锁和非公平锁的性能数据。通常情况下,你会发现:
- 非公平锁的吞吐量更高: 非公平锁允许线程插队,减少了线程上下文切换的开销,从而提高了吞吐量。
- 公平锁的公平性更好: 公平锁保证了所有线程都有机会获得锁,避免了饥饿现象。但为了维护公平性,它需要额外的开销,例如维护一个等待队列,这会降低吞吐量。
以下是一个典型的测试结果示例(结果可能会因硬件和 JVM 配置而异):
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 总计数 | 10000000 | 10000000 |
| 执行时间 (ms) | 1200 | 800 |
| 平均每次递增时间 (微秒) | 0.12 | 0.08 |
从这个示例可以看出,非公平锁的执行时间更短,平均每次递增的时间也更少,这表明它的吞吐量更高。
4. 深入理解性能差异的原因
- 上下文切换: 公平锁需要维护一个等待队列,并按照队列的顺序唤醒线程。这涉及到更多的线程上下文切换,而上下文切换是一项昂贵的操作。非公平锁则允许线程直接尝试获取锁,减少了上下文切换的次数。
- 调度开销: 公平锁需要额外的调度开销来保证公平性。非公平锁则没有这个开销,它可以更快速地分配锁。
- CPU 竞争: 在高并发情况下,非公平锁可能导致某些线程长时间无法获得锁,造成饥饿现象。但这通常只发生在非常激烈的竞争环境中。
5. 选择指南:如何选择公平锁或非公平锁
在选择公平锁或非公平锁时,需要权衡吞吐量和公平性。
-
如果吞吐量是首要考虑因素: 优先选择非公平锁。大多数情况下,非公平锁可以提供更好的性能。例如,在高并发的 Web 服务器中,吞吐量至关重要,非公平锁是一个不错的选择。
-
如果公平性是首要考虑因素: 选择公平锁。如果需要保证所有线程都有机会获得锁,避免饥饿现象,那么公平锁是更好的选择。例如,在某些实时系统中,需要保证所有任务都能及时得到处理,公平锁可以避免某些任务被长时间阻塞。
-
实际情况的考量: 在实际应用中,还需要考虑具体的并发场景。如果并发量不高,或者锁的持有时间很短,那么公平锁和非公平锁的性能差异可能并不明显。在这种情况下,可以根据实际需求选择。
- 低并发场景: 非公平锁,因为开销更小
- 高并发,但资源竞争不激烈场景: 非公平锁,吞吐量更重要
- 高并发,资源竞争激烈,且需要避免饥饿: 公平锁
6. 避免死锁
无论选择公平锁还是非公平锁,都需要注意避免死锁。死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行的情况。
以下是一些避免死锁的常见方法:
- 避免循环等待: 确保线程获取锁的顺序是固定的,避免出现循环等待的情况。
- 设置超时时间: 在尝试获取锁时,设置一个超时时间。如果超过超时时间仍未获得锁,则放弃获取锁,避免无限等待。
- 使用
tryLock()方法:ReentrantLock提供了tryLock()方法,它尝试获取锁,如果锁当前被其他线程持有,则立即返回false,而不是阻塞等待。可以使用tryLock()方法来避免死锁。
7. 其他 ReentrantLock 的高级特性
除了公平性之外,ReentrantLock 还提供了其他一些高级特性:
- 可中断锁: 允许线程在等待锁的过程中被中断。这可以通过
lockInterruptibly()方法来实现。 - 条件变量:
ReentrantLock提供了条件变量(Condition),可以用来实现线程间的协作。条件变量允许线程在满足特定条件时暂停执行,并在其他线程满足条件时被唤醒。
8. 代码示例:使用 tryLock() 避免死锁
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class DeadlockAvoidance {
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();
public void method1() {
try {
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("Method 1 acquired lock1");
Thread.sleep(100); // Simulate some work
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("Method 1 acquired lock2");
// Do some work with both locks held
} finally {
lock2.unlock();
System.out.println("Method 1 released lock2");
}
} else {
System.out.println("Method 1 failed to acquire lock2, releasing lock1");
}
} finally {
lock1.unlock();
System.out.println("Method 1 released lock1");
}
} else {
System.out.println("Method 1 failed to acquire lock1");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void method2() {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("Method 2 acquired lock2");
Thread.sleep(100); // Simulate some work
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("Method 2 acquired lock1");
// Do some work with both locks held
} finally {
lock1.unlock();
System.out.println("Method 2 released lock1");
}
} else {
System.out.println("Method 2 failed to acquire lock1, releasing lock2");
}
} finally {
lock2.unlock();
System.out.println("Method 2 released lock2");
}
} else {
System.out.println("Method 2 failed to acquire lock2");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
DeadlockAvoidance deadlockAvoidance = new DeadlockAvoidance();
Thread thread1 = new Thread(deadlockAvoidance::method1);
Thread thread2 = new Thread(deadlockAvoidance::method2);
thread1.start();
thread2.start();
}
}
在这个例子中,method1 尝试先获取 lock1,然后获取 lock2,而 method2 尝试先获取 lock2,然后获取 lock1。这可能导致死锁。通过使用 tryLock(1, TimeUnit.SECONDS) 方法,我们可以设置一个超时时间,如果线程在指定时间内无法获得锁,则放弃获取锁,从而避免死锁。
9. 代码示例:使用 Condition 实现线程协作
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionExample {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean isDataReady = false;
public void produceData() {
lock.lock();
try {
while (isDataReady) {
try {
condition.await(); // Wait until data is consumed
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// Produce data
System.out.println("Producing data...");
isDataReady = true;
condition.signal(); // Signal that data is ready
} finally {
lock.unlock();
}
}
public void consumeData() {
lock.lock();
try {
while (!isDataReady) {
try {
condition.await(); // Wait until data is produced
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// Consume data
System.out.println("Consuming data...");
isDataReady = false;
condition.signal(); // Signal that data is consumed
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ConditionExample conditionExample = new ConditionExample();
Thread producer = new Thread(conditionExample::produceData);
Thread consumer = new Thread(conditionExample::consumeData);
producer.start();
consumer.start();
}
}
在这个例子中,produceData 方法生产数据,consumeData 方法消费数据。通过使用条件变量 condition,我们可以实现生产者和消费者之间的协作。await() 方法使线程进入等待状态,直到其他线程调用 signal() 或 signalAll() 方法唤醒它。signal() 方法唤醒等待队列中的一个线程,而 signalAll() 方法唤醒等待队列中的所有线程。
10. 总结与建议
今天的讲座,我们深入探讨了 Java ReentrantLock 的公平锁与非公平锁。非公平锁通常提供更高的吞吐量,而公平锁则保证了公平性,避免了饥饿现象。在选择时,需要根据具体的并发场景权衡吞吐量和公平性,并在实际应用中灵活运用 tryLock() 和条件变量等高级特性,以确保程序的正确性和性能。希望今天的讲解能够帮助大家更好地理解和使用 ReentrantLock。
性能与公平,权衡决定选择
非公平锁性能更佳,公平锁避免饥饿。
选择策略需根据实际场景,权衡吞吐与公平。