AQS的核心原理分析

之前已经写过一篇关于AQS的介绍了,基本概念我就不多逼逼了,我直接把我那篇文章复制过来。后面来根据java的源码分析这个AQS的内部实现。

前言:这一部分的基础概念讲解,全部都是我之前的一篇博客:谈谈你对AQS的了解

1.写在前面:

这篇文章,我们来聊聊面试时一个比较有杀伤力的问题:聊聊你对AQS的理解?

之前有同学反馈,去互联网公司面试,面试官聊到并发时就问到了这个问题。当时那位同学内心估计受到了一万点伤害。。。因为首先,很多人可能连AQS是什么都不知道。或者仅仅是听说过AQS这个名词,但是可能连全称怎么拼写都不知道。更有甚者,可能会说:AQS?是不是一种思想?我们平时开发怎么来用AQS?总结起来,很多同学都对AQS有一种云里雾里的感觉,如果用搜索引擎查一下AQS是什么,估计看几篇文章就直接放弃了,因为密密麻麻的文字,实在是看不懂!所以基于上述痛点,这篇文章就用最简单的大白话配合N多张手绘图,给大家讲清楚AQS到底是什么?让各位同学面试被问到这个问题时,不至于不知所措。

二、ReentrantLock和AQS的关系

首先来看看,如果用java并发包下的ReentrantLock来加锁和释放锁,是个什么样的感觉?

这个学过java的同学应该都会吧,毕竟是java并发基本API的使用,我们直接看一下代码:

你这时可能会问,这个跟AQS有啥关系?关系大了去了!因为java并发包下很多API都是基于AQS来实现的加锁和释放锁等功能的,AQS是java并发包的基础类。举个栗子,比如说ReentrantLock、ReentrantReadWriteLock底层都是基于AQS来实现的。

那么AQS的全称是什么呢:AbstractQueuedSynchronizer,抽象队列同步器

给大家画一个图,看一下ReentrantLock和AQS之间的关系。

我们看上图,说白了,ReentrantLock内部包含了一个AQS对象,也就是AbstractQueuedSynchronizer类型的对象。这个AQS对象就是ReentrantLock可以实现加锁和释放锁的关键性的核心组件。

三、ReentrantLock加锁和释放锁的底层原理

好了,现在如果有一个线程过来尝试用ReentrantLock的lock()方法进行加锁,会发生什么事情?很简单,这个AQS对象内部有一个核心的变量叫做state,是int类型的,代表了加锁的状态。初始状态下,这个state的值是0。另外,这个AQS内部还有一个关键变量,用来记录当前加锁的是哪个线程,初始化状态下,这个变量是null。

接着线程1跑过来调用ReentrantLock的lock()方法尝试进行加锁,这个加锁的过程,直接就是用CAS操作将state值从0变为1。

(关于CAS,之前专门有文章做过详细阐述,大家可以自行阅读了解)

如果之前没人加过锁,那么state的值肯定是0,此时线程1就可以加锁成功。一旦线程1加锁成功了之后,就可以设置当前加锁线程是自己。所以大家看下面的图,就是线程1跑过来加锁的一个过程。

其实看到这儿,大家应该对所谓的AQS有感觉了。说白了,就是并发包里的一个核心组件,里面有state变量、加锁线程变量等核心的东西,维护了加锁状态。你会发现,ReentrantLock这种东西只是一个外层的API,内核中的锁机制实现都是依赖AQS组件的

这个ReentrantLock之所以用Reentrant打头,意思就是他是一个可重入锁

可重入锁的意思,就是你可以对一个ReentrantLock对象多次执行lock()加锁和unlock()释放锁,也就是可以对一个锁加多次,叫做可重入加锁。

大家看明白了那个state变量之后,就知道了如何进行可重入加锁!

其实每次线程1可重入加锁一次,会判断一下当前加锁线程就是自己,那么他自己就可以可重入多次加锁,每次加锁就是把state的值给累加1,别的没啥变化。

接着,如果线程1加锁了之后,线程2跑过来加锁会怎么样呢?我们来看看锁的互斥是如何实现的?

线程2跑过来一下看到,哎呀!state的值不是0啊?所以CAS操作将state从0变为1的过程会失败,因为state的值当前为1,说明已经有人加锁了!

接着线程2会看一下,是不是自己之前加的锁啊?当然不是了,“加锁线程”这个变量明确记录了是线程1占用了这个锁,所以线程2此时就是加锁失败。给大家来一张图,一起来感受一下这个过程:

