Java中的公平锁与非公平锁:性能、吞吐量与线程饥饿的权衡
大家好,今天我们来深入探讨Java并发编程中一个重要的概念:公平锁与非公平锁。我们将从原理、实现、性能、吞吐量以及线程饥饿等多个维度,剖析这两种锁的特性,并结合代码示例,帮助大家理解如何在实际应用中做出最佳选择。
一、锁的基本概念与Java中的实现
在多线程环境下,为了保证共享资源的安全访问,我们需要锁。锁的本质是一种同步机制,它允许同一时刻只有一个线程能够访问被保护的资源,从而避免数据竞争和状态不一致。
Java提供了多种锁的实现,其中最基础的是 synchronized 关键字和 java.util.concurrent.locks 包下的 Lock 接口。synchronized 是一种隐式锁,由JVM自动管理锁的获取和释放。Lock 接口则提供了显式的锁操作,例如 lock()、unlock() 和 tryLock() 等方法,提供了更灵活的控制。
ReentrantLock 是 Lock 接口的一个重要实现类,它具有可重入性,即同一个线程可以多次获取同一个锁而不会被阻塞。ReentrantLock 提供了公平锁和非公平锁两种模式,这也是我们今天讨论的重点。
二、公平锁与非公平锁的定义与原理
-
公平锁 (Fair Lock): 公平锁保证线程按照申请锁的顺序获得锁。当锁可用时,等待时间最长的线程(即队列中最前面的线程)优先获得锁。
-
非公平锁 (Non-fair Lock): 非公平锁不保证线程按照申请锁的顺序获得锁。当锁可用时,所有等待线程都有机会竞争锁,包括正在请求锁的线程。这意味着,即使有线程在队列中等待,新来的线程也可能抢先获得锁。
为了更清楚地理解,我们用一个排队买票的例子来类比。
-
公平锁: 就像严格按照先来后到的顺序排队买票,先排队的顾客优先买到票。
-
非公平锁: 就像有些售票窗口允许插队,新来的顾客如果能挤到前面,就有可能先买到票,而不管后面有多少人在排队。
三、ReentrantLock的实现与源码分析
ReentrantLock 内部依赖于 AbstractQueuedSynchronizer (AQS) 来实现锁的机制。AQS是一个抽象的同步器框架,它使用一个 volatile int state 变量来表示同步状态,并维护一个 FIFO 的等待队列来管理等待线程。
ReentrantLock 通过构造函数来指定公平性:
// 创建一个非公平锁
ReentrantLock lock = new ReentrantLock();
// 创建一个公平锁
ReentrantLock fairLock = new ReentrantLock(true);
关键在于 ReentrantLock 内部维护了一个 Sync 抽象类,Sync 有两个子类:NonfairSync (非公平锁) 和 FairSync (公平锁)。锁的获取和释放操作实际上委托给了 Sync 的子类来完成。
让我们简单看一下 FairSync 和 NonfairSync 的 lock() 方法实现:
FairSync (公平锁):
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
@Override
final void lock() {
acquire(1);
}
@Override
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 关键:检查队列中是否有等待线程,如果有,则不竞争
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
FairSync 的 tryAcquire() 方法在尝试获取锁之前,会调用 hasQueuedPredecessors() 方法检查等待队列中是否有等待线程。如果有,则放弃竞争,直接返回 false,让等待队列中的线程优先获得锁,确保公平性。
NonfairSync (非公平锁):
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563906789880L;
@Override
final void lock() {
// 直接尝试获取锁,不考虑队列中的等待线程
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
@Override
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 直接竞争锁,不检查队列
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
NonfairSync 的 lock() 方法直接尝试使用 CAS (Compare and Swap) 操作获取锁,如果获取成功,则设置当前线程为独占所有者。即使有线程在等待队列中,NonfairSync 也会尝试直接竞争锁,这就是非公平性的体现。
四、性能、吞吐量与线程饥饿的权衡
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 公平性 | 保证按照申请顺序获取锁 | 不保证按照申请顺序获取锁 |
| 性能 | 较低,因为需要维护队列和检查等待线程 | 较高,减少了线程切换的开销 |
| 吞吐量 | 较低,因为每次只有一个线程可以访问共享资源 | 较高,允许更多线程并发地竞争锁 |
| 线程饥饿 | 降低线程饥饿的可能性,但不能完全避免 | 增加线程饥饿的可能性 |
| 适用场景 | 线程竞争激烈,需要保证公平性的场景 | 线程竞争不激烈,追求高吞吐量的场景 |
4.1 性能与吞吐量
非公平锁通常比公平锁具有更高的性能和吞吐量。这是因为非公平锁减少了线程切换的开销。当一个线程释放锁时,如果有另一个线程正在等待,非公平锁允许当前线程立即再次尝试获取锁,而不需要像公平锁那样,将锁交给等待队列中的第一个线程。
这种"抢占"机制减少了上下文切换的次数,提高了CPU的利用率,从而提高了整体的吞吐量。
4.2 线程饥饿
线程饥饿是指一个线程长时间无法获得所需的资源(例如锁),导致它无法执行。公平锁的设计初衷是为了减少线程饥饿的可能性。通过按照申请顺序分配锁,公平锁可以避免某些线程一直被"饿死"。
然而,公平锁并不能完全消除线程饥饿。在某些极端情况下,即使是公平锁,也可能出现线程饥饿。例如,如果一个线程持续地释放锁并立即重新申请锁,那么其他线程可能一直无法获得锁。
非公平锁更容易导致线程饥饿。由于新来的线程可以抢先获得锁,等待队列中的线程可能需要等待更长的时间才能获得锁,增加了线程饥饿的风险。
五、代码示例与性能测试
为了更直观地展示公平锁和非公平锁的差异,我们编写一个简单的示例,模拟多线程环境下对共享资源的访问,并比较两种锁的性能。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private static final int NUM_THREADS = 5;
private static final int NUM_ITERATIONS = 1000000;
private static int counter = 0;
private static Lock fairLock = new ReentrantLock(true);
private static Lock nonFairLock = new ReentrantLock(false);
public static void main(String[] args) throws InterruptedException {
System.out.println("Testing Fair Lock:");
testLock(fairLock);
System.out.println("nTesting Non-Fair Lock:");
counter = 0; // Reset counter for the next test
testLock(nonFairLock);
}
private static void testLock(Lock lock) throws InterruptedException {
long startTime = System.nanoTime();
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_ITERATIONS; j++) {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
long endTime = System.nanoTime();
long duration = (endTime - startTime) / 1000000; // Milliseconds
System.out.println("Counter value: " + counter);
System.out.println("Time taken: " + duration + " ms");
}
}
在这个示例中,我们创建了 NUM_THREADS 个线程,每个线程都会对 counter 变量进行 NUM_ITERATIONS 次累加操作。我们分别使用公平锁和非公平锁来保护 counter 变量的访问。
运行结果(多次运行取平均值)可能会因硬件环境和JVM配置而有所不同,但通常情况下,你会观察到以下现象:
- 非公平锁的执行时间通常比公平锁短,这意味着非公平锁具有更高的性能。
counter的最终值应该等于NUM_THREADS * NUM_ITERATIONS,这表明锁机制能够正确地保护共享资源。
注意: 上述代码只是一个简单的示例,用于演示公平锁和非公平锁的基本用法。在实际应用中,性能测试需要更严谨的方法和更复杂的场景,才能得出更可靠的结论。
六、选择公平锁还是非公平锁?
选择公平锁还是非公平锁取决于具体的应用场景和需求。
-
如果你的应用需要保证公平性,避免线程饥饿,那么应该选择公平锁。 例如,在高并发的实时系统中,如果某些线程因为无法获得锁而长时间无法响应,可能会导致严重的性能问题,此时公平锁是更合适的选择。
-
如果你的应用对性能要求较高,并且线程竞争不是很激烈,那么可以选择非公平锁。 例如,在一些缓存系统中,读操作远多于写操作,此时使用非公平锁可以提高吞吐量。
-
在大多数情况下,非公平锁是默认的选择。 因为它通常能够提供更好的性能,并且线程饥饿的问题在实际应用中并不常见。
七、ReentrantReadWriteLock 的公平性
ReentrantReadWriteLock 提供了读写锁的功能,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。ReentrantReadWriteLock 同样可以配置公平性,但需要注意的是,这里的公平性是针对写锁而言的。
ReentrantReadWriteLock 的公平性策略如下:
- 公平的写锁: 线程按照申请写锁的顺序获得写锁。
- 非公平的写锁: 线程可以抢占写锁,即使有其他线程正在等待。
读锁的公平性相对复杂,通常情况下,读锁的获取会尽量避免阻塞写锁的获取,以提高并发性。
八、总结:选择合适的锁,平衡公平与效率
公平锁与非公平锁是Java并发编程中重要的概念。公平锁保证线程按照申请顺序获得锁,降低线程饥饿的可能性,但性能较低。非公平锁允许线程竞争锁,提高吞吐量,但可能导致线程饥饿。选择哪种锁取决于具体的应用场景,需要在公平性和效率之间进行权衡。大多数情况下,非公平锁是默认的选择,但在需要保证公平性的场景下,公平锁是更合适的选择。同时,理解 ReentrantReadWriteLock 的公平性策略,有助于我们在读写并发场景中做出更合理的选择。