在本文中并不会去很深入的去全面的了解 AQS
和 ReentrantLock
的源码,旨在能够简单直接的去理解 AQS
的思想和 ReentrantLock
中这些思想的具体体现形式,并且主要以 ReentrantLock
中默认的非公平锁为例子进行介绍,公平锁的差距会略微提及,详细的可查看参考的资料进行查看,相信各位小伙伴看完这一块之后多少会有一点收获和帮助。
参考资料:
万字图文 | 聊一聊 ReentrantLock 和 AQS 那点事
深入剖析ReentrantLock公平锁与非公平锁源码实现
线程中断:Thread类中interrupt()、interrupted()和 isInterrupted()方法详解
在 Java 中,AQS
是 AbstractQueuedSynchronizer
的简称,直译过来是抽象队列同步器。AbstractQueuedSynchronizer
是一个提供了基于 FIFO
等待队列实现的同步器框架,是 Java 并发库中锁和同步器的核心实现之一。它允许开发人员通过继承 AQS
类来实现自定义同步器,从而为多线程程序提供可靠的同步机制。
AQS
的核心思想是,将等待共享资源的线程封装在一个 FIFO
队列中,然后用 CAS
操作等原子操作来修改该队列中的头结点和尾结点。对于独占式同步器(例如 ReentrantLock
),AQS
还提供了一个 state
变量,用于记录当前占用该同步器的线程数。每次执行 acquire
操作时,线程会尝试获取同步器的状态。如果成功获取,则该线程可以继续执行;否则,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS
中会将竞争共享资源失败的线程添加到一个变体的 CLH
队列中。
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer implements java.io.Serializable {
// CLH 变体队列头、尾节点
private transient volatile Node head;
private transient volatile Node tail;
// AQS 同步状态
private volatile int state;
// CAS 方式更新 state
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
}
简单来说,
AQS
是Java中的一个抽象类,为开发者提供了一种非常灵活的同步机制,可以适用于多种场景,相比较于传统的synchronized
关键字更加高效和可定制化。
CLH(Craig、Landin and Hagersten)
队列,是 单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现 前驱节点释放了锁就结束自旋,其主要有以下特点:
CLH
队列是一个单向链表,保持 FIFO
先进先出的队列特性;tail
尾节点(原子引用)来构建队列,总是指向最后一个节点;AQS
中的队列是 CLH
变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配,相对于普通的 CLH
队列来说,其主要有以下特点:
AQS
中队列是个双向链表,也是 FIFO
先进先出的特性;Head
、Tail
头尾两个节点来组成队列结构,通过 volatile
修饰保证可见性;Head
指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程;CLH
队列性能较好。独占锁也叫排它锁,是指该锁一次只能被一个线程所持有,如果别的线程想要获取锁,只有等到持有锁线程释放。获得排它锁的线程即能读数据又能修改数据,与之对立的就是共享锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独占锁 | 共享锁 | |
---|---|---|
独占锁 | 不可共存 | 不可共存 |
共享锁 | 不可共存 | 可共存 |
ReentrantLock
翻译为 可重入锁,指的是一个线程能够对 临界区共享资源进行重复加锁,确保线程安全最常见的做法是利用锁机制如 Lock、sychronized
来对 共享数据做互斥同步,这样在同一个时刻,只有 一个线程可以执行某个方法或者某个代码块,那么操作必然是 原子性的,线程安全的,与 sychronized
主要有以下区别:
Synchronized | ReentrantLock | |
---|---|---|
锁实现机制 | 对象头监视器模式 | 依赖 AQS |
灵活性 | 不灵活 | 支持响应中断、超时、尝试获取锁 |
释放锁形式 | 自动释放锁 | 显示调用 unlock() |
支持锁类型 | 非公平锁 | 公平锁 & 非公平锁 |
条件队列 | 单条件队列 | 多个条件队列 |
是否支持可重入 | 支持 | 支持 |
抽象类 AQS
同样继承自抽象类 AOS(AbstractOwnableSynchronizer)
,其内部只有一个 Thread 类型的变量,提供了获取和设置当前独占锁线程的方法,主要作用是 记录当前占用独占锁(互斥锁)的线程实例。
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
// 独占线程(不参与序列化)
private transient Thread exclusiveOwnerThread;
// 设置当前独占的线程
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
// 返回当前独占的线程
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}
到这里的之前六点都是为了这里的加锁和后续的解锁过程进行一个前置准备,从这里开始将重点介绍独占锁,也就是 ReentrantLock
的非公平锁的加锁和解锁过程。开始之前先放一张我个人理解的加锁流程图:
下面将从创建 ReentrantLock
到底层 AQS
实现的顺序,和大家一起一步步的查看其中的源码:
ReentrantLock
具有公平锁和非公平锁的实现,默认是非公平锁,如果后续需要尝试非公平锁的话可以通过构造器 public ReentrantLock(boolean fair)
进行创建:public static void main(String[] args) {
// 创建非公平锁
ReentrantLock lock = new ReentrantLock();
// 获取锁操作
lock.lock();
try {
// 执行代码逻辑
} catch (Exception ex) {
// 异常处理逻辑
} finally {
// 解锁操作
lock.unlock();
}
}
lock()
方法进去可以发现是调用了内部类实现的同步器的上锁方法 lock()
,我们继续点进去选择非公平锁实现就能找到对应的上锁逻辑:public void lock() {
sync.lock();
}
--- 选择 NonfairSync --
final void lock() {
// 使用CAS尝试获得锁,非公平的体现
if (compareAndSetState(0, 1))
// 为前面提及的AOS方法,获取锁成功便设置独占线程为当前线程
setExclusiveOwnerThread(Thread.currentThread());
else
// AQS思想的体现
acquire(1);
}
if 块中应该还算是很简单的逻辑的,由于是非公平锁,所以能够直接尝试去获得锁而不会直接被安排去阻塞入队,如果对 CAS
不了解并且感兴趣的小伙伴可以前往之前的文章【并发编程】CAS
到底是什么。
接下来我们主要看 else 块中的 acquire()
方法,其对整个 AQS
做到了承上启下的作用,通过 tryAcquire()
模版方法进行尝试获取锁,获取锁失败包装当前线程为 Node
节点加入等待队列排队:
// 为了方便查看我给 if 块加了大括号, 并调整了if中的换行,源码中是没有的(可能是JDK开发者的风格如此,我看着挺难受的)
public final void acquire(int arg) {
// 再次尝试获得锁,如果失败了取反之后为真,便会执行后面的 acquireQueued 方法将当前线程包装入队
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
// if块逻辑,当再次获取锁失败和包装入队成功后,将当前线程标记为中断状态
selfInterrupt();
}
}
// 源码
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire()
方法,看一下非公平锁的这个方法是怎么实现的,点进去之后会发现是 AQS
中的方法,默认实现是抛出一个异常的,我们选择非公平锁实现即可:protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
// 选中 NonfairSync 实现
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
// 继续查看 nonfairTryAcquire 实现
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// state == 0证明无锁状态,可直接争夺锁,体现了非公平特性
if (c == 0) {
// CAS 尝试获得锁
if (compareAndSetState(0, acquires)) {
// 争夺锁成功调用AOS中方法设置独占线程
setExclusiveOwnerThread(current);
return true;
}
}
// state != 0 证明有锁占据,需要判断独占线程是否是当前线程,体现了锁可重入特性
else if (current == getExclusiveOwnerThread()) {
// 增加重入次数
int nextc = c + acquires;
// 同步状态值达到整形最大值,再增加则会整形溢出,由最大值转变成负数,如果继续执行将永远无法获取到锁,造成死锁问题,因此在这里抛出异常
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 同步状态值正常,更新该值,获取锁成功
setState(nextc);
return true;
}
// 争夺锁失败
return false;
}
acquireQueued()
了,这个方法在这里就不会展开进行讲解(主要是我也没完全理清逻辑,打一个 TODO
吧),感兴趣的小伙伴可以前往参考资料里面第一篇文章进行查看,里面讲得十分清楚,如果只是简单理解的话,那就是这个方法中,会根据 CLH
队列 FIFO
特性将当前线程封装成 Node
数据结构从队列尾部插入到队列中。// 为了方便查看我给 if 块加了大括号, 并调整了if中的换行,源码中是没有的(可能是JDK开发者的风格如此,我看着挺难受的)
public final void acquire(int arg) {
// 再次尝试获得锁,如果失败了取反之后为真,便会执行后面的 acquireQueued 方法将当前线程包装入队
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
// if块逻辑,当再次获取锁失败和包装入队成功后,将当前线程标记为中断状态
selfInterrupt();
}
}
selfInterrupt()
方法则是通过 interrupt()
方法对线程进行标记,具体方法理解网上资料也很多,可以查看参考资料中的相关文章进行查看。到这里加锁的大概流程就此结束啦,再次将开始放出来自己绘画的流程图贴在这里,就当做是总结吧。
解锁过程相对于加锁过程来说简单了许多,因为步骤相对较少,下面是我个人理解画的流程图:
下面将开始通过源码步骤一点点来理解整个流程:
unlock()
方法执行释放锁流程,这里一般处于final块中,进去后同样是调用内部类实例的同步器实现的 release()
方法,我们继续点进去:public void unlock() {
sync.release(1);
}
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
中的方法,默认实现为抛异常,因此我们直接前往其在 ReentrantLock
中的具体实现:protected final boolean tryRelease(int releases) {
int c = getState() - releases;
// 如果当前线程不等于拥有锁线程, 抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果减去 releases 后为0,证明锁释放成功,否则说明发生了锁重入
if (c == 0) {
free = true;
// 将拥有锁线程设置为空
setExclusiveOwnerThread(null);
}
// 设置State状态为0, 解锁成功
setState(c);
return free;
}
release()
方法中,会发现获取了阻塞队列中的头结点并尝试将其唤醒,当然这一块同样不深入探索,简单理解为,阻塞队列为遵循 FIFO
规则的 CLH
变体队列,一般情况下唤醒的是头结点:public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) {
// 获取头结点
Node h = head;
// 唤醒头结点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
唤醒线程的基本流程如下:
从前面的源码中我们可以看到其实有一步是判断头结点是否为空的 h != null && h.waitStatus != 0
,那么什么情况下头节点为空呢,当线程还在争夺锁,队列还未初始化,头节点必然是为空的,当头节点等待状态等于0,证明后继节点还在自旋,不需要进行后继节点唤醒。
在 ReentrantLock
的非公平锁实现中,当唤醒阻塞队列中的节点时,会优先选择队首节点进行唤醒。如果队首节点的等待状态为0(即已经被唤醒),则继续向后查找,直到找到一个等待状态不为0的节点进行唤醒。因此,在 ReentrantLock
的非公平锁实现中,唤醒的节点可能不是头结点,而是任意一个等待状态不为0的节点。这种行为可以减少线程唤醒的数量,从而提高性能。
其实不然,在上面的流程图中也有提及,在锁释放之后,state的值其实已经变成了0,此时其他线程是可以进来争夺锁的,可别忘了我们说的是非公平锁。
扯到这里已经是很长了,在做笔记的同时自己对
AQS
的理解也更进了一步,希望对阅读本文的小伙伴也有帮助和得到对应的收获,再次感谢和贴上参考资料:
万字图文 | 聊一聊 ReentrantLock 和 AQS 那点事
深入剖析ReentrantLock公平锁与非公平锁源码实现
线程中断:Thread类中interrupt()、interrupted()和 isInterrupted()方法详解