Java多线程(中)——AQS、ReentrantLock、Condition原理和源码

上一篇我们讲了锁、CAS、JMM、线程间通信,本节将深入讨论AQS原理和源码。

1 AQS原理

    AbstractQueuedSynchronizer,抽象队列同步器,专门用来处理加锁解锁的排队等待,至于怎么获取到锁、怎么释放掉锁则由具体的实现类来完成,总之一句话,AQS可以把抢不到锁的线程管理起来。管理的方法就是维护一个FIFO的双向队列,当线程获取不到锁时,就加入到队列中去;当线程释放锁时,就从队列中移除,同时通知其他等待的线程。

AQS同步器基本结构:

其中,每个节点的属性信息包括:

属性

解释

int waitStatus

1)CANCELLED = 1,在同步队列中等待的线程超时或被中断;

2)SIGNAL = -1,当前节点如果释放了同步状态或者被取消,则要通知后继节点;

3)CONDITION = -2,节点线程在等待其他线程的signal通知;

4)PROPAGATE = -3,下一次共享式同步状态获取将无条件传播下去;

5)INITIAL = 0,初始状态

Node prev

前驱节点(predecessor)

Node next

后继节点(successor)

Node nextWaiter

等待队列中的后继节点,如果当前节点是共享的,就是SHARED,

Thread thread

获取同步状态的线程

当线程没有抢到锁时,会使用CAS将其加入到队列尾:compareAndSetTail(Node expect, Node update):

 

当线程释放锁的时候,释放首节点,并唤醒后继节点:

2 AQS源码

2.1 独占式(EXCLUSIVE)同步状态获取与释放

2.1.1 获取

acquire源码:

 

该方法对中断不敏感:如果线程获取同步状态失败了,进了同步队列,后续对该线程执行中断操作,线程也不会从队列中移出。

基本原理:

1)调用实现类的tryAcquire方法尝试获取同步状态;

2)如果获取同步状态失败(就是线程没抢到锁),则构造EXCLUSIVE节点,使用addWaiter加入同步队列尾部;

3)使用acquireQueued以“死循环”的方式获取同步状态(时刻关注着前一个节点是不是head),如果获取不到就阻塞,直到前驱节点出队,或者这个等待的线程被中断了。

 

阅读源码,我们准备按顺序先看tryAcquire、再看addWaiter、再看acquireQueued、最后看selfInterrupt()。

1.tryAcquire

除了抛了个异常,别的啥也没干,空空如也。这是因为获取、释放同步状态的操作是由具体实现类来完成的。

2.addWaiter

先是用参数mode构造了一个Node,这里mode有两种:EXCLUSIVE(独占)和SHARED(共享);

然后用compareAndSetTail把这个节点放到队尾;

如果加入队尾失败了,就用enq死循环加入队尾:

3.acquireQueued

以自旋的方式获取同步状态。好吧,再强行解释一遍,所谓“自旋”,其实就是个死循环。。

代码乍一看有点复杂,我们不妨先看下原理:

什么意思呢?每个节点只关注自己的前驱节点,如果前驱节点是head,那就说明当前节点可以去抢锁了,用tryAcquire去抢。

要是抢到了,就把这个节点作为head,同时移除掉原有的head。(head节点是没有数据的,就是一个队列起始的标志)。于是下一个节点成为了head指向的节点。

那要是没抢到呢?那就看看自己是不是可以休息了:shouldParkAfterFailedAcquire,如果能休息就park进入waiting状态,直到被unpark。

既然已经挖到这里了,也不差这一步了,再看看shouldParkAfterFailedAcquire究竟干了啥:

p是前驱节点,node是当前节点。

分支1:前驱节点状态是SIGNAL,表示节点告诉了前驱节点,你拿完同步状态要告诉我,所以此时我可以安详地睡去了;

分支2:前驱节点状态 > 0,回头看看开始的表格,只有CANCELED是1大于0,嗯前驱节点取消了,此时一直往前找,直到找到一个状态<=0(没被取消)的节点,放它后面。

分支3:ws < 0,就是说前驱节点是正常的,那就把前驱节点状态设置成SIGNAL,跟他说声,大哥,拿完跟我说啊。

结果:走分支1的这会可以睡一会了;走分支2、3的,还要在自旋几回,总归来讲最后都可以睡的,时间问题,能不能安心睡关键是看前驱节点状态是不是SIGNAL。

如果能睡,那就试着睡睡看(LockSupport.park):

park会使线程进入waiting状态。

Thread.interrupted()的意思是说如果线程被唤醒了,要看看是不是因为被中断了。注意这个方法会reset中断状态,这次是true,下次再调就是false。

总结:tryAcquire全过程如下:

图片来源:http://www.cnblogs.com/waterystone/p/4920797.html

2.1.2 释放

独占式锁,完全释放的时候state=0(private volatile int state;),此时唤醒等待队列里的其他线程,告诉它们,睡你麻痹起来抢锁。

