Java并发:深入浅出AQS之独占锁模式源码分析

arg) {

if

(!tryAcquire(arg) &&

acquireQueued(addWaiter(

Node

.EXCLUSIVE), arg))

selfInterrupt();

}

代码虽然短,但包含的逻辑却很多,一步一步看下:

1、首先是调用开发人员自己实现的 tryAcquire() 方法尝试获取锁资源,如果成功则整个 acquire()方法执行完毕,即当前线程获得锁资源,可以进入临界区。

2、如果获取锁失败,则开始进入后面的逻辑,首先是 addWaiter(Node.EXCLUSIVE)方法。来看下这个方法的源码实现

addWaiter(Node)

此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的节点。

//注意:该入队方法的返回值就是新创建的节点

private

Node

addWaiter(

Node

mode) {

//基于当前线程,节点类型(Node.EXCLUSIVE)创建新的节点

//由于这里是独占模式,因此节点类型就是Node.EXCLUSIVE

Node

node =

new

Node

(

Thread

.currentThread(), mode);

Node

pred = tail;

//这里为了提搞性能,首先执行一次快速入队操作,即直接尝试将新节点加入队尾

if

(pred !=

null

) {

node.prev = pred;

//这里根据CAS的逻辑,即使并发操作也只能有一个线程成功并返回,其余的都要执行后面的入队操作。即enq()方法

if

(compareAndSetTail(pred, node)) {

pred.next = node;

return

node;

}

}

//上一步失败则通过enq入队。

enq(node);

return

node;

}

enq(final Node node)

此方法用于将node加入队尾

//完整的入队操作

private

Node

enq(

final

Node

node) {

// CAS 自旋 ,直到成功加入队尾

for

(;😉 {

Node

t = tail;

//如果队列还没有初始化,则进行初始化,即创建一个空的头节点

if

(t ==

null

) {

//同样是CAS,只有一个线程可以初始化头结点成功,其余的都要重复执行循环体

if

(compareAndSetHead(

new

Node

()))

tail = head;

}

else

{

//新创建的节点指向队列尾节点,毫无疑问并发情况下这里会有多个新创建的节点指向队列尾节点

node.prev = t;

//基于这一步的CAS,不管前一步有多少新节点都指向了尾节点,这一步只有一个能真正入队成功,其他的都必须重新执行循环体

if

(compareAndSetTail(t, node)) {

t.next = node;

//该循环体唯一退出的操作,就是入队成功(否则就要无限重试)

return

t;

}

}

}

}

上面的入队操作有两点需要说明:

1、初始化队列的触发条件就是当前已经有线程占有了锁资源,因此上面创建的空的头节点可以认为就是当前占有锁资源的节点(虽然它并没有设置任何属性)。

2、注意 enq(finalNodenode)代码是,是一个经典的CAS自旋操作,直到成功加入队尾,否则一直重试。

经过上面的操作,我们申请获取锁的线程已经成功加入了等待队列,通过文章最一开始说的独占锁获取流程,那么节点现在要做的就是挂起当前线程,等待被唤醒,这个逻辑是怎么实现的呢?来看下源码:

acquireQueued(final Node node, int arg)

通过 tryAcquire()和 addWaiter(),如果线程获取资源失败,已经被放入等待队列尾部了。

如果线程获取资源失败,下一步进入等待状态休息,直到其他线程彻底释放资源后,唤醒自己再拿到资源,在等待队列中排队拿号,直到拿到号后再返回。(队列先进先出)

通过上面的分析,该方法入参node就是刚入队的包含当前线程信息的节点

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

;

//setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!

//表示锁资源成功获取,因此把failed置为false

failed =

false

;

//返回中断标记,表示当前节点是被正常唤醒还是被中断唤醒

return

interrupted;

}

//如果没有获取锁成功,则进入挂起逻辑

if

(shouldParkAfterFailedAcquire(p, node) &&

parkAndCheckInterrupt())

//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true

interrupted =

true

;

}

}

finally

{

//最后会分析获取锁失败处理逻辑

if

(failed)

cancelAcquire(node);

}

}

挂起逻辑是很重要的逻辑,这里拿出来单独分析一下,首先要注意目前为止,我们只是根据当前线程,节点类型创建了一个节点并加入队列中,其他属性都是默认值。

