更多内容,前往个人博客

ReentrantLock 支持公平锁和非公平锁,可重入锁 ReentrantLock的底层是通过 AQS[链接]实现。

一、BAT 大厂的面试题


【1】什么是可重入,什么是可重入锁? 它用来解决什么问题?
【2】ReentrantLock 的核心是 AQS,那么它怎么来实现的,继承吗? 说说其类内部结构关系。
【3】ReentrantLock 是如何实现公平锁的?
【4】ReentrantLock 是如何实现非公平锁的?
【5】ReentrantLock 默认实现的是公平还是非公平锁?
【6】使用ReentrantLock 实现公平和非公平锁的示例?
【7】ReentrantLock Synchronized的对比?

下面通过伪代码,进行更加直观的比较:

 1 // **************************Synchronized的使用方式**************************
2 // 1.用于代码块
3 synchronized (this) {}
4 // 2.用于对象
5 synchronized (object) {}
6 // 3.用于方法
7 public synchronized void test () {}
8 // 4.可重入
9 for (int i = 0; i < 100; i++) {
10 synchronized (this) {}
11 }
12 // **************************ReentrantLock的使用方式**************************
13 public void test () throw Exception {
14 // 1.初始化选择公平锁、非公平锁
15 ReentrantLock lock = new ReentrantLock(true);
16 // 2.可用于代码块
17 lock.lock();
18 try {
19 try {
20 // 3.支持多种加锁方式,比较灵活; 具有可重入特性
21 if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
22 } finally {
23 // 4.手动释放锁
24 lock.unlock()
25 }
26 } finally {
27 lock.unlock();
28 }
29 }

二、ReentrantLock 源码分析


1、类的继承关系


ReentrantLock 实现了 Lock接口,Lock接口中定义了 lockunlock相关操作,并且还存在 newCondition方法,表示生成一个条件。

public class ReentrantLock implements Lock, java.io.Serializable

2、类的内部类


ReentrantLock 总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。

说明:ReentrantLock 类内部总共存在SyncNonfairSyncFairSync三个类,NonfairSync FairSync类继承自 Sync类,Sync类继承自 AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

AQS提供了大量用于自定义同步器实现的 Protected方法。自定义同步器实现的相关方法也只是为了通过修改 State字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(ReentrantLock需要实现的方法如下,并不是全部):

方法名 描述
protected boolean isHeldExclusively() 该线程是否正在独占资源。只有用到Condition才需要去实现它。
protected boolean tryAcquire(int arg) 独占方式。arg为获取锁的次数,尝试获取资源,成功则返回True,失败则返回False。
protected boolean tryRelease(int arg) 独占方式。arg为释放锁的次数,尝试释放资源,成功则返回True,失败则返回False。
protected int tryAcquireShared(int arg) 共享方式。arg为获取锁的次数,尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected boolean tryReleaseShared(int arg) 共享方式。arg为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回True,否则返回False。

一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。ReentrantLock是独占锁,所以实现了tryAcquire-tryRelease。以非公平锁为例,这里主要阐述一下非公平锁与AQS之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。

