概述

上一篇我们讲了AQS的使用,这一篇讲AQS的内部实现原理。

我们前面介绍了,AQS使用一个int变量state表示同步状态,使用一个隐式的FIFO同步队列(隐式队列就是并没有声明这样一个队列,只是通过每个节点记录它的上个节点和下个节点来从逻辑上产生一个队列)来完成阻塞线程的排队。

这里FIFO队列的节点在AQS中被定义为一个内部类Node,Node的主要字段有:

  1. waitStatus:等待状态,所有的状态见下面的表格。
  2. prev:前驱节点
  3. next:后继节点
  4. thread:当前节点代表的线程
  5. nextWaiter:Node既可以作为同步队列节点使用,也可以作为Condition的等待队列节点使用(将会在后面讲Condition时讲到)。在作为同步队列节点时,nextWaiter可能有两个值:EXCLUSIVE、SHARED标识当前节点是独占模式还是共享模式;在作为等待队列节点使用时,nextWaiter保存后继节点。
状态 含义
CANCELLED 1 当前节点因为超时或中断被取消同步状态获取,该节点进入该状态不会再变化
SIGNAL -1 标识后继的节点处于阻塞状态,当前节点在释放同步状态或被取消时,需要通知后继节点继续运行。每个节点在阻塞前,需要标记其前驱节点的状态为SIGNAL。
CONDITION -2 标识当前节点是作为等待队列节点使用的。
PROPAGATE -3  
0 0 初始状态

队列拥有首节点和尾节点,这两个节点分别保存于AQS的两个字段:head、tail。

同步队列的基本结构:

当一个线程想要获得同步状态的时候,如果当前有其他线程持有同步状态,当前线程将无法获取,转而被构造为一个Node添加到同步队列的尾部,而这个加入的过程必须保证线程安全,因此同步器提供了一个基于CAS的设置队尾的方法:compareAndSetTail(Node expect, Node update),它需要传递当前线程"认为"的队尾。在一个Node被CAS设置为队列之前,这个Node的prev已经被设置为之前的尾节点,而在这个Node被设置为队尾之后,之前尾节点的next才会被指向这个Node。因此在任一时刻,从head向后遍历队列不一定能遍历到tail,因为最后的tail可能还没有被倒数第二个节点指为next,但是从tail向head遍历一定能遍历head。记住这个结论之后会用到。

在队列中,首节点是当前获取同步状态成功的节点,首节点在释放同步状态时,会唤醒后继节点,而后继节点会在自己获取同步状态成功时,将自己设置为首节点。因为设置首节点是由持有同步状态的线程来完成的,因此不需要使用CAS来保证线程安全,只需要持有同步状态的线程将首节点设置为原首节点的后继节点并断开原首节点的next引用即可。

独占式同步状态获取与释放

获取

获取独占同步状态使用方法acquire(int arg):

    public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

其中tryAcquire(arg)方法是我们继承AQS的子类重写的方法,可以看到,如果tryAcquire返回了true代表获取锁成功,则acquire方法立即返回。如果tryAcquire返回了false我们知道AQS应该完成的操作是阻塞当前线程直到当前线程获取到同步状态。可以看出会依次调用方法addWaiter、acquireQueued。

先来看addWaiter:

    private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode); // 将当前线程构造为Node,mode传入值为Node.EXCLUSIVE,将保存在Node的nextWaiter字段,标识当前节点为独占模式
// 如果当前尾节点不为空,尝试设置当前节点为尾节点,这块是完整设置尾节点的一个简单实现,如果这个能成功,不用调用完整设置尾节点的方法enq了,如果失败,则调用enq方法。
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
   // 完整的设置尾节点方法,如果当前节点不为空,则把当前节点设为尾节点,并将原尾节点next指向当前节点;如果当前尾节点为空,即当前同步队列为空,则新建一个傀儡节点作为首节点和尾节点,然后再将当前节点设为尾节点。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

然后看acquireQueued:

    final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
   //如果当前节点的前驱是头节点,说明即将轮到自己获得同步状态,再次调用tryAcquire检查是否能获取到同步状态(这里之所以要再次检查,有两个原因:
一是因为尽管当前节点排到首节点后面,而且已经被首节点唤醒,但是首节点在唤醒当前节点后,并不是马上释放同步状态;
二是因为如果此时有新的线程第一次尝试获取同步状态正好赶在首节点释放同步状态,那么新的线程可能直接就不排队了直接获取到同步状态。)
if (p == head && tryAcquire(arg)) {
setHead(node); //获取到同步状态则将自己设为头节点
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
} private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
//如果前驱节点已经是SIGNAL状态则前驱节点执行完成后会唤醒当前节点
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
//前驱节点状态为CANCELLED,则继续查找前驱节点的前驱节点,因为当首节点唤醒时,会跳过CANCELLED节点
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
//如果是0或PROPAGATE状态,则用CAS设置为SIGNAL
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
} //该方法在 shouldParkAfterFailedAcquire 方法返回true后执行,shouldParkAfterFailedAcquire 方法返回true代表前驱节点已经被设置为SIGNAL状态,
因此当前节点可以阻塞等待唤醒了,使用LockSupport.park(this)方法来阻塞。这个方法会一直阻塞直到首节点唤醒当前节点或当前节点被中断,如果是被中断,中断标识将会被一直往上层方法传,最终acquire方法会执行selfInterrupt。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}

