上一篇我们讲了锁、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