Sync 类的源码如下:

 1 abstract static class Sync extends AbstractQueuedSynchronizer {
2 // 序列号
3 private static final long serialVersionUID = -5179523762034025860L;
4
5 // 获取锁
6 abstract void lock();
7
8 // 非公平方式获取
9 final boolean nonfairTryAcquire(int acquires) {
10 // 当前线程
11 final Thread current = Thread.currentThread();
12 // 获取状态
13 int c = getState();
14 if (c == 0) { // 表示没有线程正在竞争该锁
15 if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用
16 // 设置当前线程独占
17 setExclusiveOwnerThread(current);
18 return true; // 成功
19 }
20 }
21 else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁
22 int nextc = c + acquires; // 增加重入次数
23 if (nextc < 0) // overflow
24 throw new Error("Maximum lock count exceeded");
25 // 设置状态
26 setState(nextc);
27 // 成功
28 return true;
29 }
30 // 失败
31 return false;
32 }
33
34 // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
35 protected final boolean tryRelease(int releases) {
36 int c = getState() - releases;
37 if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
38 throw new IllegalMonitorStateException(); // 抛出异常
39 // 释放标识
40 boolean free = false;
41 if (c == 0) {
42 free = true;
43 // 已经释放,清空独占
44 setExclusiveOwnerThread(null);
45 }
46 // 设置标识
47 setState(c);
48 return free;
49 }
50
51 // 判断资源是否被当前线程占有
52 protected final boolean isHeldExclusively() {
53 return getExclusiveOwnerThread() == Thread.currentThread();
54 }
55
56 // 新生一个条件
57 final ConditionObject newCondition() {
58 return new ConditionObject();
59 }
60
61 // 返回资源的占用线程
62 final Thread getOwner() {
63 return getState() == 0 ? null : getExclusiveOwnerThread();
64 }
65 // 返回状态
66 final int getHoldCount() {
67 return isHeldExclusively() ? getState() : 0;
68 }
69
70 // 资源是否被占用
71 final boolean isLocked() {
72 return getState() != 0;
73 }
74
75 // 自定义反序列化逻辑
76 private void readObject(java.io.ObjectInputStream s)
77 throws java.io.IOException, ClassNotFoundException {
78 s.defaultReadObject();
79 setState(0); // reset to unlocked state
80 }
81 }

NonfairSync 类继承了 Sync类,表示采用非公平策略获取锁,其实现了 Sync类中抽象的 lock方法,源码如下:从 lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。Acquire方法是 FairSync和 UnfairSync的父类 AQS中的核心方法。

 1 // 非公平锁
2 static final class NonfairSync extends Sync {
3 // 版本号
4 private static final long serialVersionUID = 7316153563782823691L;
5
6 // 获得锁
7 final void lock() {
8 /**
9 * 若通过CAS设置变量State(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占线程。
10 * 若通过CAS设置变量State(同步状态)失败,也就是获取锁失败,则进入Acquire方法进行后续处理。
11 */
12 if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
13 // 把当前线程设置独占了锁
14 setExclusiveOwnerThread(Thread.currentThread());
15 else // 锁已经被占用,或者set失败
16 // 以独占模式获取对象,忽略中断
17 acquire(1); //Acquire方法是FairSync和UnfairSync的父类AQS中的核心方法。
18 }
19
20 protected final boolean tryAcquire(int acquires) {
21 return nonfairTryAcquire(acquires);
22 }
23 }

FairSync 类也继承了 Sync类,表示采用公平策略获取锁,其实现了 Sync类中的抽象 lock方法,源码如下:

 1 // 公平锁
2 static final class FairSync extends Sync {
3 // 版本序列化
4 private static final long serialVersionUID = -3000897897090466540L;
5
6 final void lock() {
7 // 以独占模式获取对象,忽略中断
8 acquire(1);
9 }
10
11 // 尝试公平获取锁
12 protected final boolean tryAcquire(int acquires) {
13 // 获取当前线程
14 final Thread current = Thread.currentThread();
15 // 获取状态
16 int c = getState();
17 if (c == 0) { // 状态为0
18 if (!hasQueuedPredecessors() &&
19 compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
20 // 设置当前线程独占
21 setExclusiveOwnerThread(current);
22 return true;
23 }
24 }
25 else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
26 // 下一个状态
27 int nextc = c + acquires;
28 if (nextc < 0) // 超过了int的表示范围
29 throw new Error("Maximum lock count exceeded");
30 // 设置状态
31 setState(nextc);
32 return true;
33 }
34 return false;
35 }
36 }

跟踪 lock方法的源码可知,当资源空闲时,它总是会先判断 sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。其中,FairSync 类的 lock的方法调用如下,只给出了主要的方法。

可以看出只要资源被其他线程占用,该线程就会添加到 sync queue中的尾部,而不会先尝试获取资源。这也是和 Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

为了帮助大家理解 ReentrantLock和 AQS之间方法的交互过程,以非公平锁为例,我们将加锁和解锁的交互流程单独拎出来强调一下,以便于对后续内容的理解。