释放

释放同步状态通过方法release(int arg):

    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方法是我们自己在AQS子类中重写的方法,可以看到release方法的逻辑比较简单,如果tryRelease方法返回false,那么release方法直接返回false;如果tryRelease方法返回true则通过unparkSuccessor方法唤醒后继节点:

    private void unparkSuccessor(Node node) {
//如果头节点的状态是负值,将其归0.如果失败了也ok。
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); //通常要唤醒的节点就是头节点的直接后继节点,但是如果直接后继节点是null或状态为CANCELLED,则从tail向前遍历取离head最近的一个非CANCELLED状态的节点。这里之所以要从tail向前遍历,前面说过原因:最后的tail节点在构造的时候在某时刻可能只有其向前一个节点的prev引用,而没有前一个节点向它的next引用。
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)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread); // 唤醒下一个节点。这里要注意理论上可能头节点唤醒下一个节点的时候,下一个节点还没有通过park方法阻塞,而LockSupport方法在这种情况的表现是:如果先调用了unpark方法,那么之后调用park时将不会阻塞。因此在这种情况下也不会有什么问题。
}

共享式同步状态获取与释放

共享式同步状态获取/释放和独占式状态获取/释放大体一致,只是加了释放传播:

我们举个例子,比如我们要定义一个类似于Semaphore的同步组件,支持n个线程可以同时获取同步状态,超过n时则阻塞,假如AQS没有给我们提供共享式的tryAcquireShared和tryReleaseShared方法,我们试图用独占式方法来实现这个组件,那么我们可能会这样重写tryAcquire和tryRelease(只贴出AQS的子类实现,其他代码略):

    private static class SemaphoreSynchronizer extends AbstractQueuedSynchronizer {
public SemaphoreSynchronizer(int arg) {
setState(arg); //用state表示当前可用许可数
} @Override
protected boolean tryAcquire(int arg) {
for (;;) {
int state = getState();
int newState = state - 1; //许可数-1 //如果已经没有许可可用,则返回false
if (newState < 0) {
return false;
} //如果有许可可用而且CAS成功,则返回true,否则循环重新判断是否有许可可用
if (compareAndSetState(state, newState)) {
return true;
}
} } @Override
protected boolean tryRelease(int arg) {
for (;;) {
int current = getState();
int newCount = current + 1; //释放成功则许可数+1
//如果释放成功返回true,否则循环重新释放
if (compareAndSetState(current, newCount)) {
return true;
}
}
} protected Condition newCondition() {
return new ConditionObject();
} }

这样实现的话仔细想就会发现有问题:

比如许可数设为3,当前正有t1、t2、t3这三个线程在运行,然后来了两个线程t4、t5被阻塞了,因为t1、t2、t3是并发运行,因此假设t1和t2同时释放许可,独占式释放同步状态代码如下:

    public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}

这两个线程在并发条件下,Node h=head这句可能h同时指向t4,然后t4被唤醒2次,最终的结果就是t3和t4在执行,而t5在被阻塞,尽管有效许可数是3。

为了避免这个问题,需要在共享式同步状态的释放和获取处都做一些工作。

释放

    public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
} private void doReleaseShared() {
for (;;) {
Node h = head; //获取首节点
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) { //如果首节点的状态是SIGNAL,则CAS修改SIGNAL为0,如果成功就唤醒后继节点,失败则重新获取首节点
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 && //如果首节点状态是0,则将状态改为PROPAGATE(传播状态)
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // 这个是外面一层for循环的终止条件,外面一层循环的意义在于如果首节点在以上操作中发生了变化,那么可能有其他节点已经唤醒了之前获取的首节点的后继节点,于是当前线程要获取新的首节点的后继节点。
break;
}
}

获取

    public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
} private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
} private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node); if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}

和独占式获取相比,主要区别在于setHeadAndPropagate方法:

一个节点在获取了同步状态后,不仅把自己设置为头节点,而且如果当前同步状态>0||原head为null||原head的状态<0||当前head为null||当前状态<0,且下一个节点的类型为null(类型未知)||下一个节点类型为shared,则继续唤醒下一个节点。

注:节点状态<0意味着是SIGNAL或PROPAGATE。

总结

