大家好,我是小黑,一个在互联网苟且偷生的农民工。

在Java并发编程中,经常会用到锁,除了Synchronized这个JDK关键字以外,还有Lock接口下面的各种锁实现,如重入锁ReentrantLock,还有读写锁ReadWriteLock等,他们在实现锁的过程中都是依赖与AQS来完成核心的加解锁逻辑的。那么AQS具体是什么呢?

提供一个框架,用于实现依赖先进先出(FIFO)等待队列的阻塞锁和相关同步器(信号量,事件等)。 该类被设计为大多数类型的同步器的有用依据,这些同步器依赖于单个原子int值来表示状态。 子类必须定义改变此状态的受保护方法,以及根据该对象被获取或释放来定义该状态的含义。 给定这些,这个类中的其他方法执行所有排队和阻塞机制。 子类可以保持其他状态字段,但只以原子方式更新int使用方法操纵值getState() , setState(int)和compareAndSetState(int, int)被跟踪相对于同步。

上述内容来自JDK官方文档。

简单来说,AQS是一个先进先出(FIFO)的等待队列,主要用在一些线程同步场景,需要通过一个int类型的值来表示同步状态。提供了排队和阻塞机制。

类图结构

从类图可以看出,在ReentrantLock中定义了AQS的子类Sync,可以通过Sync实现对于可重入锁的加锁,解锁。

AQS通过int类型的状态state来表示同步状态。

AQS中主要提供的方法:

acquire(int) 独占方式获取锁

acquireShared(int) 共享方式获取锁

release(int) 独占方式释放锁

releaseShared(int) 共享方式释放锁

独占锁和共享锁

关于独占锁和共享锁先给大家普及一下这个概念。

独占锁指该锁只能同时被一个线程持有;

共享锁指该锁可以被多个线程同时持有。

举个生活中的例子,比如我们使用打车软件打车,独占锁就好比我们打快车或者专车,一辆车只能让一个客户打到,不能两个客户同时打到一辆车;共享锁就好比打拼车,可以有多个客户一起打到同一辆车。

AQS内部结构

我们简单通过一张图先来了解下AQS的内部结构。其实就是有一个队列,这个队列的头结点head代表当前正在持有锁的线程,后续的其他节点代表当前正在等待的线程。


接下来我们通过源码来看看AQS的加锁和解锁过程。先来看看独占锁是如何进行加解锁的。

独占锁加锁过程

ReentrantLock lock = new ReentrantLock();
lock.lock();
public void lock() {
// 调用sync的lock方法
sync.lock();
}

可以看到在ReentrantLock的lock方法中,直接调用了sync这个AQS子类的lock方法。