加锁:

  • 通过ReentrantLock的加锁方法Lock进行加锁操作。

  • 会调用到内部类Sync的Lock方法,由于Sync#lock是抽象方法,根据ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,本质上都会执行AQS的Acquire方法。

  • AQS的Acquire方法会执行tryAcquire方法,但是由于tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。

  • tryAcquire是获取锁逻辑,获取失败后,会执行框架 AQS的后续逻辑,跟ReentrantLock自定义同步器无关。

解锁:

  • 通过 ReentrantLock的解锁方法 Unlock进行解锁。

  • Unlock会调用内部类 Sync的 Release方法,该方法继承于AQS。

  • Release中会调用 tryRelease方法,tryRelease需要自定义同步器实现,tryRelease只在ReentrantLock中的Sync实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。

  • 释放成功后,所有处理由AQS框架完成,与自定义同步器无关。

通过上面的描述,大概可以总结出 ReentrantLock加锁解锁时 API层核心方法的映射关系。

3、类的属性


ReentrantLock 类的 sync非常重要,对ReentrantLock 类的操作大部分都直接转化为对 syncAQS类的操作。

1 public class ReentrantLock implements Lock, java.io.Serializable {
2 // 序列号
3 private static final long serialVersionUID = 7373984872572414699L;
4 // 同步队列
5 private final Sync sync;
6 }

4、类的构造函数


ReentrantLock 构造函数:默认是采用的非公平策略获取锁

1 public ReentrantLock() {
2 // 默认非公平策略
3 sync = new NonfairSync();
4 }

ReentrantLock(boolean) 构造函数:可以传递参数确定采用公平策略或者是非公平策略,参数为 true表示公平策略,否则,采用非公平策略。

1 public ReentrantLock(boolean fair) {
2 sync = fair ? new FairSync() : new NonfairSync();
3 }

5、核心函数分析


通过分析 ReentrantLock的源码,可知对其操作都转化为对 Sync对象的操作,由于 Sync继承了 AQS,所以基本上都可以转化为对 AQS的操作。如将 ReentrantLocklock函数转化为对 Synclock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到 Sync的不同子类。所以可知,在 ReentrantLock的背后,是 AQS对其服务提供了支持。下面还是通过例子来更进一步分析源码。

三、线程加入等待队列


加入队列的时机

当执行Acquire(1)时,会通过tryAcquire获取锁。在这种情况下,如果获取锁失败,就会调用 addWaiter加入到等待队列中去。

如何加入队列

获取锁失败后,会执行 addWaiter(Node.EXCLUSIVE)加入等待队列,具体实现方法如下:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private Node addWaiter(Node mode) {
4 Node node = new Node(Thread.currentThread(), mode);
5 // Try the fast path of enq; backup to full enq on failure
6 Node pred = tail;
7 if (pred != null) {
8 node.prev = pred;
9 if (compareAndSetTail(pred, node)) {
10 pred.next = node;
11 return node;
12 }
13 }
14 enq(node);
15 return node;
16 }
17 private final boolean compareAndSetTail(Node expect, Node update) {
18 return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
19 }

主要的流程如下:

  • 通过当前的线程和锁模式新建一个节点。

  • Pred指针指向尾节点Tail。

  • 将New中Node的Prev指针指向Pred。

  • 通过compareAndSetTail方法,完成尾节点的设置。这个方法主要是对 tailOffset和 Expect进行比较,如果 tailOffset的 Node和 Expect的 Node地址是相同的,那么设置 Tail的值为 Update的值。

     1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
    2
    3 static {
    4 try {
    5 stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
    6 headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
    7 tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
    8 waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("waitStatus"));
    9 nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("next"));
    10 } catch (Exception ex) {
    11 throw new Error(ex);
    12 }
    13 }

    从AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是 tail对应的偏移量,所以这个时候会将 new出来的 Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。

  • 如果 Pred指针是 Null(说明等待队列中没有元素),或者当前 Pred指针和 Tail指向的位置不同(说明被别的线程已经修改),就需要看一下 Enq的方法。
     1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
    2
    3 private Node enq(final Node node) {
    4 for (;;) {
    5 Node t = tail;
    6 if (t == null) { // Must initialize
    7 if (compareAndSetHead(new Node()))
    8 tail = head;
    9 } else {
    10 node.prev = t;
    11 if (compareAndSetTail(t, node)) {
    12 t.next = node;
    13 return t;
    14 }
    15 }
    16 }
    17 }

    如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。

