1 前言
在Java的JUC包中,提供了一个强大的锁工具ReentrantLock,在多线程编程时,我们会时常用到。而其中的公平锁与非公平锁更是有着独特的魅力和重要的作用。理解公平锁与非公平锁的区别,对于优化程序性能、确保资源的合理分配至关重要。
下面,我们将深入探讨ReentrantLock的公平锁与非公平锁,带你揭开它们的神秘面纱,掌握多线程编程的关键技巧。那么接下来,让我们一起开启这场探索之旅吧!
2 公平 VS 非公平锁
首先我们先来了解下什么是公平锁和非公平锁。
公平锁:指多个线程按照申请锁的顺序来获取锁。在公平锁机制下,线程获取锁的顺序是遵循先来后到的原则,就像在排队一样。
非公平锁:指多个线程获取锁的顺序是不确定的。当一个线程释放锁后,新请求锁的线程有可能立即获取到锁,而不管在它之前是否还有其他等待的线程。
3 ReentrantLock公平锁和非公平锁
3.1 继承关系图谱
图片
通过继承关系图谱,我们可以看到ReentrantLock类实现了Serializable接口和Lock接口,另外其内部定义了3个内部类Sync、NonfairSync、FairSync。Sycn是一个抽象类实现了AbstractQueuedSynchronizer(下文简称AQS),NonfairSync、FairSync为Sync的实现子类,通过类的命名其实我们就可以知道NonfairSync为非公平锁的实现类,FairSync为公平锁的实现类,而Sycn为抽象出来的公共抽象类。
3.2 创建公平锁与非公平锁
ReentrantLock中提供了两个构造函数,一个是默认的构造函数,另一个是有参构造函数,通过布尔值参数控制创建锁对象的类型。可以看到使用默认构造函数默认创建的是非公平锁,使用有参构造函数参数为true时,创建的为公平锁,参数为false时,创建的为非公平锁。
/**
* 无参构造器
* 说明:从构造器内部实现可知默认构造的锁为非公平锁
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* 有参构造器
* 说明:fair参数设定构造的对象是公平锁还是非公平锁
* true:公平锁
* false:非公平锁
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
3.3 使用示例
3.3.1 非公平锁
@Test
public void testUnfairLock() throws InterruptedException {
// 无参构造函数,默认创建非公平锁模式
ReentrantLock lock = new ReentrantLock();
for (int i = 0; i < 6; i++) {
final int threadNum = i;
new Thread(() -> {
//获取锁
lock.lock();
try {
System.out.println("线程" + threadNum + "获取锁");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// finally中解锁
lock.unlock();
System.out.println("线程" + threadNum +"释放锁");
}
}).start();
Thread.sleep(999);
}
Thread.sleep(100000);
}
运行结果:
线程0获取锁
线程0释放锁
线程1获取锁
线程1释放锁
线程3获取锁
线程3释放锁
线程2获取锁
线程2释放锁
线程5获取锁
线程5释放锁
线程4获取锁
线程4释放锁
3.3.2 公平锁
@Test
public void testfairLock() throws InterruptedException {
// 有参构造函数,true表示公平锁,false表示非公平锁
ReentrantLock lock = new ReentrantLock(true);
for (int i = 0; i < 6; i++) {
final int threadNum = i;
new Thread(() -> {
//获取锁
lock.lock();
try {
System.out.println("线程" + threadNum + "获取锁");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// finally中解锁
lock.unlock();
System.out.println("线程" + threadNum +"释放锁");
}
}).start();
Thread.sleep(10);
}
Thread.sleep(100000);
}
运行结果:
线程0获取锁
线程0释放锁
线程1获取锁
线程1释放锁
线程2获取锁
线程2释放锁
线程3获取锁
线程3释放锁
线程4获取锁
线程4释放锁
线程5获取锁
线程5释放锁
3.4 实现原理分析
接下来,我们从ReentrantLock提供的两个核心API加锁方法lock()和解锁方法unlock()为入口,继续深入探索其内部公平锁和非公平锁的实现原理。
3.4.1 加锁流程剖析
1)ReentrantLock.lock()方法为ReentrantLock提供的加锁方法。公平锁和非公平锁都可以通过该方法来获取锁,区别在于其内部的sync引用的实例对象不同,公平锁时,sync引用的为FairSync对象,非公平锁时,sync引用的为NonfairSync对象。
public void lock() {
sync.lock();
}
2)那FairSync和NonfairSync中lock()方法的具体实现有哪些不同呢?
通过下面的代码对比我们可以看到FairSync.lock()方法实现是直接调用了AQS提供的acquire()方法。而NonfairSync.lock()方法实现是先通过CAS的方式先尝试获取了一次锁,如果尝试成功则直接将当前线程设置为占用锁线程,而获取失败时同样调用了AQS提供的acquire()方法。从这里可以看到非公平锁获取锁时,如果当前锁未被其他任何线程占用时,当前线程是有一次机会直接获取到锁的,而从公平锁的方法实现中我们还无法看到公平锁是如何实现,那我们继续深入看下AQS提供的acquire()方法的实现。
/**
* FairSync.lock()方法实现
**/
final void lock() {
//调用的AQS中提供的的实现独占锁方法
acquire(1);
}
/**
* NonfairSync.lock()方法实现
**/
final void lock() {
//通过CAS的方式尝试获取锁
if (compareAndSetState(0, 1))
//获取锁成功则将当前线程设置为占用锁线程
setExclusiveOwnerThread(Thread.currentThread());
else
//未成功获取到锁,调用AQS中的acquire()方法,再次尝试获取锁
acquire(1);
}
3)AbstractQueuedSynchronizer.acquire()方法,该方法是AQS实现独占锁的核心方法,主要的逻辑都在if判断条件中,这里面有3个重要的方法tryAcquire(),addWaiter()和acquireQueued()。这三个方法中分别封装了加锁流程中的主要处理逻辑。
方法中首先调用了tryAcquire()方法进行尝试获取锁。如果尝试获取失败则会调用addWaiter()方法将获取锁失败的线程加入到等待队列中,然后将addWaiter()方法返回的结果作为参数,继续调用acquireQueued()方法,此时当前线程会不断尝试获取锁,当获取锁成功后则会调用selfInterrupt()方法唤醒线程继续执行。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
我们继续层层剖析!分别看下tryAcquire(),addWaiter()和acquireQueued()的源码实现。
4)AbstractQueuedSynchronizer.tryAcquire()方法,该方法默认抛出了UnsupportedOperationException异常,自身未提供具体实现,此方法为AQS提供的钩子模版方法,由子类同步组件通过扩展该方法实现尝试获取锁逻辑。FairSync和NonfairSync分别重写了该方法并提供了不同的实现。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
5)FairSync和NonfairSync中tryAcquire()方法重写实现。
通过下图中的源码对比,我们可以明显的看出公平锁与非公平锁主要区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),而其他的代码流程是基本一致的,那我们再进入hasQueuedPredecessors()方法看一下。
/**
* FairSync.lock()方法实现
**/
protected final boolean tryAcquire(int acquires) {
//获取当前线程对象
final Thread current = Thread.currentThread();
//获取当前锁的状态
int c = getState();
//状态为0时表示锁未被占用
if (c == 0) {
//首先调用hasQueuedPredecessors()方法,检查队列中是否存在等待执行的线程,如果队列中有待执行的线程,会优先让队列中的线程执行,这是公平锁实现的核心
if (!hasQueuedPredecessors() &&
//如果hasQueuedPredecessors()这个方法返回false,则表示队列中没有等待执行的线程,那么会继续调用compareAndSetState(0, acquires)方法,通过cas尝试获取锁
compareAndSetState(0, acquires)) {
//如果获取锁成功,设置当前线程对象为占用锁的线程对象
setExclusiveOwnerThread(current);
//返回获取锁成功
return true;
}
//如果 current == getExclusiveOwnerThread() 相等,说明当前线程与占用锁的线程是是同一个线程,则也会被认为获取锁成功,即:重入锁
} else if (current == getExclusiveOwnerThread()) {
//叠加重入次数
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
//更新锁重入次数
setState(nextc);
//返回获取锁成功
return true;
}
//返回获取锁失败
return false;
}
/**
* NonfairSync.lock()方法
**/
protected final boolean tryAcquire(int acquies) {
//继续调用父抽象类Sync类中的nonfairTryAcquire方法
return nonfairTryAcquire(acquires);
}
/**
* Sync.nonfairTryAcquire()方法
**/
final boolean nonfairTryAcquire(int acquires) {
//获取当前线程对象实例
final Thread current = Thread.currentThread();
//获取state变量的值,即当前锁被重入的次数
int c = getState();
//state值为0,说明当前锁未被任何线程持有
if (c == 0) {
//以cas方式获取锁
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;
}
6)FairSync.hasQueuedPredecessors()方法,可以看到该方法主要做一件事情:主要是判断检查队列是否存在等待执行的线程,并且头部等待线程非当前线程。如果是则返回true,否则返回false。该方法也是公平锁实现的核心。当队列中已存在其他等待中的线程时,则会获取锁失败,会调用AbstractQueuedSynchronizer.addWaiter()方法将当前线程放入等待队列的尾部来排队获取锁。
/**
* 判断检查队列头部是否存在等待执行的线程,并且等待线程非当前线程
*
* @return
*/
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
/**
* h != t,如果头结点等于尾节点,说明队列中无数据,则说明队列中没有等待处理的节点
* (s = h.next) == null,头节点的下一个节点为空 返回true
* s.thread != Thread.currentThread() 头结点的下一个节点(即将执行的节点)所拥有的线程不是当前线程,返回true,说明队列中有即将执行的节点。
*/
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
为了方便大家理解,下面罗列了此方法返回true和返回false的场景图解:
图片
7)AbstractQueuedSynchronizer.addWaiter()方法,该方法主要是将获取锁失败的线程加入到等待队列的尾部,也就是进行排队,如果队列已初始化完成则直接将线程加入到队列尾部,如果队列尚未初始化,则会调用AbstractQueuedSynchronizer.enq()方法来完成队列的初始化再将当前线程加入到队列尾部。
/**
* 将获取锁失败的线程加入到等待队列中
*
* return 返回新加入的节点对象
*/
private Node addWaiter(Node mode) {
//创建新的节点,设置节点线程为当前线程,模式为独占模式
Node node = new Node(Thread.currentThread(), mode);
//pred引用尾节点
Node pred = tail;
//判定是否有尾节点
if (pred != null) {
//存在尾节点将当前节点的前驱指针指向尾节点
node.prev = pred;
//通过cas将当前节点设置为尾几点,当期望尾节点为pred时,则将当前node节点更新为尾节点
if (compareAndSetTail(pred, node)) {
//将原尾节点的后继指针指向当前节点,这里是双向链表 node.prev = pred; pred.next = node; 构成双向链表
pred.next = node;
//设置成功返回当前节点
return node;
}
}
//如果没有尾节点说明队列还未初始化,那么将进行初始化,并将当前节点添加值队列尾部
enq(node);
return node;
}
流程图解:
图片
8)AbstractQueuedSynchronizer.enq()方法,初始化队列,并将当前节点追加到队列尾部,如果已经初始化完成则直接追加。
/**
* 初始化队列,并将当前节点追加到队列尾部,如果已经初始化完成则直接追加
*
* return 节点对象
*/
private Node enq(final Node node) {
//死循环,直到插入队列成功跳出
for (;;) {
Node t = tail;
//判断尾节点是否为空,如果为空则说明当前队列未进行初始化,则需进行初始化操作
if (t == null) {
//新建一个空节点设置为头节点
if (compareAndSetHead(new Node()))
//尾节点指向头节点,此时尾节点与头结点为同一个节点
tail = head;
} else {
//如果不为空则说明已经初始化完成,直接将当前节点插入尾部,构成双向链表 node.prev = t;t.next = node;
node.prev = t;
//设置当前节点为尾节点
if (compareAndSetTail(t, node)) {
//设置原尾节点的下一个节点为当前节点
t.next = node;
return t;
}
}
}
}
流程图解:
9)AbstractQueuedSynchronizer.acquireQueued()方法,将线程加入到队列尾部后,加入线程会不断尝试获取锁,直到获取成功或者不再需要获取(中断)。
该方法的实现分成两部分:
9.1)如果当前节点已经成为头结点,尝试获取锁(tryAcquire)成功,然后返回。
9.2)否则检查当前节点是否应该被park(等待),将该线程park并且检查当前线程是否被可以被中断。
/**
* 不断尝试(自旋)进行获取锁,直到获取成功或者不再需要获取(中断)
*
* return
*/
final boolean acquireQueued(final Node node, int arg) {
//标记是否成功获取到锁,默认为未获取到
boolean failed = true;
try {
//标记是否需要唤醒中断线程,线程是否处于中断状态
boolean interrupted = false;
//开始自旋,要么获取到锁,要么线程被中断挂起
for (; ; ) {
//获取当前节点的前驱节点
final Node p = node.predecessor();
//判断前驱节点是否为头节点,如果为头节点,则说明当前线程为排队第一的待执行节点,可以尝试获取锁
if (p == head && tryAcquire(arg)) {
//如果获取锁成功将当前节点设置为头结点
setHead(node);
//将原头节点的后继指针设为null,去除强引用关系,帮助GC回收
p.next = null;
//标记获取锁成功 failed = false
failed = false;
//返回当前线程的中断标记,是否需要唤醒当前线程
return interrupted;
}
//检查当前节点是否应该被阻塞等待park
if (shouldParkAfterFailedAcquire(p, node) &&
//设置当前线程进入阻塞状态
parkAndCheckInterrupt())
//标记当前线程的中断状态为中断挂起状态,线程再次执行需要被唤醒。
interrupted = true;
}
} finally {
if (failed)
//只有在出异常的情况下才会执行到这里,需要将当前节点取消掉
cancelAcquire(node);
}
}
3.4.2 解锁流程剖析
1)ReentrantLock.unlock()方法为ReentrantLock提供的解锁方法。从实现可以看到该方法继续调用了release()方法,而NonFairLock、FairLock和Sync类中均未重写release()方法,所以此处是直接调用了AQS提供的release()方法来进行的解锁操作。
public void unlock() {
sync.release(1);
}
2)AbstractQueuedSynchronizer.release()方法,此方法主要做了两个事情,首先是通过调用tryRelease()方法尝试释放锁,如果释放失败直接返回失败,如果锁释放成功则会去唤醒下个节点线程的执行。下面,我们继续先看下tryRelease()方法的实现。
/**
* 锁释放 并唤醒下一个节点
*
* @param arg
* @return
*/
public final boolean release(int arg) {
// 1.尝试释放锁
if (tryRelease(arg)) {
Node h = head;
//2.头结点不为null并且等待状态不是初始化状态,因为处于初始化状态的节点可能仍未初始化完成
if (h != null && h.waitStatus != 0)
//3.唤醒头结点的下一个节点
unparkSuccessor(h);
return true;
}
//尝试获取锁失败
return false;
}
3)AbstractQueuedSynchronizer.tryRelease()方法,可以看到此方法同AbstractQueuedSynchronizer.tryAcquiry()方法一样,是由子类决定具体实现的,我们回看下ReentrantLock中定义的内部类,可以看到Sync类中重写了该方法,而NonFairLock和FairLock方法中并未再次重写该方法。所以在调用AQS中的tryRelease()方法时其实是调用的Sync类中的tryRelease()方法。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
4)Sync.tryRelease()方法,该方法做的事其实跟简单主要是将已执行完成的线程持有的锁资源释放掉。
/**
* 已执行完成的线程,释放资源
*
* @param releases
* @return 返回释放锁后的已重入次数
*/
protected final boolean tryRelease(int releases) {
//获取当前资源状态值,并重新计算已重入次数
int c = getState() - releases;
//如果当前线程不是获得资源的线程,将抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//资源是否完全释放,因为涉及到可重入锁
boolean free = false;
if (c == 0) {
//等于0的情况下表示资源完全释放
free = true;
//清除锁的持有线程标记
setExclusiveOwnerThread(null);
}
//重新设置已重入次数
setState(c);
return free;
}
5)Sync.unparkSuccessor()方法,锁释放成功后会调用该方法,来唤醒当前节点的后续节点线程的执行。
/**
* 唤醒当前节点的后续节点
*
* @param node the node
*/
private void unparkSuccessor(Node node) {
//获取头结点的等待状态
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
//获取当前节点的下一个节点
Node s = node.next;
//如果当前节点的下一个节点是null或者状态大于0,说明当前节点的下一个节点不是有效节点,那么则需要找到下一个有效的等待节点
if (s == null || s.waitStatus > 0) {
s = null;
//从尾节点开始向前找,找到最前面的状态小于0的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
//唤醒让当前节点的下一个节点线程,继续执行
LockSupport.unpark(s.thread);
}
4 总结
从实现来看,公平锁的实现利用了FIFO队列的特性,先加入同步队列等待的线程会比后加入的线程更靠近队列的头部,那么它将比后者更早的被唤醒,它也就能更早的得到锁。从这个意义上,对于在同步队列中等待的线程而言,它们获得锁的顺序和加入同步队列的顺序一致,这显然是一种公平模式。然而,线程并非只有在加入队列后才有机会获得锁,哪怕同步队列中已有线程在等待,非公平锁的不公平之处就在于此。回看下非公平锁的加锁流程,线程在进入同步队列等待之前有两次抢占锁的机会。
- 第一次是非重入式的获取锁,只有在当前锁未被任何线程占有(包括自身)时才能成功。
图片
- 第二次是在进入同步队列前,包含所有情况的获取锁的方式。
图片
只有这两次获取锁都失败后,线程才会构造结点并加入到同步队列等待,而线程释放锁时是先释放锁(修改state值),然后才唤醒后继结点的线程的。试想下这种情况,线程A已经释放锁,但还没来得及唤醒后继线程C,而这时另一个线程B刚好尝试获取锁,此时锁恰好不被任何线程持有,它将成功获取锁而不用加入队列等待。线程C被唤醒尝试获取锁,而此时锁已经被线程B抢占,故而其获取失败并继续在队列中等待。如果以线程第一次尝试获取锁到最后成功获取锁的次序来看,非公平锁确实很不公平。因为在队列中等待很久的线程相比于还未进入队列等待的线程并没有优先权,甚至竞争也处于劣势,在队列中的线程要等待其他线程唤醒,在获取锁之前还要检查前驱结点是否为头结点。在锁竞争激烈的情况下,在队列中等待的线程可能迟迟竞争不到锁。这也就非公平在高并发情况下会出现的饥饿问题。
5 思考
5.1 为什么非公平锁性能好
非公平锁对锁的竞争是抢占式的(队列中线程除外),线程在进入等待队列前可以进行两次尝试,这大大增加了获取锁的机会。这种好处体现在两个方面:
1)线程不必加入等待队列就可以获得锁,不仅免去了构造结点并加入队列的繁琐操作,同时也节省了线程阻塞唤醒的开销,线程阻塞和唤醒涉及到线程上下文的切换和操作系统的系统调用,是非常耗时的。在高并发情况下,如果线程持有锁的时间非常短,短到线程入队阻塞的过程超过线程持有并释放锁的时间开销,那么这种抢占式特性对并发性能的提升会更加明显。
2)减少CAS竞争,如果线程必须要加入阻塞队列才能获取锁,那入队时CAS竞争将变得异常激烈,CAS操作虽然不会导致失败线程挂起,但不断失败重试导致的对CPU的浪费也不能忽视。
5.2 公平锁与非公平锁的选择
公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。所以适用场景适用于对资源访问顺序有严格要求的场景。例如,在一些资源分配系统中,要求按照请求的先后顺序来分配资源,以避免饥饿现象(某个线程一直无法获取锁)的发生。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。所以非公平锁适用于如果对线程获取锁的顺序没有严格要求的场景,例如在一些高并发的缓存系统或者日志系统中,可以使用非公平锁来提高系统的整体性能。
关于作者孔德志 采货侠Java开发工程师