接着,线程2会将自己放入AQS中的一个等待队列,因为自己尝试加锁失败了,此时就要将自己放入队列中来等待,等待线程1释放锁之后,自己就可以重新尝试加锁了。所以大家可以看到,AQS是如此的核心!AQS内部还有一个等待队列,专门放那些加锁失败的线程!

接着,线程1在执行完自己的业务逻辑代码之后,就会释放锁!他释放锁的过程非常的简单,就是将AQS内的state变量的值递减1,如果state值为0,则彻底释放锁,会将“加锁线程”变量也设置为null!整个过程,参见下图:

接下来,会从等待队列的队头唤醒线程2重新尝试加锁。

好!线程2现在就重新尝试加锁,这时还是用CAS操作将state从0变为1,此时就会成功,成功之后代表加锁成功,就会将state设置为1。此外,还要把“加锁线程”设置为线程2自己,同时线程2自己就从等待队列中出队了。最后再来一张图,大家来看看这个过程。

四、总结

OK,本文到这里为止,基本借着ReentrantLock的加锁和释放锁的过程,给大家讲清楚了其底层依赖的AQS的核心原理。基本上大家把这篇文章看懂,以后再也不会担心面试的时候被问到:谈谈你对AQS的理解这种问题了。其实一句话总结:AQS就是一个并发包的基础组件,用来实现各种锁,各种同步组件的。它包含了state变量、加锁线程、等待队列等并发中的核心组件。

这上面讲的其实很不错,仔细看完肯定能够知道什么是AQS,但是想要更加深入的了解原理,可以看下我下面的原理分析。

五、什么是Lock

Lock 在 J.U.C 中是最核心的组件,前面我们讲 synchronized 的时候说过,锁最重 要的特性就是解决并发安全问题。为什么要以 Lock 作为切入点呢?如果有同学看 过 J.U.C 包中的所有组件,一定会发现绝大部分的组件都有用到了 Lock。所以通 过 Lock 作为切入点使得在后续的学习过程中会更加轻松。

Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意 味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现 Lock 接口的类有很多,以下为几个常见的锁实现

ReentrantLock:表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是 线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入 次数
ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个 类中维护了两个锁,一个是 ReadLock,一个是 WriteLock,他们都分别实现了 Lock 接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则 是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的 操作都会存在互斥。

StampedLock: stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写 锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全 并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。 stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程

Lock的继承体系图:

ReentrantLock 重入锁:

重入锁,表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized 和 ReentrantLock 都是可重入锁。很多同学不理解为什么锁会存在重入的特性,那是因为对于同步锁的理解程度还不够,比如在下面这类的场景中,存在多个加锁的方法的相互调用,其实就是一种重入特性的场景。

重入锁的设计目的

比如调用 demo 方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2 中的存在同一个实例锁,这个时候当前线程会因为无法获得demo2 的对象锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁

Lock的基本使用:

ReentrantLock 的实现原理

我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串 行执行,从而达到线程安全性的目的。在 synchronized 中,我们分析了偏向锁、 轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销, 同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。 那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题。就是在多线程 竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢

AQS 是什么

在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它 是一个同步工具也是 Lock 用来实现线程同步的核心组件。如果你搞懂了 AQS,那 么 J.U.C 中绝大部分的工具都能轻松掌握

AQS 的两种功能

从使用层面来说,AQS 的功能分为两种:独占和共享 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是 以独占方式实现的互斥锁 共享锁,允许多个线程同时获取锁,并发访问共享资源,比如 ReentrantReadWriteLock

AQS 的内部实现

AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任 意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线 程争抢锁失败后会封装成 Node 加入到 ASQ 队列中去;当获取锁的线程释放锁以 后,会从队列中唤醒一个阻塞的节点(线程)。

Node节点:

有了前面这么多前置知识,我们应该多AQS以一个全局的深刻认识,带着这些理论基础,不多逼逼,我们来看下他的源码分析吧,这一次我会画很多图来分析(我一般是不用工具画图的,我都是在书本上画,这样比较快)

我们先来看一下非公平锁的实现(如果不知道这个概念的,本文的最后会有介绍,不影响这个源码的分析)

这个lock的第一行代码就是一个CAS操作,不知道CAS的可以看下我这篇博客   什么是CAS和ABA问题

这行代码就是一个非公平锁的一个体现,不管有没有线程排队,我先上来都是CAS去抢占一波,成功了,就表示获取了锁,把当前的state设置为1,并把exclusiveOwnerThread设置为当前线程,失败的话,才走acquire(1)去抢占锁

我们可以看下这个compareAndSetState(0,1),简单的介绍下这个CAS,因为后续的源码分析会有大量的CAS操作