shouldParkAfterFailedAcquire(Node pred, Node node)

此方法主要用于检查状态,看看自己是否真的可以去休息了,进入 waiting状态

//首先说明一下参数,node是当前线程的节点,pred是它的前置节点

private

static

boolean

shouldParkAfterFailedAcquire(

Node

pred,

Node

node) {

//获取前置节点的waitStatus

int

ws = pred.waitStatus;

if

(ws ==

Node

.SIGNAL)

//如果前置节点的waitStatus是Node.SIGNAL则返回true,然后会执行parkAndCheckInterrupt()方法进行挂起

return

true

;

if

(ws >

0

) {

//由waitStatus的几个取值可以判断这里表示前置节点被取消

do

{

node.prev = pred = pred.prev;

}

while

(pred.waitStatus >

0

);

//这里我们由当前节点的前置节点开始,一直向前找最近的一个没有被取消的节点

//注,由于头结点head是通过new Node()创建,它的waitStatus为0,因此这里不会出现空指针问题,也就是说最多就是找到头节点上面的循环就退出了

pred.next = node;

}

else

{

//根据waitStatus的取值限定,这里waitStatus的值只能是0或者PROPAGATE,那么我们把前置节点的waitStatus设为Node.SIGNAL然后重新进入该方法进行判断

compareAndSetWaitStatus(pred, ws,

Node

.SIGNAL);

}

return

false

;

}

上面这个方法逻辑比较复杂,它是用来判断当前节点是否可以被挂起,也就是唤醒条件是否已经具备,即如果挂起了,那一定是可以由其他线程来唤醒的。该方法如果返回false,即挂起条件没有完备,那就会重新执行 acquireQueued()方法的循环体,进行重新判断,如果返回 true,那就表示万事俱备,可以挂起了,就会进入 parkAndCheckInterrupt()方法看下源码:

parkAndCheckInterrupt()

private

final

boolean

parkAndCheckInterrupt() {

LockSupport

.park(

this

);

//调用park()使线程进入waiting状态

//被唤醒之后,返回中断标记,即如果是正常唤醒则返回false,如果是由于中断醒来,就返回true

return

Thread

.interrupted();

}

注意: Thread.interrupted()会清除当前线程的中断标记位。

park()会让当前线程进入 waiting状态。在此状态下,有两种途径可以唤醒该线程:1,被 unpark();2,被 interrupt()

看 acquireQueued方法中的源码,如果是因为中断醒来,那么就把中断标记置为 true。

不管是正常被唤醒还是由与中断醒来,都会去尝试获取锁资源。如果成功则返回中断标记,否则继续挂起等待。

Thread.interrupted()方法在返回中断标记的同时会清除中断标记,也就是说当由于中断醒来然后获取锁成功,那么整个 acquireQueued方法就会返回 true

表示是因为中断醒来,但如果中断醒来以后没有获取到锁,继续挂起,由于这次的中断已经被清除了,下次如果是被正常唤醒,那么 acquireQueued方法就会返回 false,表示没有中断。

看了 shouldParkAfterFailedAcquire(Nodepred,Nodenode)和 parkAndCheckInterrupt(),现在让我们再回到 acquireQueued(finalNodenode,intarg),总结下该函数的具体流程:

节点进入队尾后,检查状态,是否可以被挂起去休息;

调用 park进入 waiting状态,等待 unpark()或 interrupt()唤醒自己;

被唤醒后,看自己是不是有资格能拿到号。如果拿到,head指向当前结点,并返回从入队到拿到号的整个过程中是否被中断过;如果没拿到,继续流程1。

cancelAcquire(Node node)

最后我们回到 acquireQueued方法的最后一步, finally模块。这里是针对锁资源获取失败以后做的一些善后工作,翻看上面的代码,其实能进入这里的就是 tryAcquire()方法抛出异常,也就是说AQS框架针对开发人员自己实现的获取锁操作如果抛出异常,也做了妥善的处理,一起来看下源码:

//传入的方法参数是当前获取锁资源失败的节点

private

void