release源码:

跟获取资源对应,我们准备按顺序读源码,tryRelease -> unparkSuccessor

1.tryRelease

同样的,AQS也没有实现获取资源的方法,而是提供一个入口供具体实现类去写。

需要注意,如果返回true,那么说明state=0。

2.unparkSuccessor

唤醒后继节点。

compareAndSetWaitStatus(node, ws, 0);

——清空当前节点状态(置为初始值0)

Node s = node.next;

——获取当前节点的后继节点

if (s == null || s.waitStatus > 0)

——waitStatus > 0:看看节点状态表格,只有CANCELED=1是大于0的。

所以条件是:如果下一个节点不存在或已取消。

如果下一个节点不存在或已取消要怎么办呢?

for (Node t = tail; t != null && t != node; t = t.prev)
    if (t.waitStatus <= 0)
        s = t;

——很简单,就是从后向前遍历,直到找到一个waitStatus <= 0(未被取消)的节点,然后唤醒它:

if (s != null)
LockSupport.unpark(s.thread);

好吧其实并不准确,找到以后也没break啊,嗯确实,它又接着往前遍历了,所以:

其实是要获取最前面的那个等待的线程。

那为啥不从前往后遍历?这是因为node.next是null啊,当前节点找不到下一个节点在哪里,但tail是有的,所以就只能退而求其次,从后向前遍历了。

当然了,如果这个s(当前节点的下一个节点)一开始就是存在的且未被取消的,就不用绕这个大弯子了,直接unpark唤醒。

是不是也没那么难?

嗯,一步一个脚印地学,没有搞不定的,给自己点信心。

 

2.2 共享式(SHARED)同步状态获取与释放

2.2.1 获取

acquireShared:

1.tryAcquireShared依然需要自定义同步器实现:

2.doAcquireShared跟独占式acquireQueued是一样的,也是自旋,不行就park等待,不再重复解释,不清楚的回头看看:

head释放同步状态的时候,会通知后继节点,且仅会通知紧邻的后继节点。假设head释放了2单位资源,而head->next需要3单位资源,head->next->next需要2单位资源,那也不会唤醒head->next->next,仍然去唤醒head->next,显然head->next还不能被唤醒,它会等待其他线程再释放至少1单位资源。

如果获取同步状态成功(if (r >= 0),r:获取资源数量):

3.setHeadAndPropagate

setHead(node);

——head指向自己

doReleaseShared();

——如果资源还有剩余,继续唤醒下一个节点

2.2.2 释放

releaseShared

1.tryReleaseShared——自定义同步器

 

2.doReleaseShared

通过自旋(for (;;){})的方式释放共享资源。

独占模式下,考虑到可重入,只有完全释放资源(state=0)的时候才能通知后继节点,共享模式下不一样,只要释放资源就可以通知后继节点。

if (h == head) // loop if head changed
    break;

——这句不太懂,看方法注释:we must loop in case a new node is added while we are doing this. 意思是有新节点加入了,就要再循环一次,可是节点不是插入队列尾部的吗?那头部不可能变呀。我又回头看了下doAcquireShared才明白,就是可能在这个过程中队列的头节点获取到同步资源了,完了移除出对列了,head->next成了新的head,所以head变了。

总结:

(1)线程在等待队列中都是忽略中断

(2)独占模式下获取-释放资源:acquire-release

(3)共享模式下获取-释放资源:acquireShared-releaseShared

3 ReentrantLock

经过对AQS坚持不懈地研究,我们明白了,AQS定义了一种排队方式,解决那些暂时未获取到锁的线程该何去何从的问题,但是并没有告诉我们该如何获取、释放资源,而是留给了自定义同步器去实现。那本小节我们去研究下ReentrantLock是怎么获取和释放同步资源的,ReentrantLock又分为偏向锁(默认)和非偏向锁。

ReentrantLock有三大概念:可重入、公平锁&非公平锁、Condition等待/通知机制

3.1 锁的可重入是怎么实现的

setState()是AQS里面的方法:

 

private volatile int state;

——这个变量用来记录线程获取锁的状态

ReentrantLock在获取锁的时候,如果判断出来当前线程就是锁的拥有者(current == getExclusiveOwnerThread()),就直接给这个状态state加上acquires值,

其实就是1,所以每次重入ReentrantLock锁时+1,在释放锁时也是每次-1,直到state=0才表示锁完全释放掉。

3.2 公平锁&非公平锁

首先,我们要知道ReentrantLock默认是非公平锁:

当然也可以在创建锁的时候指定是公平的还是非公平的:

NonfairSync还有个大兄弟FairSync

我们带着疑问往下看,什么是公平的什么是非公平的:

3.2.1 公平锁加锁

乍一看花里胡哨、不知在干嘛,仔细瞧瞧,不是有句老话么,懂行的看门道,不懂行的看热闹。