通过 cas 乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的 state 的值和预期值 expect 相等,则替换为 update。更新成功返回 true,否则返 回 false.
这个操作是原子的,不会出现线程安全问题,这里面涉及到 Unsafe 这个类的操作, 以及涉及到 state 这个属性的意义。

state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入 锁的实现来说,表示一个同步状态。它有两个含义的表示
1. 当 state=0 时,表示无锁状态
2. 当 state>0 时,表示已经有线程获得了锁,也就是 state=1,但是因为 ReentrantLock 允许重入,所以同一个线程多次获得同步锁的时候,state 会递增, 比如重入 5 次,那么 state=5。而在释放锁的时候,同样需要释放 5 次直到 state=0 其他线程才有资格获得锁

这边unsafe是个什么鬼?

Unsafe 类是在 sun.misc 包下,不属于 Java 标准。但是很多 Java 的基础类库,包 括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、 Hadoop、Kafka 等;
Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、 线程的挂起和恢复、CAS、线程同步、内存屏障而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象, 第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第 四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值 var4 相等,则 更新为新的期望值 var5,如果更新成功,则返回 true,否则返回 false;

stateOffset:一个 Java 对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存 里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节 偏移。用于在后面的 compareAndSwapInt 中,去根据偏移量找到对象在内存中的 具体位置所以 stateOffset 表示 state 这个字段在 AQS 类的内存中相对于该类首地址的偏移量

看完这个,应该大致明白CAS的操作的用途了吧。那我们看下面这个else的抢占锁的代码:

首先会调用这个tryAcquire方法,我么点进去,看到是一个

直接抛出了一个异常,是不是很吃惊?别迷了,这个方法被子类重写了,我们应该看java.util.concurrent.locks.ReentrantLock.NonfairSync#tryAcquire下面的

我这里其实是有个疑惑的?为啥这个不在AQS里面搞一个抽象方法,之类自己实现,非要在父类搞一个异常,不是很懂。

好吧,我们来看下子类的方法实现:

抢到了锁就返回true,否则就是false

我们再看这一行代码

看里面那个addWaiter方法,这个就是构造我们上面理论知识的等待队列的逻辑:

当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成 Node.
入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状 态。意味着重入锁用到了 AQS 的独占锁功能

1. 将当前线程封装成 Node
2. 当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的

node 添加到 AQS 队列
3. 如果为空或者 cas 失败,调用 enq 将节点添加到 AQS 队列

private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode); //把
当前线程封装为 Node
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail; //tail 是 AQS 中表示同比队列队尾的属性,默认为null
        if (pred != null) { //tail 不为空的情况下,说明队列中存在节点
            node.prev = pred;//把当前线程的 Node 的 prev 指向 tail
            if (compareAndSetTail(pred, node)) {//通过 cas 把 node
加入到 AQS 队列,也就是设置为 tail
                pred.next = node;//设置成功以后,把原 tail 节点的 next指向当前 node
                return node;
            }
        }
        enq(node);//tail=null,把 node 添加到同步队列
        return node;
    }

我们来画图分析这个代码的实现过程:假如有三个线程来来争抢锁:

这个图画的有点简陋,大致细节已经画出来了,明天我再补一个图,让大家更明白,这点不搞清楚,后面中断唤醒啥的不好理解哦。

addWaiter构建完成之后,我们来看一下acquireQueued这个方法,会把构建的Node作为参数传递进来,一上来就会自旋,先拿到这个构建的节点的前一个节点,也就是我们上面画的节点BNode,如果是head节点,并且调用抢占锁成功,把获得锁的节点设置为head,并移除原来初始化的head节点,否则的话,根据waitStatus的值来判断是否需要挂起线程,最后,通过cancelAcquire取消获得锁的操作.看下代码:

我们来看下shouldParkAfterFailedAcquire

如果 ThreadA 的锁还没有释放的情况下,ThreadB 和 ThreadC 来争抢锁肯定是会 失败,那么失败以后会调用 shouldParkAfterFailedAcquire 方法
Node 有 5 中状态,分别是:CANCELLED(1),SIGNAL(-1)、CONDITION(- 2)、PROPAGATE(-3)、默认状态(0)

CANCELLED: 在同步队列中等待的线程等待超时或被中断,需要从同步队列中取 消该 Node 的结点, 其结点的 waitStatus 为 CANCELLED,即结束状态,进入该状 态后的结点将不会再变化
SIGNAL: 只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程

CONDITION: 和 Condition 有关系,后续会讲解 PROPAGATE:共享模式下,PROPAGATE 状态的线程处于可运行状态
0:初始状态
这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是 否应该被挂起。

