Java公平锁和非公平锁的区别
本文讲解Java公平锁与非公平锁。公平锁如排队,先到先得,性能较低;非公平锁可“插队”,性能更高但可能导致线程饥饿。ReentrantLock可配置此策略。
我们来详细讲解一下Java中的公平锁和非公平锁。这是一个在Java并发编程中非常重要的概念,主要与ReentrantLock相关。
1. 核心概念:什么是公平与非公平?
想象一下在银行排队办业务。
公平锁 (Fair Lock):就像严格排队。每个新来的人都必须到队尾排队,只有排在最前面的人才能办理业务。这种方式非常公平,保证了“先来后到”(First-In, First-Out, FIFO)。等待时间最长的线程会最先获得锁。
非公平锁 (Non-fair Lock):就像允许“插队”。当一个窗口空闲出来时(锁被释放),一个刚到银行的人(新来的线程)可以不用去排队,直接尝试抢占这个窗口。如果他抢到了,就直接办理业务。只有当他没抢到时,才会乖乖去队尾排队。这种方式不保证先来后到,可能会导致队列中某些人(线程)等待很长时间。
2. Java中的实现:ReentrantLock
在Java中,公平锁和非公平锁是java.util.concurrent.locks.ReentrantLock类的两种实现策略。我们可以通过其构造函数来选择:
// 1. 创建一个非公平锁(默认行为)
Lock nonFairLock = new ReentrantLock();
// 等价于:
Lock nonFairLock = new ReentrantLock(false);
// 2. 创建一个公平锁
Lock fairLock = new ReentrantLock(true); // 传入true
为什么默认是非公平的?
因为非公平锁通常有更高的性能。我们稍后会详细解释原因。
3. 公平锁与非公平锁的详细对比
| 特性/方面 | 公平锁 (Fair Lock) | 非公平锁 (Non-fair Lock) |
|---|---|---|
| 获取锁的规则 | 严格按照线程请求锁的顺序(FIFO队列)分配。 | 允许新来的线程“插队”,直接尝试获取锁。如果获取失败,再进入等待队列。 |
| 性能/吞吐量 | 较低。因为线程切换开销大。即使锁是可用的,也需要检查队列头部是否有等待者,并可能需要唤醒等待的线程,这个过程有延迟。 | 较高。减少了线程挂起和唤醒的次数。一个线程释放锁后,另一个刚请求的线程可以立即获取锁,无需等待队列中的线程被唤醒,从而提高了吞吐量。 |
| 饥饿问题 | 不会产生饥饿。因为有严格的排队机制,每个线程最终都有机会获得锁。 | 可能产生饥饿。如果一个线程运气总是不好,每次它被唤醒时,都有新来的线程“插队”成功,那么它可能永远也得不到锁。 |
| 实现原理 (AQS) | 线程请求锁时,会先检查等待队列中是否有比它更早的线程。如果有,则获取失败并进入队列。 | 线程请求锁时,会先用CAS(Compare-And-Swap)操作尝试直接获取锁。如果成功,就“插队”了。如果失败,再走公平锁的逻辑(检查队列并排队)。 |
4. 源码层面的简单解析 (基于AQS)
ReentrantLock的公平性是其内部的Sync类(继承自AbstractQueuedSynchronizer,简称AQS)决定的。
非公平锁 lock() 方法的逻辑:
// NonfairSync.java
final void lock() {
// 1. 尝试直接抢锁(插队的关键)
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
} else {
// 2. 如果抢锁失败,再走标准的获取流程(可能会排队)
acquire(1);
}
}
它会先尝试一次CAS操作来抢锁。如果成功了,就直接拿到了锁,实现了“插队”。
公平锁 lock() 方法的逻辑:
// FairSync.java
final void lock() {
acquire(1);
}
// acquire(1) 内部会调用 tryAcquire
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 关键区别在这里:!hasQueuedPredecessors()
// 在尝试获取锁之前,会检查队列中是否有比自己更早的等待者
if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current);
return true;
}
}
// ... 处理重入的逻辑 ...
return false;
}
公平锁在尝试获取锁之前,会调用hasQueuedPredecessors()方法,检查AQS的等待队列中是否已经有其他线程在等待了。如果有,它就不会去尝试获取锁,而是乖乖地去排队,从而保证了公平性。
5. 性能差异的原因(为什么非公平锁更快?)
非公平锁的性能优势主要来自于减少了CPU上下文切换。
- 场景: 线程A释放了锁,此时线程B在等待队列的头部,正等待被唤醒。
- 公平锁: 系统必须先唤醒线程B,让它获取锁。这个唤醒过程涉及到线程状态的转换(从WAITING到RUNNABLE),需要时间。
- 非公平锁: 如果此时线程A(刚释放锁的那个线程)又立即请求这把锁(或者另一个CPU上正在运行的线程C请求这把锁),它可以直接尝试获取。由于它正在CPU上运行,所以获取锁的概率很大,并且几乎没有延迟。它避免了唤醒线程B的开销。虽然对线程B不公平,但整体的吞吐量(单位时间内完成的任务数)提高了。
6. 如何选择?
使用非公平锁(默认):
- 绝大多数情况下,性能是首要考虑因素。
- 业务逻辑对线程获取锁的顺序没有严格要求。
- 线程持有锁的时间很短,或者线程竞争不激烈,发生饥饿的概率很低。
- 这是
ReentrantLock和synchronized的默认策略,足以说明它的普适性。
使用公平锁:
- 当公平性是业务的硬性要求时。例如,实现一个资源调度系统,必须保证先申请的先得到服务。
- 当需要避免线程饥饿问题时。
- 注意:使用公平锁会带来显著的性能开销,所以只有在确实需要时才使用。
7. synchronized 是公平的还是非公平的?
Java中的synchronized关键字实现的是非公平锁。
它是一种内置的、由JVM实现的锁机制。在Java 6之后,JVM对synchronized进行了大量优化(如偏向锁、轻量级锁、自旋锁等),但当竞争激烈,锁升级为重量级锁时,其行为仍然是非公平的,不保证线程的FIFO顺序。你无法将其设置为公平锁。
总结
| 特性 | 公平锁 (new ReentrantLock(true)) |
非公平锁 (new ReentrantLock() 或 synchronized) |
|---|---|---|
| 核心思想 | 先来后到,严格排队 | 允许插队,效率优先 |
| 优点 | 公平,不产生饥饿 | 吞吐量大,性能高 |
| 缺点 | 性能较低,上下文切换频繁 | 可能导致线程饥饿 |
| 适用场景 | 对顺序有严格要求的业务 | 绝大多数追求高性能的场景 |