ReentrantLock 源码分析从入门到入土

回答一个问题

在开始本篇文章的内容讲述前,先来回答我一个问题,为何 JDK 提供一个 synchronized 关键字以后还要提供一个 Lock 锁,这不是画蛇添足吗?难道 JDK 设计人员都是沙雕吗?html

我听过一句话很是的经典,也是我认为是每一个人都应该了解的一句话:你觉得的并非你觉得的。明白什么意思么?不明白的话,加我微信我告诉你。java

初识 ReentrantLock

ReentrantLock 位于 java.util.concurrent.locks 包下,它实现了 Lock 接口和 Serializable 接口。面试

image.png

ReentrantLock 是一把可重入锁互斥锁,它具备与 synchronized 关键字相同的含有隐式监视器锁(monitor)的基本行为和语义,可是它比 synchronized 具备更多的方法和功能。微信

ReentrantLock 基本方法

构造方法

ReentrantLock 类中带有两个构造函数,一个是默认的构造函数,不带任何参数;一个是带有 fair 参数的构造函数数据结构

public ReentrantLock() {
  sync = new NonfairSync();
}

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

第二个构造函数也是判断 ReentrantLock 是不是公平锁的条件,若是 fair 为 true,则会建立一个公平锁的实现,也就是 new FairSync(),若是 fair 为 false,则会建立一个 非公平锁的实现,也就是 new NonfairSync(),默认的状况下建立的是非公平锁多线程

// 建立的是公平锁
private ReentrantLock lock = new ReentrantLock(true);

// 建立的是非公平锁
private ReentrantLock lock = new ReentrantLock(false);

// 默认建立非公平锁
private ReentrantLock lock = new ReentrantLock();

FairSync 和 NonfairSync 都是 ReentrantLock 的内部类,继承于 Sync 类,下面来看一下它们的继承结构,便于梳理。并发

image.png

abstract static class Sync extends AbstractQueuedSynchronizer {...}

static final class FairSync extends Sync {...}
  
static final class NonfairSync extends Sync {...}

在多线程尝试加锁时,若是是公平锁,那么锁获取的机会是相同的。不然,若是是非公平锁,那么 ReentrantLock 则不会保证每一个锁的访问顺序函数

下面是一个公平锁的实现工具

public class MyFairLock extends Thread{

    private ReentrantLock lock = new ReentrantLock(true);
    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();
        }
    }
}

不信?不信你输出试试啊!懒得输出?就知道你懒得输出,因此直接告诉你结论吧,结论就是本身试源码分析

试完了吗?试完了我是不会让你休息的,过来再试一下非公平锁的测试和结论,知道怎么试吗?上面不是讲过要给 ReentrantLock 传递一个参数的吗?你想,传 true 的时候是公平锁,那么反过来不就是非公平锁了?其余代码还用改吗?不须要了啊。

明白了吧,再来测试一下非公平锁的流程,看看是否是你想要的结果。

公平锁的加锁(lock)流程详解

一般状况下,使用多线程访问公平锁的效率会很是低(一般状况下会慢不少),可是 ReentrantLock 会保证每一个线程都会公平的持有锁,线程饥饿的次数比较小。锁的公平性并不能保证线程调度的公平性。

此时若是你想了解更多的话,那么我就从源码的角度跟你聊聊如何 ReentrantLock 是如何实现这两种锁的。

image.png

如上图所示,公平锁的加锁流程要比非公平锁的加锁流程简单,下面要聊一下具体的流程了,请小伙伴们备好板凳。

下面先看一张流程图,这张图是 acquire 方法的三条主要流程

image.png

首先是第一条路线,tryAcquire 方法,顾名思义尝试获取,也就是说能够成功获取锁,也能够获取锁失败。

使用 ctrl+左键 点进去是调用 AQS 的方法,可是 ReentrantLock 实现了 AQS 接口,因此调用的是 ReentrantLock 的 tryAcquire 方法;

image.png

