JUC源码学习笔记1——AQS和ReentrantLock
笔记主要参考《Java并发编程的艺术》并且基于JDK1.8的源码进行的刨析,此篇只分析独占模式,后续在ReentrantReadWriteLock和 CountDownLatch中 会重点分析AQS的共享模式
一丶Lock
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁可以防止多个线程同时访问共享资源(这种锁称为独占锁,排他锁)但是有些锁可以允许多个线程并发访问共享资源,比如读写锁
1.Lock接口的方法:
方法 | 作用 |
---|---|
void lock() | 获取锁,调用该方法的线程将会获取锁,当锁获得之后从该方法返回 |
void lockInterruptibly() | 可中断地获取锁,该方法会响应中断,在锁的获取可以中断当前线程,如果在获取锁之前设置了中断标志,or获取锁的中途被中断or其他线程中断该线程则抛出InterruptedException并清除当前线程的中断状 |
boolean tryLock() | 尝试非阻塞的获取锁,调用方法会立即返回,如果可以获取到锁返回true |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 超时获取锁,从当前返回有三种情况 1.超时时间内获取到锁 2.当前线程在超时时间内被中断3.超时间结束没有获取到锁,返回false |
void unLock | 释放锁 |
Condition newCondition() | 获取等待通知的组件,该组件和当前锁绑定,只有获取到锁调用wait方法后当前线程将放弃锁,后续被其他线程signal继续争抢锁 |
2.Lock相比synchronized具备的特性
- 尝试非阻塞的获取锁
- 响应中断的获取锁
- 超时获取锁
synchronized相比于Lock 更加简单,更不容易犯错,但是不够灵活
3.使用Lock的经典范式
获取锁的过程不要写在try中,避免获取锁失败最后finally释放其他线程持有的锁
二丶AbstractQueuedSynchronizer队列同步器
使用一个int成员变量state表示同步状态,内置的FIFO队列来完成资源的获取和线程的排队工作,支持独占也支持共享的获取同步状态。
三个变量被volatile修饰,保证其线程可见性
1.队列同步器可以被重写的方法
方法 | 说明 |
---|---|
protected boolean tryAcquire(int arg) | 独占的获取锁,需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS改变同步状态 |
protected boolean tryRelease(int arg) | 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态 |
protected int tryAcquireShared(int arg) | 共享式的获取同步状态,放回大于等于0()的值表示成功,反之失败 |
protected boolean tryReleaseShared(int arg) | 共享式释放同步状态 |
protected boolean isHeldExclusively() | 当前队列同步器释放再独占模式下被线程占用,一般表示当前线程是否独占 |
2.队列同步器提供的模板方法
方法 | 说明 |
---|---|
void acquire(int arg) | 独占式获取同步状态,如果获取成功那么直接返回,反之进入同步队列中等待, |
void acquireInterruptibly(int arg) | 和acquire,但是此方法支持在获取锁的过程中响应中断,如果当前线程被中断那么抛出InterruptedException |
boolean tryAcquireNanos(int arg, long nanosTimeout) | 在acquireInterruptibly的基础上增加了超时限制,如果在指定时间内没有获取到同步状态那么返回false反之true |
void acquireShared(int arg) | 共享式获取同步状态,如果没有获取到那么进入等待队列等待,和acquire不同的式支持同一个时刻多个线程获取同步状态 |
void acquireSharedInterruptibly(int arg) | 和acquireShared类似但是支持响应中断 |
boolean tryAcquireSharedNanos(int arg, long nanosTimeout) | 在acquireSharedInterruptibly新增了超时限制 |
boolean release(int arg) | 独占式释放同步资源,在释放同步状态后唤醒后继线程 |
boolean releaseShared(int arg) | 共享式释放同步状态 |
Collection<Thread> getQueuedThreads() |
获取等待在同步队列上的线程们 |
3.同步队列的节点属性
属性 | 描述 |
---|---|
int waitStatus | 等待状态 |
Node pre | 前驱节点 |
Node next | 后继节点 |
Node nextWaiter | 等待队列中的后继节点,如果当前节点式共享模式,那么这个节点是SHARED常量,也就是说节点类型和等待中后继节点是公用一个字段 |
Thread thread | 获取同步状态的线程 |
等待状态是一个枚举,具备下列可选的值
- CANCELLED(1)线程获取锁超时or被中断,需要从同步队列中取消中断,节点进入改状态后状态不再改变
- SIGNAL(-1)后继节点线程处于等待状态,而当前节点的线程如果释放共享资源或者被取消会通知后继节点,使后继线程被唤醒继续执行
- CONDITION(-2)节点在等待队列中,节点中的线程在Condition上进行等待,需要等待其他线程调用Condition的singal or singalAll进行唤醒,该节点会从等待队列移动到同步队列,进行共享资源的争夺
- PROPAGATE(-3)表示下一次共享式同步状态的获取将无条件的传播下去
- 0 初始状态,节点假如到同步队列时候的状态
4.AQS怎么维护同步队列
AQS中包含两个节点类型引用:头节点和尾节点。当一个线程获取到同步状态的时候,其他线程无法获取,将被放入到同步队列中,加入队列这个过程为了保证线程安全而采用CAS。同步队列遵守FIFO,头节点是获取到同步状态的线程,释放同步状态将会唤醒后继线程,后继节点获取到同步状态后将被设置为头节点
三丶ReentrantLock可重入锁
1.ReentrantLock简介
支持公平和非公平和重入的独占式锁
- 重入表示已经获得锁的线程可以对共享资源重复加锁
- 公平锁,支持先来后到,像在公司排队上厕所,先来的人肯定优先获取到茅坑,先来的线程肯定先获取到共享资源
- 独占式,同一时间只允许一个线程操作共享资源
2.公平锁和非公平锁比较
公平锁在头节点释放同步资源的时候需要unpark后续节点,并切换线程执行上下文,导致效率并不如非公平锁,但是公平锁可以减少饥饿,因为非公平锁好像A在排队,A获取到共享资源需要进行唤醒和上下文切换,而导致需要更多时间,这时候流氓B刚好进厕所门,上来就是一个CAS,很快抢占了厕所这一共享资源,导致A处于饥饿
——迟迟得不到厕所(共享资源)的操作资源。
3.ReentrantLock的可重入
实现可重入需要解决两个问题
- 线程再次获得锁,锁需要去识别当前线程释放是当前占据锁的线程,如果是那么直接加锁成功
- 锁的最终释放,加锁多少次,就需要释放多少次,完全解锁后其他线程才可以获取到锁。
第一个问题ReentrantLock通过获取当前线程和独占锁线程的`==1判断来实现,第二个问题ReentrantLock通过对AQS中的共享资源state增加和减少来实现
四丶结合ReentrantLock分析加锁解锁的流程
1.ReentranLock
ReentrantLock的公平和非公平就是由于sync引用指向了的不同实现,其lock unlock等操作也是一律交由到sync
2.ReentrantLock的非公平模式
2.1非公平加锁——lock方法
加锁的大致流程
- 无论是非公平还是公平在加锁成功后都会通过setExclusiveOwnerThread设置当前线程为独占锁的线程,这个方法会记住当前线程,这是后面实现可重入的关键、
- acquire 方法会调用tryAcquire方法,这个方法由AQS的子类实现,NonfairSync这里会调用nonfairTryAcquire方法
2.1.1不公平的尝试获取共享资源nonfairTryAcquire
-
如果nonfairTryAcquire返回true表示当前线程获取到了锁,那么皆大欢喜,当前线程可以继续运行
-
返回false的情况
- 共享资源是0,但是同一个时间多个线程抢占,当前这个线程CAS失败了
- 共享资源不是0,当前线程也不是独占的线程
这两种情况都需要继续执行AQS的acquire方法
2.1.2AQS的acquire 方法
独占模式获取共享资源,对中断不敏感,或者说不响应中断——获取共享资源失败的线程将会进入到同步队列,后续对此线程进行中断操作,线程不会从同步队列中移出
1.执行流程
2.将当前线程包装成Node加入到队列尾addWaiter
-
快速入队
下面这段代码值得品一品
Node pred = tail; if (pred != null) { //当前线程的前置设置为尾,这一步那么多个线程执行这一步也是无关紧要的 //只是把当前节点的前置改变了,不是改变pred的next指向 node.prev = pred; //CAS设置尾节点 为当前节点,这个自选操作compareAndSetTail是线程安全,同一时间只有一个线程可以设置自己为尾节点 if (compareAndSetTail(pred, node)) { //注意 如果原尾节点是S,线程A设置成功 那么尾巴被修改为了A,假如A执行下面一行的时候消耗完了时间片,线程B进来了,这时候线程B拿到的tail就是A,所以不会存在线程安全问题 pred.next = node; return node; } }
-
完整入队
!
完整入队和快速入队差不多,就是多了一个初始化的逻辑
那么为什么不直接完整入队,也许是for循环比if多更多的字节码需要执行?也许Doug Lea测试多次后发现快速入队后完整入队,比直接完整入队效率更高
3.尝试出队acquireQueued
-
如何从自旋中退出
前继节点是头节点,头节点是当前获取到共享资源的节点,且获取共享资源tryAcquire成功
-
挂起当前线程避免无休止的自选
自选是cpu操作,无限制的自选是很浪费cpu资源的
如果shouldParkAfterFailedAcquire放回true 表示当前线程需要被挂起,会继续执行parkAndCheckInterrupt,这个方法很简单只有两行
private final boolean parkAndCheckInterrupt() {
//挂起当前线程
LockSupport.park(this);
//返回中断状态,并且清除中断标识
return Thread.interrupted();
}
如果parkAndCheckInterrupt 返回了true 表示当前线程被中断过,并且会让外层的acquireQueued返回true,会导致acquire执行当前线程的自我中断
理解这一段代码需要对java中断机制具备一定理解
java线程中断机制
-
调用Thread的interrupt方法
- 如果线程处于Running状态那么只是修改Thread内部的中断标识值为true
- 如果线程由于sleep,wait,join等方法进入等待状态,会直接抛出中断异常并清楚中断标识
- 如果线程由于LockSupport.park进入等待状态,调用该线程的interrupt方法只会让LockSupport.park返回
-
interrupt,interrupted,isInterrupted三个方法比较
-
interrupt 见上⬆
-
interrupted 返回当前线程的中断标识并且充值中断标识
-
isInterrupted返回中断标识
-
我们继续说为什么当前线程在获取锁的途中被中断,需要自我中断以下
acquire的"需求":
独占模式获取共享资源,对中断不敏感,或者说不响应中断——获取共享资源失败的线程将会进入到同步队列,后续对此线程进行中断操作,线程不会从同步队列中移出
线程获取同步状态的时候被中断会发生什么——从LockSupport.park(this)中返回继续拿锁,这就是为什么说acquire的对中断不敏感。
LockSupport.park();不会抛出受检查异常,当出现被打断的情况下,线程被唤醒后,我们可以通过Interrupt的状态来判断,我们的线程是不是被interrupt的还是被unpark或者到达指定休眠时间
假如我们写如下这样的代码执行
存在一个调度线程中断了上面的线程,但是上面的线程还在抢夺锁,并且被park了,这时候上面线程的park会返回,并且清除中断标识,如果不进行自我中断,那么下面while内容还是会进行,那么我们调度线程的中断就无效了
3.ReentrantLock的公平模式
传入true获取一个公平锁
3.1公平的获取锁——lock方法
公平锁的lock方法直接调用AQS的acquire方法,上面我们分析的acquire方法它会先去调用tryAcquire,这个tryAcquire被FairSync重写
- FairSync的tryAcquire方法
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//共享状态当前空闲
if (c == 0) {
//前面没有节点 这就是公平是怎么实现的
//且cas成功 那么拿到锁
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;
}
}
源码没什么很难的点,就是通过判断前面时候还有节点(标识是否由线程比当前线程先到)如果没有那么再去拿锁,如果共享状态不是0且当前线程不是独占的线程那么就会执行acquireQueued方法,在acquireQueued里面自选获取锁会判断前一个节点是否是头节点且调用tryAcquire
4.释放锁
释放锁直接调用AQS的release方法,其中tryRelease方法由ReentrantLock中Sync自己实现(公平or非公平都一样)
public final boolean release(int arg) {
//完全的释放资源
if (tryRelease(arg)) {
Node h = head;
//头节点初始化的时候才为0,但是后面如果由节点加入到同步队列会把前置节点的状态设置为Singnal
if (h != null && h.waitStatus != 0)
//唤醒后继节点
unparkSuccessor(h);
return true;
}
return false;
}
4.1 tryRelease
protected final boolean tryRelease(int releases) {
//重入了n次,当前释放m次 c=n-m
int c = getState() - releases;
//如果不是独占锁的线程 那么抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//是否完全的释放了锁
boolean free = false;
//只有剩下的为0 才是完全释放锁
if (c == 0) {
//置为true
free = true;
//独占线程设置为null
setExclusiveOwnerThread(null);
}
//修改state
setState(c);
return free;
}
需要注意的是只有完全的释放了共享资源,在ReentrantLock里就是加锁n次解锁n次,才返回true,才会去唤醒后继节点
4.2 unparkSuccessor
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
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)//大于0代表放弃了
s = t;
}
if (s != null)
//唤醒
LockSupport.unpark(s.thread);
}
使用 LockSupport.unpark(s.thread)唤醒线程,这里需要品一品 Doug Lea 他为什么要从尾部开始唤醒
-
再品入队
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; }
-
快速入队
快速入队要求尾节点不为空,如果尾节点为空那么说明
- 当前没有线程竞争,锁只有一个线程再使用,直接tryAcquire就成功了,所以头和尾都没有初始化
-
完整入队
- 进入完成入队条件
- 队列头和尾没有初始化
- CAS失败,也就是说存在比较多的线程在执行快速入队
private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { //假设AB两个线程现在正在抢锁 node.prev = t; //CAS设置尾 当前线程A被设置为了尾 if (compareAndSetTail(t, node)) { //假如A执行这一行的时候用完了时间片,轮到了B B把自己设置了尾并且B的前置是A,此时A的前置还没来得及设置 //如果这个时候进行唤醒,从头开始遍历的话会发现没有后面的节点了 //所以需要从尾开始,找到B,B继续往前找到A //Doug Lea 永远的神 t.next = node; return t; } } } }
- 进入完成入队条件
-
-
为什么要从尾开始遍历
5.其他
5.1独占式尝试获取锁—— tryLock方法
这部分都是调用的nonfairTryAcquire方法,也就是是说无论是公平还是非公平都是直接不公平的获取资源。tryLock方法是直接尝试,只有当前共享资源没有被占用的时候返回true,否则false 并且是立即返回所以无论是公平还是非公平,调用这个方法都是一样的逻辑——有人占着厕所那就直接回去继续工作
5.2 独占式响应中断的获取锁——lockInterruptibly
这个方法直接调用了AQS的acquireInterruptibly(1)
public final void acquireInterruptibly(int arg)
throws InterruptedException {
//如果已经中断了那么抛出中断异常
//Thread.interrupted() 会清除中断标识,因为抛出InterruptedException就是响应了中断,
if (Thread.interrupted())
throw new InterruptedException();
//调用公平or非公平自己复写的方法
if (!tryAcquire(arg))
//如果尝试获取共享资源失败了 那么入队,自旋的共享资源
doAcquireInterruptibly(arg);
}
5.2.1 doAcquireInterruptibly
基本上和acquireQueued差不多,就是自旋时发现中断了那么抛出中断异常,注意parkAndCheckInterrupt是调用的Thread.interrupted(),会清除中断标识
个人认为
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
//不是interrupted 而是 isInterrupted
return Thread.isInterrupted();
}
也可以实现功能,无非使用的地方比如doAcquireInterruptibly 要先调用Thread.interrupted()然后抛出异常
并且acquire方法也不要执行自我中断
5.2.2 cancelAcquire
放弃共享资源的争抢,一般是等待超时,或者被中断后响应中断
总体上就是,如果当前节点前面右节点可以唤醒当前节点的后继节点,那么CAS设置,否则直接唤醒后面的节点,并且把自己从队列移除
5.3超时并响应中断的获取锁——tryLock(long timeout, TimeUnit unit)
直接调用了AQS的tryAcquireNanos方法
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
//上来就判断下是否中断了
if (Thread.interrupted())
throw new InterruptedException();
//尝试获取锁 (调用对应公平和非公平的方法)or doAcquireNanos
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
5.3.1doAcquireNanos
大致逻辑还是那些,需要注意的是,nanosTimeout > spinForTimeoutThreshold,剩余时间大于阈值(1000)才会挂起,如果小于的化还是进行自旋,因为非常短的超时时间无法做到十分精确(挂起和唤醒也是需要时间的)如果还是进行超时等待反而会表现得不精确