公平锁与非公平锁的区别及其在 ReentrantLock 中的实现详解
引言
在并发编程中,锁是用于解决多个线程对共享资源进行竞争访问的常见工具。锁机制的设计可以分为多种形式,其中最为常见的是公平锁与非公平锁。Java 提供了 ReentrantLock
来实现这两种锁的机制,通过使用公平和非公平锁,我们可以对线程竞争进行不同的控制。
公平锁保证锁的获取按照线程请求的顺序进行,而非公平锁则允许线程抢占锁。虽然公平锁可以避免线程饥饿,但它也带来了性能上的开销。本文将详细讲解公平锁与非公平锁的区别,探讨公平锁的缺点,并通过代码和图文解释 ReentrantLock
是如何实现这两种锁的。
第一部分:什么是公平锁与非公平锁
1.1 公平锁
公平锁是指线程获取锁的顺序严格按照它们请求锁的顺序进行。即每个线程获取锁的顺序与它们排队的顺序保持一致,类似于在银行排队取号的机制。公平锁的实现通过一个先进先出的队列(FIFO)来管理线程,当某个线程持有锁时,其他线程会进入等待队列,直到轮到自己。
特点:
- 保证每个线程都能够公平地获得锁。
- 避免了线程饥饿问题。
示意图:公平锁的锁获取流程
+-------------------+
| 线程1 请求锁 | ---> 进入等待队列
+-------------------+
+-------------------+
| 线程2 请求锁 | ---> 进入等待队列
+-------------------+
+-------------------+
| 线程3 请求锁 | ---> 进入等待队列
+-------------------+
1.2 非公平锁
非公平锁则是一种允许线程“抢占”锁的机制。当某个线程请求锁时,它可以尝试直接获取锁,而不必等待已经在等待队列中的线程。这意味着如果当前锁没有被其他线程持有,那么请求锁的线程可以直接获取到锁,而不用排队。
特点:
- 可能导致线程饥饿:某些线程可能长时间获取不到锁。
- 性能较高,因为非公平锁减少了上下文切换和线程调度的开销。
示意图:非公平锁的锁获取流程
+-------------------+
| 线程1 请求锁 | ---> 立即尝试获取锁
+-------------------+
+-------------------+
| 线程2 请求锁 | ---> 立即尝试获取锁
+-------------------+
+-------------------+
| 线程3 请求锁 | ---> 立即尝试获取锁
+-------------------+
第二部分:公平锁的优缺点
2.1 公平锁的优点
-
避免线程饥饿:公平锁严格按照请求的顺序分配锁,保证每个线程都有机会获取锁,防止某些线程长时间等待,特别是在高并发场景中,这种公平机制非常重要。
-
任务的公平调度:在某些业务逻辑中,要求任务按照请求的顺序来处理,因此使用公平锁可以确保任务的顺序性,防止一些任务提前或被长时间延迟。
2.2 公平锁的缺点
-
性能开销较大:公平锁需要维护一个排队机制,每当有新线程请求锁时,系统需要检查等待队列中的线程并按照顺序分配锁,这增加了锁的获取成本。
-
上下文切换较频繁:由于每个线程都需要按照顺序等待获取锁,频繁的线程调度和上下文切换可能会降低系统的性能,特别是在高并发的场景中。
-
降低吞吐量:由于公平锁严格按照顺序分配锁,某些本可以快速执行的线程也需要等待队列中其他线程先获取锁,这可能导致系统吞吐量的降低。
示意图:公平锁中的上下文切换
+--------------------+ +--------------------+
| 线程1 获取锁 | --> 切换到 | 线程2 等待获取锁 |
+--------------------+ +--------------------+
+--------------------+ +--------------------+
| 线程2 获取锁 | --> 切换到 | 线程3 等待获取锁 |
+--------------------+ +--------------------+
第三部分:非公平锁的优缺点
3.1 非公平锁的优点
-
高性能:非公平锁允许线程“抢占”锁,而不必按照排队的顺序等待。这样可以减少线程的上下文切换和调度开销,从而提升系统的并发性能。
-
高吞吐量:由于锁可以被直接抢占,非公平锁在某些情况下能够快速处理短时间的任务,提高系统的整体吞吐量。
3.2 非公平锁的缺点
-
可能导致线程饥饿:非公平锁不保证线程获取锁的顺序,某些线程可能长期处于等待状态,特别是在高并发场景中,如果线程抢不到锁,就会出现线程饥饿问题。
-
任务顺序不确定:非公平锁不保证任务的执行顺序,在某些对顺序要求严格的场景下,非公平锁可能不合适。
第四部分:ReentrantLock 的公平锁和非公平锁实现
4.1 ReentrantLock 概述
ReentrantLock
是 Java 提供的显式锁,它比 synchronized
具有更丰富的功能。ReentrantLock
允许线程重复获取锁,并提供了公平锁与非公平锁的选择。
- 公平锁:
ReentrantLock
的构造方法可以通过传入true
参数来创建公平锁。 - 非公平锁:
ReentrantLock
的默认构造方法使用非公平锁,也可以通过传入false
参数来创建非公平锁。
4.2 ReentrantLock 的公平锁实现
当使用公平锁时,ReentrantLock
通过内部维护一个FIFO队列来确保每个线程按照请求顺序获取锁。具体实现上,ReentrantLock
使用 AbstractQueuedSynchronizer
(AQS)的 tryAcquire
方法,检查当前是否有其他线程在排队,确保锁按照顺序分配。
代码示例:ReentrantLock 公平锁
import java.util.concurrent.locks.ReentrantLock;
public class FairLockExample {
private static final ReentrantLock lock = new ReentrantLock(true); // 公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(new Task(), "线程-" + i).start();
}
}
static class Task implements Runnable {
@Override
public void run() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取了锁");
Thread.sleep(1000); // 模拟业务操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
}
运行结果(不同线程获取锁的顺序依次进行):
线程-0 获取了锁
线程-1 获取了锁
线程-2 获取了锁
线程-3 获取了锁
线程-4 获取了锁
实现细节:
- 当线程尝试获取锁时,
ReentrantLock
会检查是否有其他线程在等待队列中。如果有,当前线程会被加入到等待队列的末尾。 - 锁的释放时,系统会唤醒等待队列中的第一个线程,从而保证锁的获取顺序是公平的。
4.3 ReentrantLock 的非公平锁实现
非公平锁是 ReentrantLock
的默认锁实现。在非公平锁中,线程在请求锁时,不会关心等待队列中的顺序,而是直接尝试获取锁。这种抢占式的策略减少了排队和上下文切换,提高了系统的性能。
代码示例:ReentrantLock 非公平锁
import java.util.concurrent.locks.ReentrantLock;
public class UnfairLockExample {
private static final ReentrantLock lock = new ReentrantLock(); // 非公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(new Task(), "线程-" + i).start();
}
}
static class Task implements Runnable {
@Override
public void run() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取了锁");
Thread.sleep(
1000); // 模拟业务操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
}
运行结果(不同线程获取锁的顺序可能不按提交顺序进行):
线程-0 获取了锁
线程-1 获取了锁
线程-3 获取了锁
线程-2 获取了锁
线程-4 获取了锁
实现细节:
- 线程直接尝试获取锁,如果锁是可用的,立即获取,不需要查看等待队列中的其他线程。
- 由于线程不按顺序排队,某些线程可能抢占其他线程的锁,从而提升了性能,但可能导致某些线程长期等待。
4.4 公平锁与非公平锁的性能比较
公平锁和非公平锁的核心区别在于锁的分配顺序上。公平锁保证了锁的顺序性,而非公平锁则允许锁的抢占。因此,它们在不同的应用场景下表现不同:
- 公平锁:适合对顺序性要求高的场景,但由于频繁的上下文切换,性能可能较低。
- 非公平锁:适合高并发、低延迟的场景,能够提升系统的吞吐量,但可能导致某些线程长期处于饥饿状态。
第五部分:ReentrantLock 内部如何实现公平锁与非公平锁
ReentrantLock
的内部是通过 AbstractQueuedSynchronizer
(AQS)来管理锁的状态和线程队列。AQS 提供了 FIFO 队列来管理线程的等待,公平锁与非公平锁的实现主要区别在于 tryAcquire()
方法。
5.1 AQS 中的公平锁实现
对于公平锁,ReentrantLock
会首先检查等待队列中是否有其他线程,如果有,当前线程会被加入队列,等待前面的线程获取和释放锁。
代码示例:AQS 中的公平锁实现
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;
}
}
return false;
}
5.2 AQS 中的非公平锁实现
对于非公平锁,线程在获取锁时,不会检查等待队列中的其他线程,而是直接尝试获取锁。
代码示例:AQS 中的非公平锁实现
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 非公平锁直接尝试获取锁
if (c == 0 && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
return false;
}
5.3 公平锁与非公平锁的选择
ReentrantLock
提供了两种构造方法,开发者可以选择使用公平锁或非公平锁:
// 使用公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 使用非公平锁
ReentrantLock unfairLock = new ReentrantLock(false);
第六部分:公平锁与非公平锁的应用场景
6.1 公平锁的应用场景
公平锁适用于对锁的顺序性要求较高的场景,典型应用包括:
- 银行排队系统:保证客户按顺序办理业务。
- 任务调度系统:确保任务按照提交的顺序执行,避免任务的无序执行导致结果错误。
6.2 非公平锁的应用场景
非公平锁适用于高并发、对性能要求较高的场景,典型应用包括:
- Web服务器:在处理高并发请求时,非公平锁能够减少锁竞争带来的性能损耗。
- 数据库连接池:非公平锁可以提高数据库连接的利用率,减少线程切换带来的开销。
第七部分:公平锁与非公平锁的性能测试
通过实际的性能测试,可以更直观地了解公平锁与非公平锁在不同并发场景下的表现。以下是一个性能测试的示例代码,用于对比公平锁与非公平锁在高并发场景下的表现。
代码示例:公平锁与非公平锁的性能测试
import java.util.concurrent.locks.ReentrantLock;
public class LockPerformanceTest {
private static final int THREAD_COUNT = 100;
private static final ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
private static final ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁
public static void main(String[] args) throws InterruptedException {
testLockPerformance(fairLock, "公平锁");
testLockPerformance(unfairLock, "非公平锁");
}
private static void testLockPerformance(ReentrantLock lock, String lockType) throws InterruptedException {
long startTime = System.currentTimeMillis();
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100000; j++) {
lock.lock();
try {
// 模拟业务操作
} finally {
lock.unlock();
}
}
});
}
// 启动所有线程
for (Thread thread : threads) {
thread.start();
}
// 等待所有线程完成
for (Thread thread : threads) {
thread.join();
}
long endTime = System.currentTimeMillis();
System.out.println(lockType + " 耗时: " + (endTime - startTime) + " 毫秒");
}
}
运行结果:
公平锁 耗时: 3500 毫秒
非公平锁 耗时: 2900 毫秒
分析:
- 在高并发场景下,非公平锁由于减少了线程的上下文切换,性能更高。
- 公平锁由于需要维护线程的顺序性,性能相对较低,但保证了线程的公平性。
第八部分:总结
公平锁与非公平锁是并发编程中两种常见的锁机制,它们各自具有不同的优缺点,适用于不同的应用场景。在高并发的环境下,非公平锁能够带来更高的性能,但可能会导致某些线程饥饿。而公平锁则通过维护一个FIFO队列,保证线程能够按照请求顺序获取锁,防止线程饥饿,但会带来一定的性能损耗。
ReentrantLock
提供了对公平锁与非公平锁的支持,开发者可以根据实际需求选择合适的锁类型。在需要保证任务顺序性的场景下,公平锁是较好的选择;而在对性能要求较高的场景中,非公平锁更具优势。
通过对这两种锁的实现原理、性能对比以及应用场景的分析,开发者可以更好地理解并选择适合自己的锁机制,从而提高系统的并发性能和稳定性。