首先会取得当前线程,而后去读取当前锁的同步状态,还记得锁的四种状态吗?分别是 无锁、偏向锁、轻量级锁和重量级锁,若是你不是很明白的话,请参考博主这篇文章(不懂什么是锁?看看这篇你就明白了),若是判断同步状态是 0 的话,就证实是无锁的,参考下面这幅图( 1bit 表示的是是否偏向锁 )

image.png

若是是无锁(也就是没有加锁),说明是第一次上锁,首先会先判断一下队列中是否有比当前线程等待时间更长的线程(hasQueuedPredecessors);而后经过 CAS 方法原子性的更新锁的状态,CAS 方法更新的要求涉及三个变量,currentValue(当前线程的值),expectedValue(指望更新的值),updateValue(更新的值),它们的更新以下

if(currentValue == expectedValue){
  currentValue = updateValue
}

CAS 经过 C 底层机制保证原子性,这个你不须要考虑它。若是既没有排队的线程并且使用 CAS 方法成功的把 0 -> 1 (偏向锁),那么当前线程就会得到偏向锁,记录获取锁的线程为当前线程。

而后咱们看 else if 逻辑,若是读取的同步状态是1,说明已经线程获取到了锁,那么就先判断当前线程是否是获取锁的线程,若是是的话,记录一下获取锁的次数 + 1,也就是说,只有同步状态为 0 的时候是无锁状态。若是当前线程不是获取锁的线程,直接返回 false。

acquire 方法会先查看同步状态是否获取成功,若是成功则方法结束返回,也就是 !tryAcquire == false ,若失败则先调用 addWaiter 方法再调用 acquireQueued 方法

而后看一下第二条路线 addWaiter

image.png

这里首先把当前线程和 Node 的节点类型进行封装,Node 节点的类型有两种,EXCLUSIVESHARED ,前者为独占模式,后者为共享模式,具体的区别咱们会在 AQS 源码讨论,这里读者只须要知道便可。

首先会进行 tail 节点的判断,有没有尾节点,其实没有头节点也就至关于没有尾节点,若是有尾节点,就会原子性的将当前节点插入同步队列中,再执行 enq 入队操做,入队操做至关于原子性的把节点插入队列中。

若是当前同步队列尾节点为null,说明当前线程是第一个加入同步队列进行等待的线程。

在看第三条路线 acquireQueued

image.png

主要会有两个分支判断,首先会进行无限循环中,循环中每次都会判断给定当前节点的先驱节点,若是没有先驱节点会直接抛出空指针异常,直到返回 true。

而后判断给定节点的先驱节点是否是头节点,而且当前节点可否获取独占式锁,若是是头节点而且成功获取独占锁后,队列头指针用指向当前节点,而后释放前驱节点。若是没有获取到独占锁,就会进入 shouldParkAfterFailedAcquireparkAndCheckInterrupt 方法中,咱们贴出这两个方法的源码

image.png

shouldParkAfterFailedAcquire 方法主要逻辑是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL)使用CAS将节点状态由 INITIAL 设置成 SIGNAL,表示当前线程阻塞。当 compareAndSetWaitStatus 设置失败则说明 shouldParkAfterFailedAcquire 方法返回 false,而后会在 acquireQueued 方法中死循环中会继续重试,直至compareAndSetWaitStatus 设置节点状态位为 SIGNAL 时 shouldParkAfterFailedAcquire 返回 true 时才会执行方法 parkAndCheckInterrupt 方法。(这块在后面研究 AQS 会细讲)

parkAndCheckInterrupt 该方法的关键是会调用 LookSupport.park 方法(关于LookSupport会在之后的文章进行讨论),该方法是用来阻塞当前线程。

因此 acquireQueued 主要作了两件事情:若是当前节点的前驱节点是头节点,而且可以获取独占锁,那么当前线程可以得到锁该方法执行结束退出

若是获取锁失败的话,先将节点状态设置成 SIGNAL,而后调用 LookSupport.park 方法使得当前线程阻塞。

若是 !tryAcquireacquireQueued 都为 true 的话,则打断当前线程。

那么它们的主要流程以下(注:只是加锁流程,并非 lock 全部流程)

image.png

非公平锁的加锁(lock)流程详解

