结合ReentrantLock解析AQS

在工作中,"多线程"一直出现在我们耳边,大多数人对于多线程的印象就是能够提高代码的性能,满足多个请求,就比方说是一块蛋糕用一个刀切和用多个刀同时一起切,区别还是很大的,但是很多小伙伴又对于多线程的使用很困难,线程之间的争抢会增加编码的复杂度,所以说多线程真是可爱又可恨。

​ 在我刚接触多线程时,我一直有很多疑惑,比如 线程之间是怎么保证顺序的,多个线程去访问同一个代码块时,如果在保证请求顺序的前提下,那么并行不还是最终变成串行,实际的性能提高并不明显,比方说 用多个线程去累计1到100之和,多个线程的争抢,导致最终的结果必然会出现问题,这些疑问,希望小伙伴们和我一起慢慢的解开…、

提到多线程,那么不得不说下AQS(AbstractQueuedSynchronizer),这是JUC包中的核心框架,大多数的锁都是基于AQS来实现的,那么AQS在锁中到底是扮演一个什么角色呢? 我们可以用一个场景来解释,最近国内疫情又开始泛滥了,社区都在组织核酸监测,来做核酸监测的人数众多,需要一个监察员来组织群众排队,维持现场持续,那么这个监察员的角色就可以相当于AQS了,在JUC中,AQS也是负责维护各个请求,保证请求可以得到有序的执行,不会发生混乱。

​ JUC下面有很多的锁,比如ReentrantLock ,CountDonwLatch 都使用到了AQS,下面我们以ReentrantLock为例,来看下AQS是怎么和锁进行协同的(单独讲AQS有点枯燥,就结合ReetrantLock来说了)

ReetrantLock

RrKHQf.png

上图为ReetrantLock的结构图,其内部组合了Sync对象,Sync对象继承了AQS抽象类

我们现在看看,但我们创建一个ReetrantLock,并加上lock锁时,代码内部会发生一些什么状况

ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock();

我们来分析一下reentrantLock的lock方法

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

首先它内部会调用内部类Sync的lock方法,继续往下看

RrKIJI.png

我们看到Sync有两个实现类分别为公平的和不公平的,这里就涉及到了公平锁和非公平锁,后面会详细讲到二者的区别以及在ReetrantLock中的体现。

这里我们要注意到在创建ReentrantLock对象是,是可以传入参数来标识当前新建锁是否公平,以及在其内部创建Sync时也会进行处理,来看看Sync的初始化过程就一目了然了,go

RrKDiR.png

好了下面我们就来看看Lock方法的具体实现吧

RrldeS.png

lock方法中有个if else分支,if中的compareAndSetState是个CAS方法,关于CAS后面我们也会讲到,这里的意思就是查看当前线程有没有锁,如果没有,直接把state状态由0改为1

如果当前线程已经获取到锁了就会进入到acquire方法中,这里的acquire就是重头戏了,其实这里还涉及到锁重入的知识,不要着急,后续我都会有详细的讲解。

我们来看看acquire方法:

这里就不放图片了,直接上源码了

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

这里的tryAcquire方法注意,它是AQS中的钩子方法,不清楚什么是钩子方法的小伙伴可以自己去看看模板设计模式,了解一下,在reentrantLock中有相应的tryAcquire具体实现,主要是用来判断当前线程是否能够成功获取到锁,我们来看看源码的逻辑

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

注:这里有一点不明,在cas中已经将0变为1,后续在acquire中的tryAcquire方法中还进行了一次state==0的判断有点不解。

首先会获取当前的线程有没有持有锁,若没有还有进一步判断当前线程需不需要排队,也就是上述中的hasQueuedPredecessors(),关于这个方法大家可以自己去看看其中的判断逻辑。

在这个方法中会引出一个Node对象,这个对象很关键,它是AQS的线程队列中的管理对象,我们来看看这个Node对象中存放着什么信息

Rr7Xkt.png

waitStatus:当前线程在队列中的等待状态 有一下几种

  • CANCELLED:值为1,表示线程的获锁请求已经“取消”
  • SIGNAL:值为-1,表示该线程一切都准备好了,就等待锁空闲出来给我
  • CONDITION:值为-2,表示线程等待某一个条件(Condition)被满足
  • PROPAGATE:值为-3,当线程处在“SHARED”模式时,该字段才会被使用上(在后续讲共享锁的时候再细聊)

