ReentrantLock在线程池中应用背后的那些事(四)

c00ef69f44ea0d215cf004aaab86a713.png

一、ReentrantLock类图结构

a5610d3a317f6cd558ec87370d520d60.png

一、ReentrantLock类图结构

从类图结构上我们来对应下上篇文章提到的三要素 线程、状态、队列 分别处于什么位置。

1.1 线程

持有锁的线程会被存储在顶层抽象类AbstractOwnableSynchronizerexclusiveOwnerThread字段上,注意该字段是被transient修饰的,作用是在序列化和反序列化时不会带上该字段的具体信息(又串联了一个知识点)。

1.2 状态

锁状态信息被定义在抽象类AbstractQueuedSynchronizerstate字段上,注意该字段是被volatile修饰的,具体作用相信看过线程池那两篇文章的同学已经知道是干什么用的了,状态值修改后可以被其他线程及时感知到,即内存可见性。另外在Node类里面也有一个状态字段waitStatus,这个状态是用于线程被包装成node节点后,在等待队列中使用的,切记不要混淆了。

1.3 队列

没有成功获取锁的线程被包装成Node对象,Node是被定义在抽象类AbstractQueuedSynchronizer内的一个内部类,然后通过双向队列将等待的线程串联起来。对于条件队列使用的单向列表,我们后面有机会在展开介绍。

二、源码分析

2.1 ReentrantLock下的独占锁结构分析

public abstract class AbstractQueuedSynchronizer {
     // 等待队列的头结节点
     private transient volatile Node head;

     // 等待队列的尾节点
     private transient volatile Node tail;

     // 锁状态
     private volatile int state;

     // 等待抢锁的线程对应的node结构
     static final class Node {
        // 独占模式下的node节点
        static final Node EXCLUSIVE = null;

        // 独占模式下,只会用到这两个状态值,
        static final int CANCELLED =  1;
        static final int SIGNAL    = -1;
        // node节点的状态
        volatile int waitStatus;

        // 前置节点
        volatile Node prev;
        // 后置节点
        volatile Node next;

        // 等待抢锁的线程
        volatile Thread thread;
     }
}

除以上提到的信息,别的属性都可以先不关注,是不是一下觉得轻松多了,确实ReentrantLock也没想象的那么复杂。

2.2 非公平锁实现

ReentrantLock内部定义了抽象类Sync,并定义了抽象方法lock(),子类FairSyncNonfairSync分别实现了lock()方法来体现出来抢锁过程的公平与不公平,先来看创建ReentrantLock时,默认使用的非公平锁NonfairSync的抢锁过程。

2.2.1 加锁
final void lock() {
 // Step1: 通过一次CAS操作,尝试将锁状态从0设置为1,一旦设置成功,
 //        就将exclusiveOwnerThread设置为当前线程。
 //        这里是第一次体现出非公平的地方,因为如果等待队列中有已经等待的线程,
 //        应该优先让等待队列中的线程获取锁。
    if (compareAndSetState(0, 1))
     // Step2: 抢锁成功,将exclusiveOwnerThread设置为当前线程
     //        即当前线程获得了锁,接下来由该线程来执行业务逻辑
        setExclusiveOwnerThread(Thread.currentThread());
    else
    // Step3: 抢锁失败,通过tryAcquire()来继续后续的抢锁,
    //        如果抢锁失败,将当前线程包装成node扔到阻塞队列里去
        acquire(1);
}

前两步很容易理解就不再展开了,我们重点来看第三步acquire()内部是如何抢锁的。

public final void acquire(int arg) {
 // Step1: 尝试获取锁,如果锁获取成功,则直接返回,
 // Step2: 若锁获取失败,则创建一个独占模式的node,加入到队列中,
 // Step3: 从队列中获取一个node,执行抢锁,抢锁成功则返回,
 //        抢锁失败,则对当前线程执行中断操作
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // Step4: 执行中断操作
        selfInterrupt();
}

先来展开Step1的tryAcquire()抢锁逻辑,该方法最终调用了AQS内实现的nonfairTryAcquire()来实现非公平抢锁逻辑

final boolean nonfairTryAcquire(int acquires) {
 // Step1: 获取到当前线程和锁状态state
    final Thread current = Thread.currentThread();
    int c = getState();
    // Step2: 如果state为0,则代表当前锁没被占用,则通过CAS尝试获取状态,
    //        一旦成功就会将`exclusiveOwnerThread`设置为当前线程,
    //        即代表当前线程抢得了锁(这里是第二个地方体现出了抢锁的非公平性)
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // Step3: 如果state不为0,则认为当前有线程已经获得了锁,
    //        若当前获得锁的线程恰好是当前线程,则会对状态state值加1,
    //        这里也提现了ReentrantLock的另外一个特性,可重入性,
    //        即同一线程多次加锁,解锁逻辑也类似,会对状态state进行多次减1,
    //        直到state为0,则认为当前线程释放了锁,后续我们在研究锁释放流程
    //        的时候看看是不是这样做的
    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;
}