1. 如果 ThreadA 的 pred 节点状态为 SIGNAL,那就表示可以放心挂起当前线程 2. 通过循环扫描链表把 CANCELLED 状态的节点移除
3. 修改 pred 节点的状态为 SIGNAL,即把我们的BNode的waitStatus改为-1,返回 false.
返回 false 时,也就是不需要挂起,返回 true,则需要调用 parkAndCheckInterrupt 挂起当前线程

那我们来看下parkAndCheckInterrupt

使用 LockSupport.park 挂起当前线程编程 WATING 状态 Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是 thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识 true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回 true,意味 着在 acquire 方法中会执行 selfInterrupt()。

这边我想讲一下线程的中断,这个概念还是非常重要的,我这边先参考一下别人的,以后我再补上我自己的理解吧:

中断线程 - Ruthless - 博客园

selfInterrupt: 标识如果当前线程在 acquireQueued 中被中断过,则需要产生一 个中断请求,原因是线程在调用 acquireQueued 方法的时候是不会响应中断请求 的

LockSupport

LockSupport 类是 Java6 引入的一个类,提供了基本的线程同步原语。LockSupport 实际上是调用了 Unsafe 类里的函数,归结到 Unsafe 里,只有两个函数

unpark 函数为线程提供“许可(permit)”,线程调用 park 函数则等待“许可”。这个有 点像信号量,但是这个“许可”是不能叠加的,“许可”是一次性的。
permit 相当于 0/1 的开关,默认是 0,调用一次 unpark 就加 1 变成了 1.调用一次

park 会消费 permit,又会变成 0。 如果再调用一次 park 会阻塞,因为 permit 已 经是 0 了。直到 permit 变成 1.这时调用 unpark 会把 permit 设置为 1.每个线程都 有一个相关的 permit,permit 最多只有一个,重复调用 unpark 不会累积

看完了lock的实现原理,我们再来看一下unlock

如果这个时候 ThreadA 释放锁了,那么我们来看锁被释放后会产生什么效果

如果释放锁成功,得到AQS中的head节点,如果head不为空,而且waitStatus不为0,就唤醒后续线程

我们看一下那个tryRelease方法

这个方法可以认为是一个设置锁状态的操作,通过将 state 状态减掉传入的参数值 (参数是 1),如果结果状态为 0,就将排它锁的 Owner 设置为 null,以使得其它的线程有机会进行执行。
在排它锁中,加锁的时候状态会增加 1(当然可以自己修改这个值),在解锁的时 候减掉 1,同一个锁,在可以重入后,可能会被叠加为 2、3、4 这些值,只有 unlock() 的次数与 lock()的次数对应才会将 Owner 线程设置为空,而且也只有这种情况下 才会返回 true。

再看一下unparkSuccessor

获得head节点的状态,如果小于0,cas操作,把ws设为0,后面这一步又是把队列里面那些状态>0的都删掉,这个是从尾部遍历删除的,在lock里面那个是从头部遍历删除的?想一想这里为啥从尾部遍历删除呢?找到ws状态值小于等于0的节点,直接唤醒这个线程。

我来解释下这个疑问吧:

一个新的节点是如何加入到链表中:

1. 将新的节点的 prev 指向 tail
2. 通过 cas 将 tail 设置为新的节点,因为 cas 是原子操作所以能够保证线程安全性

3. t.next=node;设置原 tail 的 next 节点指向新的节点

在 cas 操作之后,t.next=node 操作之前。 存在其他线程调用 unlock 方法从 head 开始往后遍历,由于 t.next=node 还没执行意味着链表的关系还没有建立完整。 就会导致遍历到 t 节点的时候被中断。所以从后往前遍历,一定不会存在这个问 题。

图解分析

通过锁的释放,原本的结构就发生了一些变化。head 节点的 waitStatus 变成了 0, ThreadB 被唤醒

原本挂起的线程继续执行

通过 ReentrantLock.unlock,原本挂起的线程被唤醒以后继续执行,应该从哪里执 行大家还有印象吧。 原来被挂起的线程是在 acquireQueued 方法中,所以被唤 醒以后继续从这个方法开始执行AQS.acquireQueued

这个方法前面已经完整分析过了,我们只关注一下 ThreadB 被唤醒以后的执行流 程。
由于 ThreadB 的 prev 节点指向的是 head,并且 ThreadA 已经释放了锁。所以这 个时候调用 tryAcquire 方法时,可以顺利获取到锁

1. 把 ThreadB 节点当成 head
2. 把原 head 节点的 next 节点指向为 null

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值