总结一下,线程获取锁的时候,过程大体如下:

  1. 当没有线程获取到锁时,线程1获取锁成功。
  2. 线程2申请锁,但是锁被线程1占有。
  3. 如果再有线程要获取锁,依次在队列中往后排队即可。

回到上边的代码,hasQueuedPredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以争取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。

 1 // java.util.concurrent.locks.ReentrantLock
2
3 public final boolean hasQueuedPredecessors() {
4 // The correctness of this depends on head being initialized
5 // before tail and on head.next being accurate if the current
6 // thread is first in queue.
7 Node t = tail; // Read fields in reverse initialization order
8 Node h = head;
9 Node s;
10 return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
11 }

看到这里,我们理解一下h != t && ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?

双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当h != t时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了Tail指向Head,没有将Head指向Tail,此时队列中有元素,需要返回True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer#enq
2
3 if (t == null) { // Must initialize
4 if (compareAndSetHead(new Node()))
5 tail = head;
6 } else {
7 node.prev = t;
8 if (compareAndSetTail(t, node)) {
9 t.next = node;
10 return t;
11 }
12 }

节点入队不是原子操作,所以会出现短暂的head != tail,此时Tail指向最后一个节点,而且Tail指向Head。如果Head没有指向Tail(可见5、6、7行),这种情况下也需要将相关线程加入队列中。所以这块代码是为了解决极端情况下的并发问题。

等待队列中线程出队列时机

回到最初的源码:

1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 public final void acquire(int arg) {
4 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
5 selfInterrupt();
6 }

上文解释了addWaiter方法,这个方法其实就是把对应的线程以 Node的数据结构形式加入到双端队列里,返回的是一个包含该线程的Node。而这个 Node会作为参数,进入到 acquireQueued方法中。acquireQueued方法可以对排队中的线程进行“获锁”操作。总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。

下面我们从“何时出队列?”和“如何出队列?”两个方向来分析一下acquireQueued源码:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 final boolean acquireQueued(final Node node, int arg) {
4 // 标记是否成功拿到资源
5 boolean failed = true;
6 try {
7 // 标记等待过程中是否中断过
8 boolean interrupted = false;
9 // 开始自旋,要么获取锁,要么中断
10 for (;;) {
11 // 获取当前节点的前驱节点
12 final Node p = node.predecessor();
13 // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
14 if (p == head && tryAcquire(arg)) {
15 // 获取锁成功,头指针移动到当前node
16 setHead(node);
17 p.next = null; // help GC
18 failed = false;
19 return interrupted;
20 }
21 // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析
22 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
23 interrupted = true;
24 }
25 } finally {
26 if (failed)
27 cancelAcquire(node);
28 }
29 }

注:setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private void setHead(Node node) {
4 head = node;
5 node.thread = null;
6 node.prev = null;
7 }
8
9 // java.util.concurrent.locks.AbstractQueuedSynchronizer
10
11 // 靠前驱节点判断当前线程是否应该被阻塞
12 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
13 // 获取头结点的节点状态
14 int ws = pred.waitStatus;
15 // 说明头结点处于唤醒状态
16 if (ws == Node.SIGNAL)
17 return true;
18 // 通过枚举值我们知道waitStatus>0是取消状态
19 if (ws > 0) {
20 do {
21 // 循环向前查找取消节点,把取消节点从队列中剔除
22 node.prev = pred = pred.prev;
23 } while (pred.waitStatus > 0);
24 pred.next = node;
25 } else {
26 // 设置前任节点等待状态为SIGNAL
27 compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
28 }
29 return false;
30 }

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private final boolean parkAndCheckInterrupt() {
4 LockSupport.park(this);
5 return Thread.interrupted();
6 }

上述方法的流程图如下:

从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致CPU资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire流程):

从队列中释放节点的疑虑打消了,那么又有新问题了:

  • shouldParkAfterFailedAcquire中取消节点是怎么生成的呢?什么时候会把一个节点的waitStatus设置为-1?

  • 是在什么时间释放节点通知到被挂起的线程呢?

四、CANCELLED状态节点生成