小结 以上就是线程在非公平锁实现下的抢锁逻辑,下面我们来看看如果线程抢锁失败,是如何将当前线程加入到队列中,对于加入到队列中的等待线程,如何从队列中唤醒重新抢锁。

2.2.2 抢锁失败,加入等待队列
private Node addWaiter(Node mode) {
 // Step1: 将当前线程包装成node对象,每个node的waitStatus属性初始都为0,
 //        并将该node的nextWaiter置为null,即nextWaiter不会被使用,
 //        该属性只有在共享模式下才会启用,这里忽略这个属性即可
    Node node = new Node(Thread.currentThread(), mode);
    
    // Step2: 获取队列的尾节点,如果队列不为空,则将新创建的node通过CAS方式完成尾插。
    //        这里有两个思考点:1:最开始时队列一定是空的,所以不会走到这里;
    //                       2:锁竞争非常激烈的时候,CAS可能会失败,但又没有使用死循环,
    //                          所以会走到enq()流程里面去
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // Step2.1: cas方式完成尾插,其实这里有个有意思的现象,就是尾分叉,具体见下面的图示
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // Step3: 通过CAS加死循环的方式完成node节点成功加入队列
    enq(node);
    // Step4: 返回当前线程对应的node
    return node;
}

// 这个方法有两个作用,
// 1: 初始化队列头结点,然后将node加入到头结点后面
// 2: 外层CAS失败后,在这里完成死循环+CAS不断重试直到成功加入到队尾
private Node enq(final Node node) {
 // 死循环
    for (;;) {
        Node t = tail;
        // Step2.1: 队列为空,创建一个空node节点,作为头结点
        //          第二次循环过来,会走else将业务node节点加入到尾部
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
         // Setp2.2: 通过CAS将node加入到尾节点
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                // Step2.3: 返回队尾的前一个节点
                return t;
            }
        }
    }
}

小结: 这段代码有两个设计有意思的点,我们来一起分析下

0c0a98a284903a351c34e2bb5ef3f54e.png

  1. 队列的头结点其实是个哑结点;即头结点是个空节点,实际不会存储等待锁的线程,这样做的目的是使链表操作标准化,如使链表永不为空、永不无头、简化插入和删除,大白话就是为了使链表的操作起来更方便。

  2. 尾分叉现象;在Step2.2操作过程中,先将新创建的node节点的前置节点设置为t,然后执行CAS将node节点设置为尾节点,一旦CAS成功,即成功将node节点设置为尾节点,则将原来的尾结点t的next指向新尾结点node。心细的同学可能会发现,列表尾插整个过程中并没有显示的用到锁(synchroized或者lock,仅用了乐观锁),那么在多个等待锁的线程同时进入到等待队列时,应该如何入队呢?如果对死循环加CAS熟悉的同学已经明白了,是的,同一时刻只有一个node会CAS成功,如果当前node在这一轮CAS失败后,会一直在死循环内自旋,直到CAS设置成功,即新建的node成功入队。这也解释了在addWaiter()进行的那一次CAS失败后,在enq()内通过自旋的方式让node成功加入到队尾。

2.2.3 从等待队列唤醒线程参与抢锁

当当前线程抢锁失败,并且成功加入到等待队列的队尾后,接着需要从等待队列中唤醒一个等待获取锁的node,然后参与抢锁,下面我们来看看是如何唤醒队列中的node参与抢锁的。