prev:当前节点的前驱节点

next:当前节点的后继节点

thread:表示当前节点中的等待线程

nextWaiter: 表示一个Node节点,该节点为等待Condition条件的节点

当tryAcquire获取线程失败时,就会进入到acquireQueued(addWaiter(Node.EXCLUSIVE), arg))方法,这个方法的意思就是把当前线程加入到AQS中的线程等待队列中,我们看看这个方法

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

首先是addWaiter(Node mode) 方法,这里用的时尾插法,在队尾将当前的新建节点加入到等待队列,并通过compareAndSetTail设置新加入节点为尾节点,enq()方法时用来保证当等待队列为空时,用来进行队列的初始化,并将当前节点加入。

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;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

再看看acquireQueued方法,上面我们通过addWaiter将当前线程加入到了等待队列,而这个acquireQueued方法是用来将加入等待队列中的线程不断的获取锁的操作,首先我们先获取当前节点的前驱节点,如果当前的节点的前驱节点为头节点,那么我们将这个头节点 删除,并且将当前节点设置为头节点,关于等待队列的头节点,希望大家记住,头节点的Node对象是没有存任何信息的,相当于是一个空对象,这里在setHead()方法中有具体的体现,大家可以看一下。

shouldParkAfterFailedAcquire这里的这个方法很重要,大家想象一下,如果当前线程一直没有成功获取到锁,那么就会一直在for循环进行获取锁的操作,从而造成cpu飙升,而这个shouldParkAfterFailedAcquire方法就是为了避免这一操作设计的,它会将获取锁失败的线程进行阻塞操作,具体的方法我们来一起看一下

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;
}

首先我们判断当前的节点的前驱节点的状态,如果为SIGNAL,意思是前驱节点就绪完毕正在等待线程,那么节点应该进行阻塞,如果前驱节点的状态大于0,以为着已经被取消,那么应该将前驱节点为取消状态的全部移除,如果不是大于零,我们应该将将前驱节点设置为SIGNAL状态,这样第二次循环就会将当前节点进行阻塞。

前面我们详细的说了lock加锁操作,现在我们再来看看解锁操作,相较于加锁操作来说,解锁操作就稍微来说简单一点了。

public void unlock() {
    sync.release(1);
}
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

解锁其实也是调用了Sync中的release方法,我们来看看其中的tryRelease方法,它和tryAcquire方法一样,用来判断当前线程是否可以进行锁的释放

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

这个方法应该很清晰吧,如果是重入锁,就将当前的重入减1,如果不是重入锁,就将当前所释放,本质就是设置当前持有锁的线程为空,当判断锁释放成功之后,我们就要进行下一步操作,去唤醒在等待队列里的线程来持有锁,具体方法为unparkSuccessor()。

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) {
        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);
}

这里记住在Node节点初始化时,waitStatus的值默认是为0。获取头节点的后继节点,因为头节点是不存放数据信息的,如果后继节点为空或者已经为取消状态的,那么就从尾节点往前进行遍历查找,找到一个不是canceled状态的线程并进行唤醒操作;

以上就是解锁的相关操作了,相对来说比较友好一点。

​ 以上就就是AQS进行加锁和解锁的详细的操作已经对源码的解读,可能看完还是有点懵逼,这个需要对应源码慢慢研读才会有体会,当然在解读过程中,有些相关知识我并没有继续进行深入的介绍,比如ReedtrantLock公平锁和非公平锁的实现,其实大家比较NonfairSync和FairSync的lock方法就应该可以看出区别,这个后续我们专门详细介绍,关于CAS的操作原理也很重要,JUC中很多工具类都用到了这一方法,如 atomic包,hashmap(1.8版本),这个CAS我也会有详细介绍,关于钩子方法和模板设计模式这点就需要大家去学习了(有时间我也会介绍,优先级不是最高),还有关于数据接口的知识这里我是默认大家已经熟悉了,希望大家可以看看这方面的知识,这里在AQS的等待队列添加Node节点时,需要大家仔细研读,节点时如何添加的,后面有空我会补上这一部分。

下期我会和大家介绍 reentrantLock和synchorized的区别已经相关的用法,这个就比较偏实战,没有AQS那么枯燥了哈哈 。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值