非公平锁的加锁步骤和公平锁的步骤只有两处不一样,一处是非公平锁在加锁前会直接使用 CAS 操做设置同步状态,若是设置成功,就会把当前线程设置为偏向锁的线程;一处是 CAS 操做失败执行 tryAcquire 方法,读取线程同步状态,若是未加锁会使用 CAS 再次进行加锁,不会等待 hasQueuedPredecessors 方法的执行,达到只要线程释放锁就会加锁的目的。下面经过源码和流程图来详细理解

image.png

这是非公平锁和公平锁不一样的两处地方,下面是非公平锁的加锁流程图

image.png

lockInterruptibly 以可中断的方式获取锁

如下是 JavaDoc 官方解释:

lockInterruptibly 的中文意思为若是没有被打断,则获取锁。若是没有其余线程持有该锁,则获取该锁并当即返回,将锁保持计数设置为1。若是当前线程已经持有锁,那么此方法会马上返回而且持有锁的数量会 + 1。若是锁是由另外一个线程持有的,则出于线程调度目的,当前线程将被禁用,并处于休眠状态,直到发生如下两种状况之一

  • 锁被当前线程持有
  • 一些其余线程打断了当前线程

若是当前线程获取了锁,则锁保持计数将设置为1。

若是当前线程发生了以下状况:

  • 在进入此方法时设置了其中断状态
  • 当获取锁的时候发生了中断(Thread.interrupt)

那么当前线程就会抛出InterruptedException 而且当前线程的中断状态会清除。

下面看一下它的源码是怎么写的

image.png

首先会调用 acquireInterruptibly 这个方法,判断当前线程是否被中断,若是中断抛出异常,没有中断则判断公平锁/非公平锁 是否已经获取锁,若是没有获取锁(tryAcquire 返回 false)则调用 doAcquireInterruptibly 方法,这个方法和 acquireQueued 方法没什么区别,就是线程在等待状态的过程当中,若是线程被中断,线程会抛出异常。

下面是它的流程图

image.png

tryLock 尝试加锁

仅仅当其余线程没有获取这把锁的时候获取这把锁,tryLock 的源代码和非公平锁的加锁流程基本一致,它的源代码以下

image.png

tryLock 超时获取锁

ReentrantLock除了能以中断的方式去获取锁,还能够以超时等待的方式去获取锁,所谓超时等待就是线程若是在超时时间内没有获取到锁,那么就会返回false,而不是一直死循环获取。可使用 tryLock 和 tryLock(timeout, unit)) 结合起来实现公平锁,像这样

if (lock.tryLock() || lock.tryLock(timeout, unit)) {...}

若是超过了指定时间,则返回值为 false。若是时间小于或者等于零,则该方法根本不会等待。

它的源码以下

image.png

首先须要了解一下 TimeUnit 工具类,TimeUnit 表示给定粒度单位的持续时间,而且提供了一些用于时分秒跨单位转换的方法,经过使用这些方法进行定时和延迟操做。

toNanos 用于把 long 型表示的时间转换成为纳秒,而后判断线程是否被打断,若是没有打断,则以公平锁/非公平锁 的方式获取锁,若是可以获取返回true,获取失败则调用doAcquireNanos方法使用超时等待的方式获取锁。在超时等待获取锁的过程当中,若是等待时间大于应等待时间,或者应等待时间设置不合理的话,返回 false。

image.png

这里面以超时的方式获取锁也能够画一张流程图以下

image.png

unlock 解锁流程

unlocklock 是一对情侣,它们分不开彼此,在调用 lock 后必须经过 unlock 进行解锁。若是当前线程持有锁,在调用 unlock 后,count 计数将减小。若是保持计数为0就会进行解锁。若是当前线程没有持有锁,在调用 unlock 会抛出 IllegalMonitorStateException 异常。下面是它的源码

image.png

