JDK系列—聊聊java中的Lock/AQS/ReentrantLock

194 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天

前言

上一篇《JDK系列—聊聊java中的那些琐》聊到了synchronized锁,本篇开始介绍Lock,比较synchronized和Lock的适用场景和实现原理都有何异同点,文章中的代码都是以java8版本

一、Lock和Synchronized的异同点

Synchronized和Lock区别.png

二、Lock家族类图

Lock类图.png

  • Lock: 获锁和释放锁的接口
  • Sync: 加锁和释放锁的底层都是通过Sync去实现,而Sync继承于AbstractQueuedSynchronizer,所以核心逻辑还是在AQS中
  • ReadWriteLock: 读写锁接口,提供了获取读锁和写锁连个接口,读写锁都是Lock的实现类
  • ReentrantLock: 可重入锁,Lock接口的实现类,提供阻塞和非阻塞两种方式获锁。 底层是通过Sync来实现具体加锁逻辑,Sync有FairSync和NonfairSync去实现公平和非公平的获锁方式
  • ReentrantReadWriteLock: 可重入的读写锁,实现ReadWriteLock接口,内部提供了读锁和写锁,读写和写锁底层也是基于AQS

三、Lock接口

从Java5开始,提供了Lock接口

  • 提供了阻塞和非阻塞两种加锁方式
  • 对同一把锁可以绑定多个条件等待对象Condition Lock接口描述.jpg

四、AbstractQueuedSynchronizer(AQS)

关于AQS在代码的注释上有详细的解释:

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state. Subclasses must define the protected methods that change this state, and which define what that state means in terms of this object being acquired or released.

简单来说:提供了一个构建阻塞锁和同步器(信号量、事件等)的框架,通过一个原子int类型的state变量和一个FIFO的队列去实现,它的子类必须重写AQS的方法去更改state的状态。

AQS重点成员变量

成员变量释义
state同步状态,代表是否是否获锁
head双向队列的头
tail双向队列的尾

成員變量.jpg

AQS重点成员方法

模板方法子类直接继承

获取排它锁方法描述
public final void acquire(int arg)阻塞获取排他锁
image.png
public final void acquireInterruptibly(int arg)可中断的获取排它锁
image.png
public final boolean tryAcquireNanos(int arg, long nanosTimeout)非阻塞获取排它锁
image.png

小结:上面三种获取排他锁的方法都是底层都是调用tryAcquire和acquireQueued的方法

  • acquire:先调用tryAcquire,成功就返回,失败就执行acquireQueued执行入队
  • acquireInterruptibly:先判断线程状态是否中断,中断向外抛异常,没中断执行tryAcquire获锁,成功就返回,不成功执行doAcquireInterruptibly入队等待获锁,支持中断获锁
  • tryAcquireNanos:先判断线程状态是否中断,如果中断直接向外抛异常,然后执行tryAcquire,如果获取成功立即返回,没成功再去调用doAcquireNanos带超时时间的获锁方法,如果指定时间能获取到就返回true,反之false
获取共享锁方法描述
public final void acquireShared(int arg)阻塞获取共享锁
image.png
public final void acquireSharedInterruptibly(int arg)可中断的获取共享锁
image.png
public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout)非阻塞获取共享锁
image.png

小结:上面三种获取共享锁的方法都是底层都是调用tryAcquireShard和acquire的5个参数(shard=true)的方法

子类需要自己实现

成员方法释义
protected boolean tryAcquire(int arg)阻塞获取排他锁,如果能立即获得锁,返回true, 不能就入入队等待其他线程唤醒
protected boolean tryRelease(int arg)释放排它锁
protected int tryAcquireShared(int arg)阻塞获取共享锁,返回负数代表获取失败,返回0代表此次许可获取成功,后续没有许可了,返回正数代表此次许可获取成功,还有剩余许可次数
protected boolean tryReleaseShared(int arg)释放共享锁
protected boolean isHeldExclusively()判断当前执行线程是否持有排他锁

AQS流程图

AQS.png

五、ReentrantLock

可重入锁,核心加锁和释放锁是基于AQS,下面我们从阻塞获锁代码入手,分析一下加锁和释放锁的逻辑

ReentrantLock.lock()

public void lock() {
    sync.lock();
}