这段代码主要分为两块:1)if (c==0);2)else if (current == getExclusiveOwnerThread())

1)if (c==0)

——尚没有锁获取到资源,这有可能是因为当前线程是头一个来抢锁的,也有可能是因为排在AQS首位的线程刚刚释放掉资源,保险起见还是先看看AQS里面有没有线程在等待:

if (!hasQueuedPredecessors() && compareAndSetState(0, acquires))

AQS方法:

什么情况下说明AQS里有前驱节点呢?

head和tail不是同一个节点,且head没有next或虽然有next但next不是当前线程。

(说明下,head的next节点就是等待队列的首个线程,head其实就是一个队列开始的标记,没有实际意义。)

判断出来没有线程在等待获取锁,尝试用CAS去获取资源:

compareAndSetState(0, acquires)

——看看上面的加锁,acquires会传1

如果CAS成功,当前线程就获取到独占锁:setExclusiveOwnerThread(current)

2)else if (current == getExclusiveOwnerThread())

当前线程本来就是资源的独占锁,说明之前已经获取到锁了,ok,直接对state累加:

int nextc = c + acquires;

3.2.2 非公平锁加锁

也不管AQS里有没有人在排队,上去就是干,上去就compareAndSetState(0, 1),万一干成功了呢?

万一成功了,就锁住资源呗:setExclusiveOwnerThread(Thread.currentThread())。

万一没成功呢?秒怂,try下试试,acquire(1),跟踪下,这个方法在AQS里面定义,

这不就是2.1.1节独占式获取同步状态的方式吗?尝试获取,失败就加入队列等着。

AQS的tryAcquire有这么几种实现:

显然我们要的是非公平锁的实现:

(又跳回ReentrantLock类了)

    跟上面公平锁的tryAcquire()方法对比下,仅仅是在if (c==0)后面少了个!hasQueuedPredecessors(),意思是这会儿共享资源的加锁状态是空的,我不管是因为没人来抢,还是因为head刚释放掉,我上来就用CAS去抢锁。

    为什么要这么设计呢?少了个hasQueuedPredecessors()就不用再判断是不是又前驱节点了,效率上会有提升。当然我是我的想法,存在即合理,既然设计出来两种加锁方式,说明不会有绝对优劣。

3.2.3 解锁

解锁就一个,没有区分公平非公平

如果tryRelease释放掉了锁,就通知后继节点(unparkSuccessor)。

看看怎么释放的:

上来把state减去要释放的资源量,其实就是1,如果当前线程不是共享资源的拥有者,就抛异常。

如果减完1还剩0,说明资源已经完全释放了,那就清空对资源的占有(setExclusiveOwnerThread(null))。

如果减完1还>0,说明还没释放干净(重入锁lock了3次,释放了2次,就还剩1次),更新state值就好了,不释放资源。

4 Condition

Lock的等待通知机制,一般这么用:

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

等待:

lock.lock();

try {

    ....

    condition.await(); //释放掉锁

} finally{

    lock.unlock;

}

通知:

lock.lock();

try {

    ....

    condition.signal(); //释放掉锁

} finally{

    lock.unlock;

}

4.1 await

1.将线程加入到等待队列

先来了解下什么是等待队列:

一个Condition就包含了一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(latWaiter),当线程调用condition.await()方法,就会以当前线程构造节点,并将节点从尾部加入等待队列中。

在Object的监视器模型上,一个对象拥有一个同步队列和一个等待一列,而Lock同步器则拥有一个同步队列和多个等待队列,每个condition维护一个等待队列:

Node node = addConditionWaiter();

我们知道,在使用condition.await()之前,一定要先加锁,就是lock.lock();,所以线程在await之前已经获取到了同步资源,同时我们知道,同步队列里只有head->next节点才可以获取同步资源,所以当前线程一定是同步队列里的head->next线程。此时把它取出来放在等待队列尾部,就是这样的:

2.如果当前线程不在同步队列里,就一直循环。怎么判断有没有在队列里:

 

 

while (!isOnSyncQueue(node)) {

    ....

}

 

从后向前遍历AQS队列,能找到这个节点就是true。

3.线程不在同步队列里,就park等待

LockSupport.park(this);

等待signal来把自己唤醒

4.2 signal

firstWaiter = first.nextWaiter

——从等待队列首节点往后遍历,直到唤醒一个节点。

唤醒条件就是CAS设置节点状态是SIGNAL。刚才await的时候把这个节点状态设置成了CONDITION。park和unpark都是UNSAFE类的native方法,作用就是把节点加入等待队列、把节点移出等待队列。

唤醒一个节点,就是把等待队列的首节点取出来,挪到同步队列的尾节点后边:

 

参考:

《Java并发编程的艺术》

http://www.cnblogs.com/waterystone/p/4920797.html

https://blog.csdn.net/u010412719/article/details/52089561

 

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值