final void lock() {
// 获取锁
acquire(1);
}
public final void acquire(int arg) {
// 1.先尝试获取,如果获取成功,则直接返回,代表加锁成功
if (!tryAcquire(arg) &&
// 2.如果获取失败,则调用addWaiter在等待队列中增加一个节点
// 3. 调用acquireQueued告诉前一个节点,在解锁之后唤醒自己,然后线程进入等待状态
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 如果在等待过程中被中断,则当前线程中断
selfInterrupt();
}

在获取锁时,基本可以分为3步:

  1. 尝试获取,如果成功则返回,如果失败,执行下一步;
  2. 将当前线程放入等待队列尾部;
  3. 标记前面等待的线程执行完之后唤醒当前线程。
/**
* 尝试获取锁(公平锁实现)
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 获取state,初始值为0,每次加锁成功会+1,解锁成功-1
int c = getState();
// 当前没有线程占用
if (c == 0) {
// 判断是否有其他线程排队在本线程之前
if (!hasQueuedPredecessors() &&
// 如果没有,通过CAS进行加锁
compareAndSetState(0, acquires)) {
// 将当前线程设置为AQS的独占线程
setExclusiveOwnerThread(current);
return true;
}
}
// 如果当前线程是正在独占的线程(已持有锁,重入)
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
// state+1
setState(nextc);
return true;
}
return false;
}
private Node addWaiter(Node mode) {
// 创建一个当前线程的Node节点
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
// 如果等待队列的尾节点!=null
if (pred != null) {
// 将本线程对应节点的前置节点设置为原来的尾节点
node.prev = pred;
// 通过CAS将本线程节点设置为尾节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//尾节点为空,或者在CAS时失败,则通过enq方法重新加入到尾部。(本方法内部采用自旋)
enq(node);
return node;
} private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 尾节点为空,代表等待队列还没有被初始化过
if (t == null) {
// 创建一个空的Node对象,通过CAS赋值给Head节点,如果失败,则重新自旋一次,如果成功,将Head节点赋值给尾节点
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 尾节点不为空的情况,说明等待队列已经被初始化过,将当前节点的前置节点指向尾节点
node.prev = t;
// 将当前节点CAS赋值给尾节点
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
final boolean acquireQueued(final Node node, int arg) {
// 标识是否加锁失败
boolean failed = true;
try {
// 是否被中断
boolean interrupted = false;
for (;;) {
// 取出来当前节点的前一个节点
final Node p = node.predecessor();
// 如果前一个节点是head节点,那么自己就是老二,这个时候再尝试获取一次锁
if (p == head && tryAcquire(arg)) {
// 如果获取成功,把当前节点设置为head节点
setHead(node);
p.next = null; // help GC
failed = false; // 标识加锁成功
return interrupted;
}
// shouldParkAfterFailedAcquire 检查并更新前置节点p的状态,如果node节点应该阻塞就返回true
// 如果返回false,则自旋一次。
if (shouldParkAfterFailedAcquire(p, node) &&
// 当前线程阻塞,在阻塞被唤醒时,判断是否被中断
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed) // 如果加锁成功,则取消获取锁
cancelAcquire(node);
}
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // ws == -1
/*
* 这个节点已经设置了请求释放的状态,所以它可以在这里安全park.
*/
return true;
if (ws > 0) {
/*
* 前置节点被取消了,跳过前置节点重试
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 将前置节点的状态设置为请求释放
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

在整个加锁过程可以通过下图更清晰的理解。

独占锁解锁过程

public void unlock() {
sync.release(1);
}

同样解锁时也是直接调用AQS子类sync的release方法。

public final boolean release(int arg) {
// 尝试解锁
if (tryRelease(arg)) {
Node h = head;
// 解锁成功,如果head!=null并且head.ws不等0,代表有其他线程排队
if (h != null && h.waitStatus != 0)
// 唤醒后续等待的节点
unparkSuccessor(h);
return true;
}
return false;
}

解锁过程如下:

  1. 先尝试解锁,解锁失败则直接返回false。(理论上不会解锁失败,因为正在执行解锁的线程一定是持有锁的线程)
  2. 解锁成功之后,如果有head节点并且状态不是0,代表有线程被阻塞等待,则唤醒下一个等待的线程。
protected final boolean tryRelease(int releases) {
// state - 1
int c = getState() - releases;
// 如果当前线程不是独占AQS的线程,但是这时候又来解锁,这种情况肯定是非法的。
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { // 如果状态归零,代表锁释放了,将独占线程设置为null
free = true;
setExclusiveOwnerThread(null);
}
// 将减1之后的状态设置为state
setState(c);
return free;
}
private void unparkSuccessor(Node node) {
/*
* 如果节点的ws小于0,将ws设置为0
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); /*
* 从等待队列的尾部往前找,直到第二个节点,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)
s = t;
}
// 如果存在符合条件的节点,unpark唤醒这个节点的线程。
if (s != null)
LockSupport.unpark(s.thread);
}

共享锁加锁过程

为了实现共享锁,AQS中专门有一套和排他锁不同的实现,我们来看一下源码具体是怎么做的。

public void lock() {
sync.acquireShared(1);
}
public final void acquireShared(int arg) {
// tryAcquireShared 尝试获取共享锁许可,如果返回负数标识获取失败
// 返回0表示成功,但是已经没有多余的许可可用,后续不能再成功,返回正数表示后续请求也可以成功
if (tryAcquireShared(arg) < 0)
// 申请失败,则加入到共享等待队列
doAcquireShared(arg);
}

tryAcquireShared尝试获取共享许可,本方法需要在子类中进行实现。不同的实现类实现方式不一样。

下面的代码是ReentrentReadWriteLock中的实现。

 protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 当前有独占线程正在持有许可,并且独占线程不是当前线程,则返回失败(-1)
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 没有独占线程,或者独占线程是当前线程。
// 获取已使用读锁的个数
int r = sharedCount(c);
// 判断当前读锁是否应该阻塞
if (!readerShouldBlock() &&
// 已使用读锁小于最大数量
r < MAX_COUNT &&
// CAS设置state,每次加SHARED_UNIT标识共享锁+1
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) { // 标识第一次加读锁
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 重入加读锁
firstReaderHoldCount++;
} else {
// 并发加读锁,记录当前线程的读的次数,HoldCounter中是一个ThreadLocal。
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 否则自旋尝试获取共享锁
return fullTryAcquireShared(current);
}

本方法可以总结为三步:

  1. 如果有写线程独占,则失败,返回-1
  2. 没有写线程或者当前线程就是写线程重入,则判断是否读线程阻塞,如果不用阻塞则CAS将已使用读锁个数+1
  3. 如果第2步失败,失败原因可能是读线程应该阻塞,或者读锁达到上限,或者CAS失败,则调用fullTryAcquireShared方法。
private void doAcquireShared(int arg) {
// 加入同步等待队列,指定是SHARED类型
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 取到当前节点的前一个节点
final Node p = node.predecessor();
// 如果前一个节点是头节点,则当前节点是第二个节点。
if (p == head) {
// 因为是FIFO队列,所以当前节点这时可以再尝试获取一次。
int r = tryAcquireShared(arg);
if (r >= 0) {
// 获取成功,把当前节点设置为头节点。并且判断是否需要唤醒后面的等待节点。
// 如果条件允许,就会唤醒后面的节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 如果前置节点不是头结点,说明当前节点线程需要阻塞等待,并告知前一个节点唤醒
// 检查并更新前置节点p的状态,如果node节点应该阻塞就返回true
// 当前线程被唤醒之后,会从parkAndCheckInterrupt()执行
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

共享锁释放过程

public void unlock() {
sync.releaseShared(1);
} public final boolean releaseShared(int arg) {
//tryReleaseShared()尝试释放许可,这个方法在AQS中默认抛出一个异常,需要在子类中实现
if (tryReleaseShared(arg)) {
// 唤醒线程,设置传播状态 WS
doReleaseShared();
return true;
}
return false;
}

AQS是很多并发场景下同步控制的基石,其中的实现相对要复杂很多,还需要多看多琢磨才能完全理解。本文也是和大家做一个初探,给大家展示了核心的代码逻辑,希望能有所帮助。


好的,本期内容就到这里,我们下期见;关注公众号【小黑说Java】更多干货。

并发编程之:AQS源码解析的更多相关文章

  1. 并发编程之 AQS 源码剖析

    前言 JDK 1.5 的 java.util.concurrent.locks 包中都是锁,其中有一个抽象类 AbstractQueuedSynchronizer (抽象队列同步器),也就是 AQS, ...

  2. 并发编程之 Condition 源码分析

    前言 Condition 是 Lock 的伴侣,至于如何使用,我们之前也写了一些文章来说,例如 使用 ReentrantLock 和 Condition 实现一个阻塞队列,并发编程之 Java 三把锁 ...

  3. 并发编程之 Exchanger 源码分析

    前言 JUC 包中除了 CountDownLatch, CyclicBarrier, Semaphore, 还有一个重要的工具,只不过相对而言使用的不多,什么呢? Exchange -- 交换器.用于 ...

  4. 并发编程之 Semaphore 源码分析

    前言 并发 JUC 包提供了很多工具类,比如之前说的 CountDownLatch,CyclicBarrier ,今天说说这个 Semaphore--信号量,关于他的使用请查看往期文章并发编程之 线程 ...

  5. 多线程进阶——JUC并发编程之CountDownLatch源码一探究竟

    1.学习切入点 JDK的并发包中提供了几个非常有用的并发工具类. CountDownLatch. CyclicBarrier和 Semaphore工具类提供了一种并发流程控制的手段.本文将介绍Coun ...

  6. Java并发编程之AbstractQueuedSynchronizer源码分析

    为什么要说AbstractQueuedSynchronizer呢? 因为AbstractQueuedSynchronizer是JUC并发包中锁的底层支持,AbstractQueuedSynchroni ...

  7. 并发编程之 CyclicBarrier 源码分析

    前言 在之前的介绍 CountDownLatch 的文章中,CountDown 可以实现多个线程协调,在所有指定线程完成后,主线程才执行任务. 但是,CountDownLatch 有个缺陷,这点 JD ...

  8. 并发编程之 CountDown 源码分析

    前言 Doug Lea 大神在 JUC 包中为我们准备了大量的多线程工具,其中包括 CountDownLatch ,名为倒计时门栓,好像不太好理解.不过,今天的文章之后,我们就彻底理解了. 如何使用? ...

  9. 并发编程之 ConcurrentLinkedQueue 源码剖析

    前言 今天我们继续分析 java 并发包的源码,今天的主角是谁呢?ConcurrentLinkedQueue,上次我们分析了并发下 ArrayList 的替代 CopyOnWriteArrayList ...

  10. 并发编程之 LinkedBolckingQueue 源码剖析

    前言 JDK 1.5 之后,Doug Lea 大神为我们写了很多的工具,整个 concurrent 包基本都是他写的.也为我们程序员写好了很多工具,包括我们之前说的线程池,重入锁,线程协作工具,Con ...

随机推荐

  1. 第十七篇 -- QTreeWidget与QDockWidget

    效果图: 目录和工具条的创建在前面几节就已经学过了,所以目录和工具条的布局可以自己画. 那么下面的部分,左侧是一个DockWidget,里面放置一个TreeWidget.右边是一个ScrollArea ...

  2. mysql 版本在springboot 中定义位置

  3. intouch制作历史趋势公用弹窗

    在先前项目中,历史趋势都是作为一个总体的画面,然后添加下拉菜单选择来配合使用.在新项目中,业主要求在相应的仪表上直接添加历史趋势,这就需要利用公用弹窗来制作历史趋势了. 1.窗体建立 窗体建立是比较简 ...

  4. Tomcat 性能监控与优化

    JMX JMX(Java Management Extensions)是一个为应用程序植入管理功能的框架.JMX是一套标准的 代理和服务,实际上,用户可以在任何Java应用程序中使用这些代理和服务实现 ...

  5. MegEngine TensorCore 卷积算子实现原理

    作者:章晓 | 旷视 MegEngine 架构师 一.前言 2020 年 5 月 Nvidia 发布了新一代的 GPU 架构安培(Ampere).其中和深度学习关系最密切的莫过于性能强劲的第三代的 T ...

  6. 最高级的AutoHotkey重试源代码结构20191221.docx

    ;; 最高级的AutoHotkey重试源代码结构20191221.docx;; 在编写AutoHotkey脚本时经常要用到重试,; 单击控件无效时需要重新再单击,; 发送模拟按键无效时需要重新发送.; ...

  7. SQL根据两个日期生成年、月、日

    1 DECLARE @beginTime DATETIME, @endTime DATETIME 2 SET @beginTime ='2019-03-01' 3 SET @endTime ='201 ...

  8. JAVA虚拟机的组成>从零开始学java系列

    目录 JAVA虚拟机的组成 什么是虚拟机? JAVA虚拟机的组成部分 堆区(堆内存) 方法区 虚拟机栈 本地方法栈 程序计数器 字符串常量池 JAVA虚拟机的组成 什么是虚拟机? 虚拟机是运行在隔离环 ...

  9. 自学linux——2.认识目录及常用指(命)令

    认识目录及常用指(命)令 1.备份: 快照(还原精灵):短期备份  频繁备份  可关可开.可能会影响系统的操作. 备份时:虚拟机--快照 还原时:虚拟机--快照--快照管理器--相应位置--转到 克隆 ...

  10. 面试常见SQL中where和having的区别你确定你知道吗!

    "Where" 是一个约束声明,使用Where来约束来之数据库的数据,Where是在结果返回之前起作用的,且Where中不能使用聚合函数. "Having" 是 ...