在有了上面阅读源码的经历后,相信你会很快明白这段代码的意思,锁的释放不会区分公平锁仍是非公平锁,主要的判断逻辑就是 tryRelease 方法,getState 方法会取得同步锁的重入次数,若是是获取了偏向锁,那么可能会屡次获取,state 的值会大于 1,这时候 c 的值 > 0 ,返回 false,解锁失败。若是 state = 1,那么 c = 0,再判断当前线程是不是独占锁的线程,释放独占锁,返回 true,当 head 指向的头结点不为 null,而且该节点的状态值不为0的话才会执行 unparkSuccessor 方法,再进行锁的获取。

image.png

ReentrantLock 其余方法

isHeldByCurrentThread & getHoldCount

在多线程同时访问时,ReentrantLock 由最后一次成功锁定的线程拥有,当这把锁没有被其余线程拥有时,线程调用 lock() 方法会马上返回并成功获取锁。若是当前线程已经拥有锁,这个方法会马上返回。能够经过 isHeldByCurrentThread getHoldCount 来进行检查。

首先来看 isHeldByCurrentThread 方法

public boolean isHeldByCurrentThread() {
  return sync.isHeldExclusively();
}

根据方法名能够略知一二,是否被当前线程持有,它用来询问锁是否被其余线程拥有,这个方法和 Thread.holdsLock(Object) 方法内置的监视器锁相同,而 Thread.holdsLock(Object) 是 Thread 类的静态方法,是一个 native 类,它表示的意思是若是当前线程在某个对象上持有 monitor lock(监视器锁) 就会返回 true。这个类没有实际做用,仅仅用来测试和调试所用。例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.isHeldByCurrentThread();
}

这个方法也能够确保重入锁可以表现出不可重入的行为

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert !lock.isHeldByCurrentThread();
  lock.lock();
  try {
    // 执行业务代码
  }finally {
    lock.unlock();
  }
}

若是当前线程持有锁则 lock.isHeldByCurrentThread() 返回 true,不然返回 false。

咱们在了解它的用法后,看一下它内部是怎样实现的,它内部只是调用了一下 sync.isHeldExclusively(),sync 是 ReentrantLock 的一个静态内部类,基于 AQS 实现,而 AQS 它是一种抽象队列同步器,是许多并发实现类的基础,例如 ReentrantLock/Semaphore/CountDownLatch。sync.isHeldExclusively() 方法以下

protected final boolean isHeldExclusively() {
  return getExclusiveOwnerThread() == Thread.currentThread();
}

此方法会在拥有锁以前先去读一下状态,若是当前线程是锁的拥有者,则不须要检查。

getHoldCount()方法和isHeldByCurrentThread 都是用来检查线程是否持有锁的方法,不一样之处在于 getHoldCount() 用来查询当前线程持有锁的数量,对于每一个未经过解锁操做匹配的锁定操做,线程都会保持锁定状态,这个方法也一般用于调试和测试,例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.getHoldCount() == 0;
  lock.lock();
  try {
    // 执行业务代码
  }finally {
    lock.unlock();
  }
}

这个方法会返回当前线程持有锁的次数,若是当前线程没有持有锁,则返回0。

newCondition 建立 ConditionObject 对象

ReentrantLock 能够经过 newCondition 方法建立 ConditionObject 对象,而 ConditionObject 实现了 Condition 接口,关于 Condition 的用法咱们后面再讲。

isLocked 判断是否锁定

查询是否有任意线程已经获取锁,这个方法用来监视系统状态,而不是用来同步控制,很简单,直接判断 state 是否等于0。

isFair 判断是不是公平锁的实例

这个方法也比较简单,直接使用 instanceof 判断是否是 FairSync 内部类的实例

public final boolean isFair() {
  return sync instanceof FairSync;
}

getOwner 判断锁拥有者

判断同步状态是否为0,若是是0,则没有线程拥有锁,若是不是0,直接返回获取锁的线程。

final Thread getOwner() {
  return getState() == 0 ? null : getExclusiveOwnerThread();
}

hasQueuedThreads 是否有等待线程

判断是否有线程正在等待获取锁,若是头节点与尾节点不相等,说明有等待获取锁的线程。

public final boolean hasQueuedThreads() {
  return head != tail;
}

isQueued 判断线程是否排队