acquireQueued方法中的 Finally代码:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 final boolean acquireQueued(final Node node, int arg) {
4 boolean failed = true;
5 try {
6 ...
7 for (;;) {
8 final Node p = node.predecessor();
9 if (p == head && tryAcquire(arg)) {
10 ...
11 failed = false;
12 ...
13 }
14 ...
15 } finally {
16 if (failed)
17 cancelAcquire(node);
18 }
19 }

通过cancelAcquire方法,将Node的状态标记为CANCELLED。接下来,我们逐行来分析这个方法的原理:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private void cancelAcquire(Node node) {
4 // 将无效节点过滤
5 if (node == null)
6 return;
7 // 设置该节点不关联任何线程,也就是虚节点
8 node.thread = null;
9 Node pred = node.prev;
10 // 通过前驱节点,跳过取消状态的node
11 while (pred.waitStatus > 0)
12 node.prev = pred = pred.prev;
13 // 获取过滤后的前驱节点的后继节点
14 Node predNext = pred.next;
15 // 把当前node的状态设置为CANCELLED
16 node.waitStatus = Node.CANCELLED;
17 // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点
18 // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null
19 if (node == tail && compareAndSetTail(node, pred)) {
20 compareAndSetNext(pred, predNext, null);
21 } else {
22 int ws;
23 // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功
24 // 如果1和2中有一个为true,再判断当前节点的线程是否为null
25 // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点
26 if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
27 Node next = node.next;
28 if (next != null && next.waitStatus <= 0)
29 compareAndSetNext(pred, predNext, next);
30 } else {
31 // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点
32 unparkSuccessor(node);
33 }
34 node.next = node; // help GC
35 }
36 }

当前的流程:

  • 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。

  • 根据当前节点的位置,考虑以下三种情况:

(1) 当前节点是尾节点。

(2) 当前节点是Head的后继节点。

(3) 当前节点不是Head的后继节点,也不是尾节点。

根据上述第二条,我们来分析每一种情况的流程。

当前节点是尾节点。

当前节点是Head的后继节点。

当前节点不是Head的后继节点,也不是尾节点。

通过上面的流程,我们对于CANCELLED节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对Next指针进行了操作,而没有对Prev指针进行操作呢?什么情况下会对Prev指针进行操作?

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。 shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);

五、如何解锁


我们已经剖析了加锁过程中的基本流程,接下来再对解锁的基本流程进行分析。由于 ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:

1 // java.util.concurrent.locks.ReentrantLock
2
3 public void unlock() {
4 sync.release(1);
5 }

可以看到,本质释放锁的地方,是通过框架来完成的。

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 public final boolean release(int arg) {
4 if (tryRelease(arg)) {
5 Node h = head;
6 if (h != null && h.waitStatus != 0)
7 unparkSuccessor(h);
8 return true;
9 }
10 return false;
11 }

在ReentrantLock里面的公平锁和非公平锁的父类Sync定义了可重入锁的释放锁机制。

 1 // java.util.concurrent.locks.ReentrantLock.Sync
2
3 // 方法返回当前锁是不是没有被线程持有
4 protected final boolean tryRelease(int releases) {
5 // 减少可重入次数
6 int c = getState() - releases;
7 // 当前线程不是持有锁的线程,抛出异常
8 if (Thread.currentThread() != getExclusiveOwnerThread())
9 throw new IllegalMonitorStateException();
10 boolean free = false;
11 // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state
12 if (c == 0) {
13 free = true;
14 setExclusiveOwnerThread(null);
15 }
16 setState(c);
17 return free;
18 }

我们来解释下述源码:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 public final boolean release(int arg) {
4 // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有
5 if (tryRelease(arg)) {
6 // 获取头结点
7 Node h = head;
8 // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
9 if (h != null && h.waitStatus != 0)
10 unparkSuccessor(h);
11 return true;
12 }
13 return false;
14 }

这里的判断条件为什么是h != null && h.waitStatus != 0?

h == null Head还没初始化。初始情况下,head == null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。

h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。

h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。

