友情提示:篇幅较长,可以在右边目录查看总体目录结构
本文主要对lock接口的实现类ReentrantLock做一个源码解析,希望可以帮到大家更好的理解ReentrantLock加锁解锁的原理。关于ReentrantLock的使用不是我们这篇文章的重点,但是本文通过源码角度解析ReentrantLock,可以帮助大家更好的理解ReentrantLock,知其然,知其所以然,更是我们作为技术人应该拥有的精神。
jdk版本:1.8
一. 简介
ReentrantLock 位于 java.util.concurrent.locks 包下,它实现了 Lock 接口和 Serializable 接口。
ReentrantLock 是一把可重入锁和互斥锁,它具有与 synchronized 关键字相同的含有隐式监视器锁(monitor)的基本行为和语义,但是它比 synchronized 具有更多的方法和功能。
二. 示例
关于ReentrantLock的公平锁和非公平锁,这里有个非公平锁示例,大家可以运行体验一下,如果是公平锁,只需要把创建lock对象时的参数改为true就行了
public class MyFairLock extends Thread{
private ReentrantLock lock = new ReentrantLock(false);
public void fairLock(){
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "正在持有锁");
}finally {
System.out.println(Thread.currentThread().getName() + "释放了锁");
lock.unlock();
}
}
public static void main(String[] args) {
MyFairLock myFairLock = new MyFairLock();
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName() + "启动");
myFairLock.fairLock();
};
Thread[] thread = new Thread[10];
for(int i = 0;i < 10;i++){
thread[i] = new Thread(runnable);
}
for(int i = 0;i < 10;i++){
thread[i].start();
}
}
}
三. 结构介绍
1. 内部类
首先我们先来看这个类的整体结构,可以看到图中我圈起来了三个内部类,分别是Sync,NonefairSync,FairSync,
这三个类便是核心的三各类,我们可以从图中看到这三个类内部的方法
然后我们来看下这三个类的继承结构,这几个结构这里一定要记清,这几个类以及父类在后边讲源码的过程都会提及到
1.1 Sync
1.2 NonfairSync
1.3 FairSync
1.4 总结
可以看到,Sync是FairSync(公平锁实现相关类)和NonfairSync(非公平锁实现相关类)的父类,而sync继承了AbstractQueuedSynchronizer类(简称AQS),关于AQS不仅仅在ReentrantLock中有用到,在Semaphore,CountDownLatch,FutureTask中都有用到,附上相关文章AbstractQueuedSynchronizer的简单介绍,有兴趣的小伙伴在底下可以了解一下
2. 构造函数
ReentrantLock是可重入锁,同时它支持公平锁和非公平锁两种方式
下边分别是ReentrantLock的无参和有参构造函数,从函数中我们可以了解到如果是无参构造,那么默认是非公平锁,如果是有参构造函数,如果我们参数为true,那么就是公平锁,如果为false,那么就是非公平锁
关于ReentrantLock的公平锁和非公平锁在解析源码之前我们先透漏一下:
- 公平锁能保证:老的线程排队使用锁,新线程仍然排队使用锁。
- 非公平锁保证:老的线程排队使用锁;但是无法保证新线程抢占已经在排队的线程的锁。
四. 源码讲解(非公平锁)
由于非公平锁要比公平锁复杂,所以我们主要讲解非公平锁,大部分讲解可能会在代码中标上注释来作为讲解
友情提示: 大家可以下载一份jdk源码,把源码放到你的ide中,这样配合讲解可以更方便地阅读源码,理解源码,而且可以用快捷键直接进入方法中查看所属类和具体的内容,对于初学者来说看源码是很复杂的,所以非常建议这样做,以便后边不迷路,另外建议先把代码块中的代码大致浏览一下再开始看我的注释,效果可能好一点。
1. 加锁
1.1 加锁入口
为了防止大家看着看着不知道进了哪个类,下边都会在变化处进行标记所处哪一个类,
ReentrantLock类:
sync就是我们调用构造函数时进行的初始化,我们来讲述非公平锁,图片不好打注释,还是贴代码吧,
1.2 ReentrantLock NonfairSync内部类:
ReentrantLock.java:
/**
* Sync object for non-fair locks
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
/*调用AQS中的方法,以cas方式将AQS中的state设置为1,
AQS中的state在此场景中,ReentrantLock用它来表示所有者线程已经重复获取该锁的次数(重入的层数),
即为0表示没有线程占用,如果成功设置为1,表示成功获取锁,没有成功设置为1,即返回false不会进入到代码快,
说明state不为0,只有state为0时才会改为1,返回true,这是cas的用法*/
if (compareAndSetState(0, 1))
/*成功获取锁后,把线程拥有者设置为当前线程,下边这个方法就是AbstractOwnableSynchronizer类中的方法,负责
管理当前拥有该锁的线程。*/
setExclusiveOwnerThread(Thread.currentThread());
else
//如果没有成功获取锁,下边讲解这个方法
acquire(1);
}
//下边这个是钩子方法,这里先记着就行,下边将acqiure方法就会用到
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
1.3 AbstractQueuedSynchronizer acquire()方法
然后我们接着上边的来讲解acquire方法,这个方法是加锁流程相当核心的部分,所以我们先大致介绍,然后对每个方法进行详细介绍,我们进入方法发现,这个方法是其父类AQS的:
AbstractQueuedSynchronizer.java:
public final void acquire(int arg) {
/*下边的tryAcquire方法其实这个类中也有,但他调用时其实是调的子类的,因为讲的是
非公平锁,所以调的也就是上边代码块中的tryAcqure方法,当然公平锁也有自己的
tryAcuire方法,父类的方法中调用的方法由子类进行具体的实现,这类方法也叫钩子方
法,这里再次尝试获取锁*/
if (!tryAcquire(arg) &&
/*如果还是获取锁失败(tryAcquire返回false),根据&&短路原则,那么就
执行下边把当前线程加入到同步队列进行等待并直到获取锁*/
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//加入同步队列后阻塞当前线程
selfInterrupt();
}
然后我们对上边这几个方法详细介绍
1.3.1 ReenTrantLock的内部类Sync的nonfairTryAcquire方法
?不是说该讲tryAcquire方法的吗,这是个什么鬼,大家可以通过侧边栏目录回到ReentrantLock NonfairSync内部类,tryAcquire方法就是内部类中的,这个方法里边只有一个方法,那就是调用NonfairSync类的父类Sync nonfairTryAcquire方法,话不多说,上代码(好吧,说的已经够多了,其实就是为了能尽可能详细一些,让大家少些困惑):
//acquires参数为加锁的重入层数,不过这里我们看成1就行了,而且上边调用该方法时传过来的也确实是1
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//state在这里就是当前锁的重入次数,如果为0说明没有线程占有锁
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
/*如果c也就是state不为0,那么说明已有线程占有该锁,我们判断占有锁的线程是不是当前线程,
如果是,表示该线程重入了锁(多次加锁),就把state加上acquires,更新重入次数*/
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;
}
1.3.2 AbstractQueuedSynchronizer的addWaiter方法
下边方法将没有获取到锁的当前线程(包含在下边Node中)加入到同步队列
1.3.2.1 AbstractQueuedSynchronizer的Node内部类
先介绍一下Node,Node是AbstractQueuedSynchronizer的内部类,我们这里主要了解以下几个变量,当然,这里可以跳过,后边源码用到了再回来看:
- int waitStatus:
节点状态字段,有以下几种状态:
* CANCELLED
值为1 。场景:当该线程等待超时或者被中断,需要从同步队列中取消等待,则该线程被置1,
即被取消(这里该线程在取消之前是等待状态)。节点进入了取消状态则不再变化;
* SIGNAL
值为-1。场景:后继的节点处于等待状态,当前节点的线程如果释放了同步状态或者被取消
(当前节点状态置为-1),将会通知后继节点,使后继节点的线程得以运行;
* CONDITION
值为-2。场景:节点处于等待队列中,节点线程等待在Condition上,当其他线程对Condition
调用了signal()方法后,该节点从等待队列中转移到同步队列中,加入到对同步状态的获取中;
* PROPAGATE
值为-3。场景:表示下一次的共享状态会被无条件的传播下去;
* INITIAL
值为0,节点初始状态。 - Node prev
* 前驱节点,当节点加入同步队列的时候被设置(尾部添加) - Node next
* 后继节点 - Node nextWaiter
* 等待节点的后继节点。如果当前节点是共享的,那么这个字段是一个SHARED常量,也就是说节点
类型(独占和共享)和等待队列中的后继节点共用一个字段。(注:比如说当前节点A是共享的,那
么它的这个字段是shared,也就是说在这个等待队列中,A节点的后继节点也是shared。如果A节点
不是共享的,那么它的nextWaiter就不是一个SHARED常量,即是独占的。 - Thread thread
* 获取同步状态的线程
1.3.2.2 addWaiter方法
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
//获取到当前队列的尾节点
Node pred = tail;
//如果尾节点不为空
if (pred != null) {
//将新加的节点的前驱节点设置为尾节点
node.prev = pred;
/*利用cas方式设置tail变量(AbstractQueuedSynchronizer中的变量)为新加入的节点(node),
保证tail一直是尾节点,仅当原值为pred时更新成功*/
if (compareAndSetTail(pred, node)) {
//将之前的尾节点的后继节点设置为新加入的节点,即新加入的节点成功放到之前尾节点的后边,成为新尾节点
pred.next = node;
return node;
}
}
/*下边这个方法其实已经包含了上边的内容,但为什么还要提出来放到上边呢,其实之所以加上
这部分“重复代码”和尝试获取锁时的“重复代码”一样,对某些特殊情况进行提前处理,牺牲一定的代码
可读性换取性能提升。*/
enq(node);
return node;
}
然后我们讲一下上边代码块中的enq()方法,这个方法同样是AbstractQueuedSynchronizer类的:
1.3.2.2.1 enq()方法
private Node enq(final Node node) {
/*下边这个for就相当于一个无限的循环,直到执行return,保证了所有获
取锁失败的线程经过失败重试后最后都能加入同步队列*/
for (;;) {
//t指向尾节点,队列为空则为空
Node t = tail;
if (t == null) { //如果队列为空 //1 (这些序号下边讨论问题使用)
/*CAS方式更新head指针,仅当原值为null时更新成功,这里只是更新为一个空的node节点,
并没有更新为我们传的新加入的节点,因为当前线程所在的结点不能直接插入空队列,阻塞
的线程是由前驱结点进行唤醒的。故先要插入一个结点作为队列首元素,当锁释放时由它来唤醒
后面被阻塞的线程,从逻辑上这个队列首元素也可以表示当前正获取锁的线程,虽然并不一定真实
持有其线程实例,然后队列不为空,再次for循环就可以执行下边代码块进行添加传入的新节点了*/
if (compareAndSetHead(new Node())) //2
tail = head; //3
} else {
//尾节点不为空的情况,下边逻辑和上边代码快一样,这里不再重复了
node.prev = t; //4
if (compareAndSetTail(t, node)) { //5
t.next = node; //6
return t;
}
}
}
}
整个入队的过程并不复杂,是典型的CAS加失败重试的乐观锁策略。其中只有更新头指针和更新尾指针这两步进行了CAS同步,可以预见高并发场景下性能是非常好的,但是本着质疑精神我们不禁会思考下这么做真的线程安全吗?
- 1.队列为空的情况:
因为队列为空,故head=tail=null,假设线程执行2成功,则在其执行3之前,因为tail=null,其他进入该方法的线程因为head不为null将在2处不停的失败,所以3即使没有同步也不会有线程安全问题。 - 2.队列不为空的情况:
假设线程执行5成功,则此时4的操作必然也是正确的(当前结点的prev指针确实指向了队列尾结点,换句话说tail指针没有改变,如若不然5必然执行失败),又因为4执行成功,当前节点在队列中的次序已经确定了,所以6何时执行对线程安全不会有任何影响,
如果4放5后边还会线程安全吗?
4放5的后边,如果刚执行完5之后,尾节点被其他线程改变了(也就是说其他线程执行完t.next = node;),而我们这里的尾节点变量还是原来那一个,原来尾节点后边已经有其他线程节点了,我们再插入原来尾节点后边,那么发生的错误可想而知
1.3.3 AbstractQueuedSynchronizer的acquireQueued方法
上边addWaiter方法讲完后,我们回顾目录1.3的代码结构,接下来该讲acquireQueued方法了
上代码:
/*这个方法的官方解释:以独占不间断模式获取队列中已存在的线程。用于条件等待方法以及获取。
简单理解来说就是一直等到该线程节点的前驱结点为头节点,并且获取到锁为止。
下边的node就是我们新加入队列的线程节点,arg是从最初lock方法调用传过来的参数,公平锁和
非公平锁传过来的都是1*/
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);
p.next = null; // help GC
failed = false;
return interrupted;
}
//确保前驱结点的状态为SIGNAL,然后阻塞当前线程。里边的方法下边做介绍
if (shouldParkAfterFailedAcquire(p, node) && //判断是否对当前线程进行阻塞
parkAndCheckInterrupt()) //阻塞当前线程,直到被前驱节点唤醒
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
上边死循环中有两个if,第一个if分句中,当前线程首先会判断前驱结点是否是头结点,如果是则尝试获取锁,获取锁成功则会设置当前结点为头结点(更新头指针)。为什么必须前驱结点为头结点才尝试去获取锁?因为头结点表示当前正占有锁的线程,正常情况下该线程释放锁后会通知后面结点中阻塞的线程,阻塞线程被唤醒后去获取锁,这是我们希望看到的。然而还有一种情况,就是前驱结点取消了等待,此时当前线程也会被唤醒,这时候就不应该去获取锁,而是往前回溯一直找到一个没有取消等待的结点,然后将自身连接在它后面。一旦我们成功获取了锁并成功将自身设置为头结点,就会跳出for循环。否则就会执行第二个if分句:确保前驱结点的状态为SIGNAL,然后阻塞当前线程。
而且上边代码for循环中有tryAcquire方法,一个好处就是如果在重新执行循环的时候成功获取了锁,就节省了线程阻塞唤醒的开销,也算是一种高并发场景下的优化
1.3.3.1 AbstractQueuedSynchronizer的shouldParkAfterFailedAcquire方法
下边几种节点的状态不懂得可以看上边1.3.2.1 AbstractQueuedSynchronizer的Node内部类
/*这个方法的目的就是确保当前结点的前驱结点的状态为SIGNAL,SIGNAL意味着线程释放锁后会唤醒
后面阻塞的线程。毕竟,只有确保能够被唤醒,当前线程才能放心的阻塞。*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
//如果前驱节点的状态为signal,返回true表示阻塞当前节点
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
//状态为CANCELLED,则一直往队列头部回溯直到找到一个状态不为CANCELLED的结点,将当前节点node挂在这个结点的后面。
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
//下边式子由后向前执行
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*pred的状态为初始化状态或PROPAGATE,此时通过compareAndSetWaitStatus(pred, ws, Node.SIGNAL)方法
将pred的状态改为SIGNAL。*/
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
也就是说上边只有在前驱结点已经是SIGNAL状态后才会执行后面的方法立即阻塞,对应上面的第一种情况。其他两种情况则保证执行后前驱节点是signal,返回false然后再重新执行1.3.3中的死循环。
1.3.3.2 AbstractQueuedSynchronizer的parkAndCheckInterrupt方法
由1.3.3可知当1.3.3.1执行完如果返回true那么就会执行下边方法,这个方法就是阻塞线程用的,阻塞当前线程,然后等待被前驱节点释放锁时唤醒,唤醒后会return true;否则继续无限循环
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
执行完1.3这个acquire方法,我们这个加锁过程就算完成了。
1.4 资源占用流程
2.解锁
2.1解锁入口
ReentrantLock类:
public void unlock() {
sync.release(1);//sync继承AbstractQueuedSynchronizer,所以调用下边代码块方法
}
AbstractQueuedSynchronizer类:
public final boolean release(int arg) {
if (tryRelease(arg)) { //释放锁(state-1),若释放后锁可被其他线程获取(state=0),返回true,2.1.1讲解
Node h = head;
if (h != null && h.waitStatus != 0)//头节点不为空,并且不为初始化状态0
unparkSuccessor(h); //唤醒队列中被阻塞的线程,2.1.2讲解
return true;
}
return false;
}
2.1.1 ReentrantLock sync内部类的tryRelease方法
这里是由上边父类进行调用的,所以仍然是钩子方法
protected final boolean tryRelease(int releases) {
int c = getState() - releases; //更新state值
if (Thread.currentThread() != getExclusiveOwnerThread()) //如果当前线程不是加锁线程,抛出异常
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true; //如果更新后state为0,表明未重入,表示可以被其他线程获取锁了
setExclusiveOwnerThread(null);//清空锁持有状态
}
setState(c); //更新state
return free;
}
2.1.2 AbstractQueuedSynchronizer的unparkSuccessor方法
/**
* Wakes up node's successor, if one exists.
*
* @param node the node
*/
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); //设置头节点为初始化状态
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) { //如果后继节点为空或者取消了等待(1)
s = null;
//从后向前进行遍历,一直找到离头节点最近,为取消等待的线程节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread); //唤醒没有取消等待的后继节点线程
}
到此线程已经成功解锁。
3.公平锁与非公平锁的差异
主要差异我们在上文已经提到过,在拿过来看一下吧:
- 公平锁能保证:老的线程排队使用锁,新线程仍然排队使用锁。
- 非公平锁保证:老的线程排队使用锁;但是无法保证新线程抢占已经在排队的线程的锁。
就是公平锁一定要排队,所有线程获取锁前必须加入同步队列,然后按照队列顺序去获取锁
这里需要补充的是在下边这种情况下会出现不公平现象:
线程A已经释放锁,但还没来得及唤醒后继线程C,而这时另一个线程B刚好尝试获取锁,此时锁恰好不被任何线程持有,它将成功获取锁而不用加入队列等待。线程C被唤醒尝试获取锁,而此时锁已经被线程B抢占,故而其获取失败并继续在队列中等待。
如果以线程第一次尝试获取锁到最后成功获取锁的次序来看,非公平锁确实很不公平。因为在队列中等待很久的线程相比还未进入队列等待的线程并没有优先权,甚至竞争也处于劣势:在队列中的线程要等待其他线程唤醒,在获取锁之前还要检查前驱结点是否为头结点。在锁竞争激烈的情况下,在队列中等待的线程可能迟迟竞争不到锁。这也就非公平在高并发情况下会出现的饥饿问题。那我们再开发中为什么大多使用会导致饥饿的非公平锁?很简单,因为它性能好啊。
然后我们看一下代码上的差异,可以看出公平比非公平多了右边方框中的方法,即在获取锁前加了一个判断,由于!符号,所以这个方法返回false才会执行获取锁:
我们看一下右边方法中的主要内容:
由于该方法在上边判断中前面加了一个!符号,所以我们可以认为
h == t || ((s = h.next) != null && s.thread == Thread.currentThread());
满足上边条件就会去获取锁
由于是公平排队获取锁,并且第一个节点是正在占有锁的线程,所以我们只需要判断第二个节点是否存在,并且第二个界定啊是否为当前节点就行了
h==t说明没有第二个节点,那么返回真,可以获取锁,如果h!=t,我们继续忘后边看,
((s = h.next) != null && s.thread == Thread.currentThread());
第二节点不为空,并且第二节点就是当前线程,那么可以获取锁
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
这是有了上边这个方法,严格限制了获取锁的条件,所以公平锁才能够有序执行
3.1非公平锁性能更好
非公平锁对锁的竞争是抢占式的(队列中线程除外),线程在进入等待队列前可以进行两次尝试,这大大增加了获取锁的机会。这种好处体现在两个方面:
- 1.线程不必加入等待队列就可以获得锁,不仅免去了构造结点并加入队列的繁琐操作,同时也节省了线程阻塞唤醒的开销,线程阻塞和唤醒涉及到线程上下文的切换和操作系统的系统调用,是非常耗时的。在高并发情况下,如果线程持有锁的时间非常短,短到线程入队阻塞的过程超过线程持有并释放锁的时间开销,那么这种抢占式特性对并发性能的提升会更加明显。
- 2.减少CAS竞争。如果线程必须要加入阻塞队列才能获取锁,那入队时CAS竞争将变得异常激烈,CAS操作虽然不会导致失败线程挂起,但不断失败重试导致的对CPU的浪费也不能忽视。除此之外,加锁流程中至少有两处通过将某些特殊情况提前来减少CAS操作的竞争,增加并发情况下的性能。一处就是获取锁时将非重入的情况提前,如下图所示
另一处就是入队的操作,将同步队列非空的情况提前处理
这两部分的代码在之后的通用逻辑处理中都有,很显然属于重复代码,但因为避免了执行无意义的流程代码,比如for循环,获取同步状态等,高并发场景下也能减少CAS竞争失败的可能。