cancelAcquire(

Node

node) {

// 如果节点不存在则直接忽略

if

(node ==

null

)

return

;

node.thread =

null

;

// 跳过所有已经取消的前置节点,跟上面的那段跳转逻辑类似

Node

pred = node.prev;

while

(pred.waitStatus >

0

)

node.prev = pred = pred.prev;

//这个是前置节点的后继节点,由于上面可能的跳节点的操作,所以这里可不一定就是当前节点,仔细想一下。_

Node

predNext = pred.next;

//把当前节点waitStatus置为取消,这样别的节点在处理时就会跳过该节点

node.waitStatus =

Node

.CANCELLED;

//如果当前是尾节点,则直接删除,即出队

//注:这里不用关心CAS失败,因为即使并发导致失败,该节点也已经被成功删除

if

(node == tail && compareAndSetTail(node, pred)) {

compareAndSetNext(pred, predNext,

null

);

}

else

{

int

ws;

if

(pred != head &&

((ws = pred.waitStatus) ==

Node

.SIGNAL ||

(ws <=

0&& compareAndSetWaitStatus(pred, ws,

Node

.SIGNAL))) &&

pred.thread !=

null

) {

Node

next = node.next;

if

(next !=

null

&& next.waitStatus <=

0

)

//这里的判断逻辑很绕,具体就是如果当前节点的前置节点不是头节点且它后面的节点等待它唤醒(waitStatus小于0),

//再加上如果当前节点的后继节点没有被取消就把前置节点跟后置节点进行连接,相当于删除了当前节点

compareAndSetNext(pred, predNext, next);

}

else

{

//进入这里,要么当前节点的前置节点是头结点,要么前置节点的waitStatus是PROPAGATE,直接唤醒当前节点的后继节点

unparkSuccessor(node);

}

node.next = node;

// help GC

}

}

上面就是独占模式获取锁的核心源码,确实非常难懂,很绕,就这几个方法需要反反复复看很多遍,才能慢慢理解。

release(int arg)

接下来看下释放锁的过程:

此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即 state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是 unlock()的语义,当然不仅仅只限于 unlock()。下面是 release()的源码:

public

final

boolean

release(

int

arg) {

if

(tryRelease(arg)) {

Node

h = head;

if

(h !=

null

&& h.waitStatus !=

0

)

unparkSuccessor(h);

return

true

;

}

return

false

;

}

tryRelease()方法是用户自定义的释放锁逻辑,如果成功,就判断等待队列中有没有需要被唤醒的节点(waitStatus为0表示没有需要被唤醒的节点),一起看下唤醒操作:

private

void

unparkSuccessor(

Node

node) {

//这里,node一般为当前线程所在的节点。

int

ws = node.waitStatus;

if

(ws <

0

)

//把标记为设置为0,表示唤醒操作已经开始进行,提高并发环境下性能

compareAndSetWaitStatus(node, ws,

0);

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
AQS(AbstractQueuedSynchronizer)是Java并发编程中的一个重要类,它可以理解为抽象的队列同步器。AQS提供了一种基于FIFO队列的同步机制,用于实现各种同步器,如ReentrantLock、CountDownLatch、Semaphore等。 AQS的核心思想是使用一个volatile的int类型变量state来表示同步状态,通过CAS(Compare and Swap)操作来实现对state的原子更新。AQS内部维护了一个双向链表,用于保存等待获取同步状态的线程。 AQS的具体实现包括以下几个方面: 1. 内部属性:AQS内部有两个重要的属性,一个是head,表示队列的头节点;另一个是tail,表示队列的尾节点。 2. 入队操作:AQS的入队操作是通过enq方法实现的。在入队操作中,首先判断队列是否为空,如果为空,则需要初始化队列;否则,将新节点添加到队列的尾部,并更新tail指针。 3. CAS操作:AQS的CAS操作是通过compareAndSetHead和compareAndSetTail方法实现的。这些方法使用CAS操作来更新head和tail指针,保证操作的原子性。 4. 出队操作:AQS的出队操作是通过deq方法实现的。在出队操作中,首先判断队列是否为空,如果为空,则返回null;否则,将头节点出队,并更新head指针。 5. 同步状态的获取和释放:AQS提供了acquire和release方法来获取和释放同步状态。acquire方法用于获取同步状态,如果获取失败,则会将当前线程加入到等待队列中;release方法用于释放同步状态,并唤醒等待队列中的线程。 通过继承AQS类,可以实现自定义的同步器。具体的实现方式是重写AQS的几个关键方法,如tryAcquire、tryRelease等,来实现对同步状态的获取和释放。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值