再看一下 unparkSuccessor方法:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private void unparkSuccessor(Node node) {
4 // 获取头结点waitStatus
5 int ws = node.waitStatus;
6 if (ws < 0)
7 compareAndSetWaitStatus(node, ws, 0);
8 // 获取当前节点的下一个节点
9 Node s = node.next;
10 // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点
11 if (s == null || s.waitStatus > 0) {
12 s = null;
13 // 就从尾部节点开始找,到队首,找到队列第一个waitStatus<0的节点。
14 for (Node t = tail; t != null && t != node; t = t.prev)
15 if (t.waitStatus <= 0)
16 s = t;
17 }
18 // 如果当前节点的下个节点不为空,而且状态<=0,就把当前节点unpark
19 if (s != null)
20 LockSupport.unpark(s.thread);
21 }

为什么要从后往前找第一个非Cancelled的节点呢?原因如下。

之前的 addWaiter方法:

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private Node addWaiter(Node mode) {
4 Node node = new Node(Thread.currentThread(), mode);
5 // Try the fast path of enq; backup to full enq on failure
6 Node pred = tail;
7 if (pred != null) {
8 node.prev = pred;
9 if (compareAndSetTail(pred, node)) {
10 pred.next = node;
11 return node;
12 }
13 }
14 enq(node);
15 return node;
16 }

我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作Tail入队的原子操作,但是此时pred.next = node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node。

综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和CANCELLED节点产生过程中断开Next指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行acquireQueued方法以后,中断如何处理?

六、中断恢复后的执行流程


唤醒后,会执行 return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。

1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 private final boolean parkAndCheckInterrupt() {
4 LockSupport.park(this);
5 return Thread.interrupted();
6 }

再回到 acquireQueued代码,当 parkAndCheckInterrupt返回True或者False的时候,interrupted的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前 interrupted返回。

 1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 final boolean acquireQueued(final Node node, int arg) {
4 boolean failed = true;
5 try {
6 boolean interrupted = false;
7 for (;;) {
8 final Node p = node.predecessor();
9 if (p == head && tryAcquire(arg)) {
10 setHead(node);
11 p.next = null; // help GC
12 failed = false;
13 return interrupted;
14 }
15 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
16 interrupted = true;
17 }
18 } finally {
19 if (failed)
20 cancelAcquire(node);
21 }
22 }

如果acquireQueued为True,就会执行selfInterrupt方法。

1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2
3 static void selfInterrupt() {
4 Thread.currentThread().interrupt();
5 }

该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于Java提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:

  1. 当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为False),并记录下来,如果发现该线程被中断过,就再中断一次。

  2. 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。

这里的处理方式主要是运用线程池中基本运作单元Worder中的runWorker,通过Thread.interrupted()进行额外的判断处理,感兴趣的同学可以看下ThreadPoolExecutor源码。

七、小结


我们在1.3小节中提出了一些问题,现在来回答一下。

Q:某个线程获取锁失败的后续流程是什么呢?

A:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。

Q:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

A:是CLH变体的FIFO双端队列。

Q:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?

A:可以详细看下2.3.1.3小节。

Q:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?

A:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见2.3.2小节。

Q:Lock函数通过Acquire方法进行加锁,但是具体是如何加锁的呢?

A:AQS的Acquire会调用tryAcquire方法,tryAcquire由各个自定义同步器实现,通过tryAcquire完成加锁过程。

八、AQS应用


ReentrantLock的可重入应用:ReentrantLock的可重入性是 AQS很好的应用之一,在了解完上述知识点以后,我们很容易得知ReentrantLock实现可重入的方法。在ReentrantLock里面,不管是公平锁还是非公平锁,都有一段逻辑。

公平锁:

 1 // java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire
2
3 if (c == 0) {
4 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
5 setExclusiveOwnerThread(current);
6 return true;
7 }
8 }
9 else if (current == getExclusiveOwnerThread()) {
10 int nextc = c + acquires;
11 if (nextc < 0)
12 throw new Error("Maximum lock count exceeded");
13 setState(nextc);
14 return true;
15 }

非公平锁:

 1 // java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire
2
3 if (c == 0) {
4 if (compareAndSetState(0, acquires)){
5 setExclusiveOwnerThread(current);
6 return true;
7 }
8 }
9 else if (current == getExclusiveOwnerThread()) {
10 int nextc = c + acquires;
11 if (nextc < 0) // overflow
12 throw new Error("Maximum lock count exceeded");
13 setState(nextc);
14 return true;
15 }