AQS的实现中,独占式状态获取与释放比较简单容易理解,共享式状态获取与释放比较复杂。不过就实际应用而言,相信我们也不需要了解里面的所有细节,只需要理解原理即可。

Java显式锁学习总结之三:AbstractQueuedSynchronizer的实现原理的更多相关文章

  1. Java显式锁学习总结之二:使用AbstractQueuedSynchronizer构建同步组件

    Jdk1.5中包含了并发大神Doug Lea写的并发工具包java.util.concurrent,这个工具包中包含了显示锁和其他的实用同步组件.Doug Lea在构建锁和组件的时候,大多是以队列同步 ...

  2. Java显式锁学习总结之六:Condition源码分析

    概述 先来回顾一下java中的等待/通知机制 我们有时会遇到这样的场景:线程A执行到某个点的时候,因为某个条件condition不满足,需要线程A暂停:等到线程B修改了条件condition,使con ...

  3. Java显式锁学习总结之五:ReentrantReadWriteLock源码分析

    概述 我们在介绍AbstractQueuedSynchronizer的时候介绍过,AQS支持独占式同步状态获取/释放.共享式同步状态获取/释放两种模式,对应的典型应用分别是ReentrantLock和 ...

  4. Java显式锁学习总结之一:概论

    我们都知道在java中,当多个线程需要并发访问共享资源时需要使用同步,我们经常使用的同步方式就是synchronized关键字,事实上,在jdk1.5之前,只有synchronized一种同步方式.而 ...

  5. Java显式锁学习总结之四:ReentrantLock源码分析

    概述 ReentrantLock,即重入锁,是一个和synchronized关键字等价的,支持线程重入的互斥锁.只是在synchronized已有功能基础上添加了一些扩展功能. 除了支持可中断获取锁. ...

  6. Java显式锁

    Java 显式锁. 一.显式锁 什么是显式锁? 由自己手动获取锁,然后手动释放的锁. 有了 synchronized(内置锁) 为什么还要 Lock(显示锁)? 使用 synchronized 关键字 ...

  7. Java并发编程之显式锁机制

    我们之前介绍过synchronized关键字实现程序的原子性操作,它的内部也是一种加锁和解锁机制,是一种声明式的编程方式,我们只需要对方法或者代码块进行声明,Java内部帮我们在调用方法之前和结束时加 ...

  8. “全栈2019”Java多线程第三十二章:显式锁Lock等待唤醒机制详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  9. “全栈2019”Java多线程第三十一章:中断正在等待显式锁的线程

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

随机推荐

  1. OI队测题解:

    Test 17   T1: 题目大意: 喵星系有n个星球,标号为1到n,星球以及星球间的航线形成一棵树. 所有星球间的双向航线的长度都为1.小昕要在若干个星球建矿石仓库,设立每个仓库的费用为K.对于未 ...

  2. BZOJ4033:[HAOI2015]树上染色——题解

    https://www.lydsy.com/JudgeOnline/problem.php?id=4033 有一棵点数为N的树,树边有边权.给你一个在0~N之内的正整数K,你要在这棵树中选择K个点,将 ...

  3. 强大工具psexec工具用法简介

    原文链接地址:https://www.cnblogs.com/boltkiller/articles/4791307.html psexec是sysinternals的一款强大的软件,通过他可以提权和 ...

  4. 网络协议之mDNS20170217

    DNS(Domain Name System,域名系统)因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串.通过主机名,最终得 ...

  5. uCOS-II之移植20160823

    首先我们看一下uC/OS-II的框架图: 1.配置文件修改 +------------------------------------------ |core: os_core.c |  os: os ...

  6. 【题解】【LibreOJ Round #6】花团 LOJ 534 时间线段树分治 背包

    Prelude 题目链接:萌萌哒传送门(/≧▽≦)/ Solution 如果完全离线的话,可以直接用时间线段树分治来做,复杂度\(O(qv \log q)\). 现在在线了怎么办呢? 这其实是个假在线 ...

  7. ubuntu 安装部分设置U盘启动系统安装盘操作

    https://jingyan.baidu.com/article/6079ad0ec4925728ff86db02.html 第三种方法适合新机型,在启动时按F10或F11,可直接选择启动模式 -- ...

  8. animatescroll.min.js ~~~~ jq滚动效果 优化target自定义方法

    $(".meun>div[name='meun_nav']>a").eq(1).on("click",function(){ $("bod ...

  9. Splay 区间操作

    据大佬说,\(Splay\)是序列操作之王.\(Splay\)是一种平衡树,通过伸展(\(Splay\)),在不改变中序遍历的前提下变换根的位置,从而快速的进行序列操作 \(Splay\)最常见的序列 ...

  10. 复制自身程序到windows目录和system32目录下

    功能:复制自身到windows目录和system32目录下. 参考代码: #include <stdio.h> #include <windows.h> void CopySe ...