判断给定的线程是否正在排队,若是正在排队,返回 true。这个方法会遍历队列,若是找到匹配的线程,返回true

public final boolean isQueued(Thread thread) {
  if (thread == null)
    throw new NullPointerException();
  for (Node p = tail; p != null; p = p.prev)
    if (p.thread == thread)
      return true;
  return false;
}

getQueueLength 获取队列长度

此方法会返回一个队列长度的估计值,该值只是一个估计值,由于在此方法遍历内部数据结构时,线程数可能会动态变化。 此方法设计用于监视系统状态,而不用于同步控制。

public final int getQueueLength() {
  int n = 0;
  for (Node p = tail; p != null; p = p.prev) {
    if (p.thread != null)
      ++n;
  }
  return n;
}

getQueuedThreads 获取排队线程

返回一个包含可能正在等待获取此锁的线程的集合。 由于实际的线程集在构造此结果时可能会动态更改,因此返回的集合只是一个大概的列表集合。 返回的集合的元素没有特定的顺序。

public final Collection<Thread> getQueuedThreads() {
  ArrayList<Thread> list = new ArrayList<Thread>();
  for (Node p = tail; p != null; p = p.prev) {
    Thread t = p.thread;
    if (t != null)
      list.add(t);
  }
  return list;
}

回答上面那个问题

那么你看完源码分析后,你能总结出 synchronizedlock 锁的实现 ReentrantLock 有什么异同吗?

Synchronzied 和 Lock 的主要区别以下:

  • 存在层面:Syncronized 是Java 中的一个关键字,存在于 JVM 层面,Lock 是 Java 中的一个接口
  • 锁的释放条件:1. 获取锁的线程执行完同步代码后,自动释放;2. 线程发生异常时,JVM会让线程释放锁;Lock 必须在 finally 关键字中释放锁,否则容易形成线程死锁
  • 锁的获取: 在 Syncronized 中,假设线程 A 得到锁,B 线程等待。若是 A 发生阻塞,那么 B 会一直等待。在 Lock 中,会分状况而定,Lock 中有尝试获取锁的方法,若是尝试获取到锁,则不用一直等待
  • 锁的状态:Synchronized 没法判断锁的状态,Lock 则能够判断
  • 锁的类型:Synchronized 是可重入,不可中断,非公平锁;Lock 锁则是 可重入,可判断,可公平锁
  • 锁的性能:Synchronized 适用于少许同步的状况下,性能开销比较大。Lock 锁适用于大量同步阶段:

    Lock 锁能够提升多个线程进行读的效率(使用 readWriteLock)

  • 在竞争不是很激烈的状况下,Synchronized的性能要优于ReetrantLock,可是在资源竞争很激烈的状况下,Synchronized的性能会降低几十倍,可是ReetrantLock的性能能维持常态;
  • ReetrantLock 提供了多样化的同步,好比有时间限制的同步,能够被Interrupt的同步(synchronized的同步是不能Interrupt的)等

还有什么要说的吗

面试官可能还会问你 ReentrantLock 的加锁流程是怎样的,其实若是你能把源码给他讲出来的话,必定是高分。若是你记不住源码流程的话能够记住下面这个简化版的加锁流程

  • 若是 lock 加锁设置成功,设置当前线程为独占锁的线程;
  • 若是 lock 加锁设置失败,还会再尝试获取一次锁数量,

    若是锁数量为0,再基于 CAS 尝试将 state(锁数量)从0设置为1一次,若是设置成功,设置当前线程为独占锁的线程;

    若是锁数量不为0或者上边的尝试又失败了,查看当前线程是否是已是独占锁的线程了,若是是,则将当前的锁数量+1;若是不是,则将该线程封装在一个Node内,并加入到等待队列中去。等待被其前一个线程节点唤醒。

文章参考:

【试验局】ReentrantLock中非公平锁与公平锁的性能测试

第五章 ReentrantLock源码解析1--得到非公平锁与公平锁lock()

https://juejin.im/post/5c95df...

【JUC】JDK1.8源码分析之ReentrantLock(三)

https://www.lagou.com/lgeduar...

相关文章
相关标签/搜索