从上面这两段都可以看到,有一个同步状态State来控制整体可重入的情况。State是Volatile修饰的,用于保证一定的可见性和有序性。

1 // java.util.concurrent.locks.AbstractQueuedSynchronizer
2 private volatile int state;

接下来看State这个字段主要的过程:

  1. State初始化的时候为0,表示没有任何线程持有锁。

  2. 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。

  3. 解锁也是对这个字段-1,一直到0,此线程对锁释放。

JUC中的应用场景:除了上边ReentrantLock的可重入性的应用,AQS作为并发编程的框架,为很多其他同步工具提供了良好的解决方案。下面列出了JUC中的几种同步工具,大体介绍一下AQS的应用场景:

同步工具 同步工具与AQS的关联
ReentrantLock 使用AQS保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。
Semaphore 使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。
CountDownLatch 使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。
ReentrantReadWriteLock 使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。
ThreadPoolExecutor Worker利用AQS同步状态实现对独占线程变量的设置(tryAcquire和tryRelease)。

九、自定义同步工具


了解 AQS基本原理以后,按照上面所说的 AQS知识点,自己实现一个同步工具。

 1 public class LeeLock  {
2
3 private static class Sync extends AbstractQueuedSynchronizer {
4 @Override
5 protected boolean tryAcquire (int arg) {
6 return compareAndSetState(0, 1);
7 }
8
9 @Override
10 protected boolean tryRelease (int arg) {
11 setState(0);
12 return true;
13 }
14
15 @Override
16 protected boolean isHeldExclusively () {
17 return getState() == 1;
18 }
19 }
20
21 private Sync sync = new Sync();
22
23 public void lock () {
24 sync.acquire(1);
25 }
26
27 public void unlock () {
28 sync.release(1);
29 }
30 }

通过我们自己定义的Lock完成一定的同步功能。

 1 public class LeeMain {
2
3 static int count = 0;
4 static LeeLock leeLock = new LeeLock();
5
6 public static void main (String[] args) throws InterruptedException {
7
8 Runnable runnable = new Runnable() {
9 @Override
10 public void run () {
11 try {
12 leeLock.lock();
13 for (int i = 0; i < 10000; i++) {
14 count++;
15 }
16 } catch (Exception e) {
17 e.printStackTrace();
18 } finally {
19 leeLock.unlock();
20 }
21
22 }
23 };
24 Thread thread1 = new Thread(runnable);
25 Thread thread2 = new Thread(runnable);
26 thread1.start();
27 thread2.start();
28 thread1.join();
29 thread2.join();
30 System.out.println(count);
31 }
32 }

上述代码每次运行结果都会是20000。通过简单的几行代码就能实现同步功能,这就是AQS的强大之处。

十、总结


我们日常开发中使用并发的场景太多,但是对并发内部的基本框架原理了解的人却不多。由于篇幅原因,本文仅介绍了可重入锁ReentrantLock的原理和AQS原理,希望能够成为大家了解AQS和ReentrantLock等同步器的“敲门砖”。