Sync中lock方法是抽象的,有两个子类实现FairSync和NonFairSync

lock()

  • NonfairSync.lock 非公平 image.png
  • FairSync.lock 公平方式 image.png

小结:非公平锁相较于公平锁多了抢锁这一步,相同的都是acquire(1)这一步

acquire(1)

image.png

我们重点聊一下acquire(arg)其中有2步:

  1. tryAcquire(arg): 当前线程尝试获锁
  • 无锁情况: 公平锁会去先判断队列中是否有任务排队,非公平锁没有这个判断,会直接尝试获锁,成功返回true,失败返回false
  • 有锁情况: 公平和非公平锁的逻辑是一样的,都是判断持有锁的线程是否是当前线程,如果相同,则重入,不相同,则结束返回false
  1. 步骤1中返回false, 则尝试入队 acquireQueued(addWaiter(Node.EXCLUSIVE),arg) image.png
  • 2.1 addWaiter(Node.EXCLUSIVE)会依据获锁的线程和获锁模式(排它或共享)构建一个Node节点
  • 2.2 判断tail节点是否为空,如果不为空,通过cas接上原队列的队尾,,如果为空,则需要进行初始化队列 image.png
  • 2.3 依据入队的Node节点获取前置节点
  • 2.4.1 如果是头节点且tryAcquire获锁成功,将Node当前节点设置为头节点,返回false(当前线程不需要中断);
  • 2.4.2 不是头结点或获锁失败,调用shouldParkAfterFailedAcquire判断当前线程是否需要被block, 首先会判断一个节点的前置节点是否为Node.Signal,其中会将已经取消的任务节点给过滤掉
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        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 {
            /*
             * 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;
    }
  • 2.5 上一步返回true,执行parkAndCheckInterrupt,阻塞线程等待唤醒,唤醒时检查线程是否被interrupted
  1. 只有在tryAcquire失败+acquireQueued返回true(是否中断状态)的时候,selfInterrupt()会执行,这个是当前线程的中断标志,作用就是在线程在阻塞的是否,客户端通过调用了中断线程的方法 interrupt(),那么该线程被唤醒的时候,就会有相应的处理。具体要看这个线程 run 方法里面的代码逻辑

ReentrantLock.unlock()

public void unlock() {
    sync.release(1);
}
  1. 调用ReentrantLock.unlock(),会执行sync.release(1)方法,这个是AQS的模板方法
  2. release(1)会首先调用tryRelease(1) 3 如果2返回的是true,判断队列中有等待任务,进行唤醒,如果是false,返回的也是false

sync.release(int arg);

image.png

  1. release(1)会首先调用tryRelease(1),这个是在ReentrantLock类中实现
  • 2.1 c=state值(重入加锁的次数)-release(释放的次数)
  • 2.2 判断当前线程是不是持锁线程,不是就抛IllegalMonitorStateException
  • 2.3 如果c=0,代表锁释放完,清空持锁线程变量,返回true,否则返回false; image.png
  1. unparkSuccessor(Node node) 入参是head头结点
  • 3.1 判断头结点的waitStatus<0(代表有任务等待获锁),尝试将节点状态位置为0
  • 3.2 判断如果首个任务节点是为null或者是cancel的状态,从后往前找,则从后尾部往前遍历找到最前的一个处于正常阻塞状态的结点唤醒 image.png

why? 明明是先进先出的队列,为什么要从后往前找任务节点?

通过查找资料,定位到是和enq入队代码有关系

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

重点看当tail节点不为空的情况:

  1. 会将入队节点的prev指针指向tail
  2. 尝试将tail指针设置到新插入的node节点上
  3. 在第二部成功后,将之前的队尾节点的next指针指向新插入的节点

因为是双向队列,涉及到prev和next指针的修改,会先修改prev指针,其次执行cas将tail指针后移,这个是很关键的一步,成功才代表这个节点入队了,不成功会循环,直到成功加进去,换句话说,一旦节点入队成功,那么节点的prev指针指向是正确的,但是它的前面一个节点的next指针还在下一步t.next=node才修改成功,这三步操作并不是原子的

所以当前任务队列中是否还有获锁的任务节点时,从后往前找,只要有任务节点在队列中,prev指针一定能找到,反之则不一定。