// 注意:这里的node代表是成功入队的node(不一定是队尾,因为别的线程
//      可能在这个时刻也成功加入这个node的后面了),从该node往前的节点一定在队列中
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
         // Step1: 从成功入队的node获取前置节点
            final Node p = node.predecessor();
            // Step2: 前置节点如果是头结点(即哑结点),说明当前队列中没有等待获取锁的线程,
            //        则尝试让当前线程再次获取锁
            if (p == head && tryAcquire(arg)) {
             // Step2.1: 当前线程终于成功抢到了锁,将新加入到队列中的node设为队列的头结点,
             //          node的thread属性设为null,node的prev设为null,队头结点的next设为null,
             //          即将node设为队头的哑结点,本身的哑结点直接丢弃,等待垃圾回收。
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // Step3: 尝试将p节点的waitStatus设为signal,设为signal并不代表已经被唤醒,
            //        只是告诉前置节点已经准备好了,等待时机开始抢锁,然后挂起当前线程,
            //        然后在这个位置阻塞中,等待被唤醒
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
         // Step4: 如果整个过程失败了,则取消node获取锁的资格,一般是程序执行过发生异常才会触发到这里
            cancelAcquire(node);
    }
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    // Step1: 通过外层acquireQueued()方法内的死循环,会把前置节点的waitStatus设置为signal,
    //        一旦设置成功后,接着就把当前线程挂起等待了
    if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) {
     // Step2: 在ReentrantLock下,waitStatus只会有两种状态,
     //        分别为signal=-1、cancelled=1以及默认值0。
     //        所以这里通过while循环,向前遍历列表,然后将队列中waitStatus
     //        为默认值0和cancelled=1的节点移除掉,等待gc回收。
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // Step3: 将node节点的前置节点的waitStatus通过CAS设置为signal状态,等待被唤醒
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
private final boolean parkAndCheckInterrupt() {
 // 将当前线程挂起阻塞在这里
    LockSupport.park(this);
    // 检查当前线程是否被中断
    return Thread.interrupted();
}

小结:以上就是将一个获取锁失败的线程包装成node,然后挂载到等待队列的队尾,然后将队尾节点的前置节点的waitStatus设为signal,然后将当前线程挂起,等待别的线程唤醒后,再从这里启动抢锁。有同学可能会疑问,就一直卡在这里也不是个事啊,那被唤醒的时机是什么时候呢?答案其实也很简单,当抢锁成功的线程执行完业务逻辑后,调用ReentrantLockunlock()方法进行锁释放,最终调用LockSupport.unpark()来唤醒挂起的线程,重新开始抢锁,后面我们在分析锁释放过程的时候再详细展开介绍。

2.3 公平锁实现

final void lock() {
 // 加锁
    acquire(1);
}

protected final boolean tryAcquire(int acquires) {
 // Step1: 获取当前线程和state状态
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
     // Step2: 首先检查等待队列中如果没有等待获取锁的线程,则尝试CAS获取线程,
     //        然后将exclusiveOwnerThread设置为当前线程。
     //        这里先让等待队列中的线程获取就是一种公平性的体现。
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // Step3: 如果持有锁的线程是当前线程,则执行重入流程。
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

小结:以上就是公平锁的公平性的体现,剩下的流程就和非公平锁的实现是一样的,比如:如果抢锁失败,将当前线程包装成node加入到等待队列,巴拉巴拉...

2.4 锁释放

锁释放的流程整体很简单,因为锁释放的前提是当前线程一定成功抢到了锁,所以不涉及到多线程操作;另外不管是公平锁还是非公平锁,对于锁释放的流程是一样的,所以锁释放的逻辑在Sync抽象类中实现的(因为FairSyncNonfairSync都集成了Sync),下面我们来看看源码实现。

// 通过调用RentrantLock的unlock()方法发起锁释放
public void unlock() {
    sync.release(1);
}

// 在AQS内定义的锁释放流程模版
public final boolean release(int arg) {
 // Step1: 尝试释放锁,如果释放成功,则开始尝试唤醒别的等待获取锁的线程
    if (tryRelease(arg)) {
        Node h = head;
        // Step2: 唤醒头结点(哑结点)的下一个node
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}


// 锁释放的具体流程,因为不涉及到多线程问题,所以整个执行过程就不需要加锁,直接操作属性即可
protected final boolean tryRelease(int releases) {
 // Step1: 对状态state值减1,因为ReentrantLock被实现为可重入的,所以减1次不一定会立马释放锁
    int c = getState() - releases;
    // Step2: 如果当前线程不是持有锁的线程,则直接抛出异常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // Step3: 如果状态state被减到了0,则可以让当前线程让出锁了,将exclusiveOwnerThread设为null,
    //        等候别的成功抢锁的线程占有
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // Step4: 修改state值
    setState(c);
    // Step5: 返回当前线程是否成功释放锁的结果
    return free;
}

// 唤醒等待队列中的第二个节点线程(第一个节点是哑结点),
// 注意:进入到这个方法内时,由于当前线程已经将锁释放,所以在这里面的操作会涉及到锁竞争
private void unparkSuccessor(Node node) {
    
    // Step1: 因为当一个node被加入到等待队列后,
    //        一定会将加入的node的前置节点的waitStatus设为signal=-1,
    //        即哑结点的waitStatus=-1
    int ws = node.waitStatus;
    if (ws < 0)
     // Step2: 将头结点的waitStatus通过CAS设置为0
        compareAndSetWaitStatus(node, ws, 0);

    // Step3: 获取到哑结点的下一个等待获取锁的线程,如果下一个节点为空,
    //        则从队列的尾部开始向前寻找,直到找到第一个waitStatues=-1的node
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // Step4: 找到第一个waitStatues=-1的node后,然后将其对应的线程进行唤醒,然后参与抢锁
    if (s != null)
        LockSupport.unpark(s.thread);
}

小结:以上就是锁释放的全过程,相比抢锁逻辑是不是简单了很多。这里面有个细节点值得思考一下,当哑结点的下一个节点为null时,并不代表队列中一定没有node,而为什么不是正向向前遍历获得待唤醒的node呢?其实原因就是抢锁过程中,未抢到锁的线程以尾插的方式入队产生的尾分叉问题,而从队尾第一个成功入队的节点往前遍历,则可以避免这个问题。

后续

本篇文章我们通过源码层面分析了基于ReentrantLock实现的AQS的公平锁和非公平锁的抢锁、抢锁失败进入队列、锁释放的全过程。可以看到状态statenode节点本身,以及node节点中的waitStatus属性,都使用了volatile关键字,以及在线程池文章中用AutomaticInteger修饰的线程池状态和活跃线程数ctl的字段的底层,也用到了volatile关键字;这么多地方都在使用volatile,那么下篇文章我们就深入到volatile内部来看看它的内部实现,以达到一方面方便理解源码为什么这样用,另外一方面方便我们在工程中更好地使用它。

 做一个有深度的技术人

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值