ReentrantLock 锁详解的更多相关文章

  1. java并发编程 | 锁详解:AQS,Lock,ReentrantLock,ReentrantReadWriteLock

    原文:java并发编程 | 锁详解:AQS,Lock,ReentrantLock,ReentrantReadWriteLock 锁 锁是用来控制多个线程访问共享资源的方式,java中可以使用synch ...

  2. “全栈2019”Java多线程第二十九章:可重入锁与不可重入锁详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  3. “全栈2019”Java多线程第二十八章:公平锁与非公平锁详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  4. “全栈2019”Java多线程第十七章:同步锁详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  5. 为什么说JAVA中要慎重使用继承 C# 语言历史版本特性(C# 1.0到C# 8.0汇总) SQL Server事务 事务日志 SQL Server 锁详解 软件架构之 23种设计模式 Oracle与Sqlserver:Order by NULL值介绍 asp.net MVC漏油配置总结

    为什么说JAVA中要慎重使用继承   这篇文章的主题并非鼓励不使用继承,而是仅从使用继承带来的问题出发,讨论继承机制不太好的地方,从而在使用时慎重选择,避开可能遇到的坑. JAVA中使用到继承就会有两 ...

  6. java的ReentrantLock类详解

    ReentrantLock 能用于更精细化的加锁的Java类, 通过它能更清楚了解Java的锁机制 ReentrantLock 类的集成关系有点复杂, 既有内部类, 还有多重继承关系 类的定义 pub ...

  7. synchronized锁详解

    synchronized的意义 解决了Java共享内存模型带来的线程安全问题: 如:两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?(针对这个问题进行分析 ...

  8. 进程、线程与GIL全局解释器锁详解

    进程与线程的关系: . 线程是最小的调度单位 . 进程是最小的管理单元 . 一个进程必须至少一个线程 . 没有线程,进程也就不复存在 线程特点: 线程的并发是利用cpu上下文的切换(是并发,不是并行) ...

  9. 《Java基础知识》Java锁详解(volatile,synchronized等)

    volatile: 让变量每次在使用的时候,都从主存中取. volatile具有synchronized关键字的“可见性”,但是没有synchronized关键字的“并发正确性”,也就是说不保证线程执 ...

  10. ReentrantLock重入锁详解

    1.定义 重入锁:能够支持一个线程对资源的重复加锁,也就是当一个线程获取到锁后,再次获取该锁时而不会被阻塞. 2.可重入锁的应用场景 2.1 如果已经加锁,则不再重复加锁,比如:交互界面点击后响应时间 ...

随机推荐

  1. 简介Hadoop

    Hadoop 简介 Hadoop 是什么 Hadoop 是一个提供分布式存储和计算的开源软件框架,它具有无共享.高可用(HA).弹性可扩展的特点,非常适合处理海量数量. Hadoop 是一个开源软件框 ...

  2. 我的编程之路刷题⑦:Problem 2719.--约瑟夫问题

    2719: 约瑟夫问题   时间限制 : 1.000 sec  内存限制 : 128 MB 题目描述 有M个人,其编号分别为1-M.这M个人按顺序排成一个圈.现在给定一个数N,从第一个人开始依次报数, ...

  3. Luogu P3374 【模板】树状数组 1 [单点修改-区间查询]

    P3374 [模板]树状数组 1 题目描述 如题,已知一个数列,你需要进行下面两种操作: 1.将某一个数加上x 2.求出某区间每一个数的和 输入输出格式 输入格式: 第一行包含两个整数N.M,分别表示 ...

  4. jp@gc - PerfMon Metrics Collector:服务器性能监测控件

    1.下载客户端及服务器端插件: 参考如下地址:https://blog.csdn.net/qq_36643889/article/details/119142106 JMeterPlugins-Sta ...

  5. mysql允许root用户在任何地方进行远程登录,并具有所有库任何操作权限

    在本机先使用root用户登录mysql: mysql -u root -p"youpass" 进行授权操作: mysql>GRANT ALL PRIVILEGES ON *. ...

  6. InnoDB的全文检索

    InnoDB的全文检索 注:全文为MySQL官网5.7的文档(MySQL 8.0的文档与此几乎一致) MySQL 5.6 不支持中.日.韩语,因为无法对其分词,5.7版本引入NGram(基于字符)对中 ...

  7. Promise顺序执行请求;Promise一个请求执行完成再执行另一个请求

    1 函数名() { 2 new Promise(resolve => { 3 this.$rest.请求名1().then(res =>{ 4 // 请求成功后改变状态 5 resolve ...

  8. Oracle账户被锁住,解锁

    转载自:https://blog.csdn.net/weixin_43464743/article/details/121226334 方法一PLSQL解锁1.用dba用户登录plsql.2.左侧选择 ...

  9. AJAX请求的基本操作

    1 const { request, response } = require('express'); 2 //引入express 3 const express = require('express ...

  10. django静态文件,form表单提交数据,连接mysql,操作ORM

    静态文件配置 1. 什么是静态文件 css js img ... 2. 静态文件的存储位置 ''' 我们在创建Django项目的时候,没有生成static目录,我们会把静态文件存储在static目录下 ...