文章目录
【JUC并发编程系列】深入理解Java并发机制:深入剖析AbstractQueuedSynchronizer的底层机制(九、AQS底层实现原理)
1. AQS底层实现设计技术点
-
CAS (Compare and Swap):
- AQS 使用 CAS 操作来保证所有关键操作的原子性。
- CAS 是一种由 CPU 硬件层面支持的原子操作,Java 语言通过
Unsafe
类提供的compareAndSwapInt
方法调用 CAS。
-
双向链表(阻塞队列):
- AQS 使用双向链表作为其内部等待队列的基础结构。
- 这种队列用于管理等待获取锁的线程,每个节点 (
Node
) 包含线程引用和其他状态信息。 - 节点可以在队列的尾部追加,以确保线程按照加入队列的顺序等待。
-
LockSupport:
- AQS 使用
LockSupport
类来阻塞和唤醒线程。 LockSupport.park(this)
方法用于阻塞当前线程,LockSupport.unpark(Thread thread)
方法用于唤醒指定的线程。
- AQS 使用
-
AQS 如何唤醒阻塞线程:
- AQS 通过
LockSupport.unpark
方法唤醒阻塞队列中的线程。 - 在释放锁时,AQS 会检查等待队列中的头结点,并唤醒头结点的下一个节点所对应的线程。
- AQS 通过
-
状态管理:
- AQS 维护一个名为
state
的整型成员变量来表示同步状态。 state
的默认值为 0,表示没有线程获取锁。- 当一个线程获取锁时,
state
的值变为 1(对于独占锁),表示有线程持有锁。 - 对于可重入锁,每次线程重入锁时,
state
的值会递增。
- AQS 维护一个名为
总结:
- AQS 使用 CAS 操作来保证所有关键操作的原子性。
- 它使用双向链表作为等待队列的基础结构,用于管理等待获取锁的线程。
- AQS 通过
LockSupport
类来阻塞和唤醒线程。 - AQS 通过修改
state
来表示锁的状态,并通过 CAS 操作来原子地更新state
。 - 当释放锁时,AQS 会唤醒等待队列中的下一个线程,使其有机会获取锁。
2. AQS基本的概念
AQS(AbstractQueuedSynchronizer)是Java并发包 java.util.concurrent
中的一个非常重要的抽象类。它为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件等)提供了一个框架。
AQS的核心概念包括:
-
独占与共享模式:
- 独占模式:一次只有一个线程可以获得同步状态。例如,
ReentrantLock
就是基于独占模式实现的。 - 共享模式:多个线程可以同时获得同步状态。例如,
Semaphore
和ReadWriteLock
中的读锁就是基于共享模式实现的。
- 独占模式:一次只有一个线程可以获得同步状态。例如,
-
同步状态:
- AQS维护了一个叫做
state
的整型成员变量来代表同步状态。 - 通过内置的
ConditionObject
和一系列模板方法,子类可以通过原子方式获取和修改这个状态。
- AQS维护了一个叫做
-
等待队列:
- AQS 使用了一个 CLH 锁类型的同步队列来管理等待的线程。
- 当线程尝试获取同步状态失败时,会被插入到等待队列中,并被挂起。
-
条件对象:
- AQS 提供了对条件变量的支持,允许线程等待某个条件满足后继续执行。
主要方法:
- getState():返回同步状态的当前值。
- setState(int newState):设置同步状态的值。
- compareAndSetState(int expect, int update):原子地设置同步状态的值,如果当前值等于预期值。
子类实现:
- AQS 的子类需要实现
tryAcquire
和tryRelease
方法(对于独占模式),或者tryAcquireShared
和tryReleaseShared
方法(对于共享模式)。 - 这些方法定义了如何获取和释放同步状态。
总结:
AQS 是一个高级工具,它简化了开发人员在实现自定义同步组件时的工作。理解它的原理有助于更好地利用 Java 并发 API 中提供的工具。
3. AQS源码解读
Lock
锁底层原理是基于AQS的api封装的
-
NonfairSync
非公平锁 -
FairSync
公平锁
ReentrantLock
在默认的情况下就是属于非公平锁
公平锁与非公平锁最大的区别:就是在做cas操作的是时候加上 !hasQueuedPredecessors()
必须遵循公平竞争
公平锁和非公平锁的父类都是AQS
4. AQS核心参数
核心参数 | 描述 | 取值及含义 |
---|---|---|
Node 结点 | 用于存放正在等待的线程。每个 Node 包含一个线程引用以及若干状态标志。 | - |
waitStatus 状态 | 表示 Node 的等待状态。 | CANCELLED (1): 节点已被取消。 SIGNAL (-1): 节点处于等待状态,当其前驱释放锁时应被唤醒。 CONDITION (-2): 节点因等待条件变量而被阻塞。 PROPAGATE (-3): 工作于共享锁状态,需要向前传播唤醒操作。 0: 节点正在等待获取锁。 |
Head 头结点 | 队列的头部节点,通常表示已经获取锁或正在尝试获取锁的线程。 | - |
Tail 尾结点 | 队列的尾部节点,表示正在排队等待获取锁的线程。 | - |
state | 同步状态,表示锁的状态。 | 0: 无锁状态。 1: 一个线程已获取锁,对于可重入锁,每次重入都会增加该值。 |
exclusiveOwnerThread | 记录当前持有独占锁的线程。 | - |
请注意,state
的具体值取决于具体的同步器实现。例如,在 ReentrantLock
中,state
为 1 表示锁已被一个线程获取,而对于 ReentrantReadWriteLock
,读锁和写锁可能有不同的表示方式。
此外,Node
结点中的 waitStatus
状态除了上述提到的状态外,还有一些额外的细节需要注意:
SIGNAL (-1)
:表示当前节点的前驱节点已经释放了锁,并且需要唤醒当前节点。CONDITION (-2)
:表示节点因为等待条件变量而被阻塞,这是在Condition
对象中使用的状态。PROPAGATE (-3)
:在共享模式下,表示需要向前传播唤醒操作,以便其他节点可以尝试获取锁。
5. AQS 中头结点为何为空
头结点在 AQS 中扮演着特殊的角色,它通常表示已经获取锁或正在尝试获取锁的线程。然而,在实际的实现中,头结点的 thread
字段通常是空的(null
)。这样设计的原因如下:
-
节省空间:
- 当一个线程成功获取锁时,它实际上并不需要作为一个
Node
加入到队列中,而是作为锁的持有者被记录在exclusiveOwnerThread
字段中。 - 因此,头结点不需要保存线程引用,可以将其设为
null
以节省内存空间。
- 当一个线程成功获取锁时,它实际上并不需要作为一个
-
简化逻辑:
- 设定头结点为空可以简化 AQS 中的一些逻辑处理,特别是当线程获取锁时不需要再将其添加到队列中。
- 这种设计简化了锁的获取流程,同时也减少了对队列的操作次数,提高了性能。
-
清晰标识:
- 一个空的头结点清楚地表明当前没有线程在队列中等待获取锁。
- 这种做法有助于其他线程快速判断当前是否有线程正在尝试获取锁,从而决定自己的下一步动作。
-
易于管理:
- 由于头结点通常表示已经成功获取锁的线程,因此将其设为空可以避免在锁释放时需要额外的操作来移除头结点。
- 这样可以简化锁释放过程中的队列管理逻辑。
综上所述,AQS 中头结点设为空是一种优化措施,它不仅节省了内存空间,还简化了代码逻辑,提高了锁的获取和释放效率。这种方式的设计考虑到了多线程环境下的性能需求,同时也保持了代码的简洁性和易维护性。
6. 非公平锁和公平锁实现原理
6.1 非公平锁获取锁的过程
-
尝试获取锁:
- 使用 CAS(Compare and Swap)操作尝试将 AQS 的
state
从 0 修改为 1。 - 如果 CAS 成功,则表示当前线程获得了锁,并记录当前线程为
exclusiveOwnerThread
。
- 使用 CAS(Compare and Swap)操作尝试将 AQS 的
-
处理重入:
- 如果当前线程已经在持有锁(即
exclusiveOwnerThread
为当前线程),则表示当前线程试图重入锁。 - 在这种情况下,
state
的值会递增,表示锁被重入了一次。
- 如果当前线程已经在持有锁(即
-
加入等待队列:
- 如果 CAS 操作失败(即另一个线程已经持有锁),当前线程会检查
exclusiveOwnerThread
是否为自身,如果不是,则说明锁被其他线程持有。 - 当前线程会在 AQS 的双向链表(等待队列)的尾部追加一个
Node
节点,并将该节点的waitStatus
设置为SIGNAL
(-1),表示它正在等待被唤醒。 - 当前线程随后会被阻塞,使用
LockSupport.park(this)
方法进入等待状态。
- 如果 CAS 操作失败(即另一个线程已经持有锁),当前线程会检查
-
总结:
-
当一个线程首次尝试获取锁时,它会尝试使用 CAS 将
state
从 0 修改为 1。如果成功,该线程成为锁的所有者,并记录在exclusiveOwnerThread
字段中。 -
如果线程试图重入锁,它会增加
state
的值,表示锁已经被重入。 -
如果锁被其他线程持有,当前线程会在等待队列中添加一个节点,并将自己阻塞,等待被唤醒。
-
这样的实现方式使得非公平锁更倾向于让当前持有锁的线程优先获得锁,即使有新来的线程尝试获取锁,也更有可能让当前线程再次获得锁,从而减少锁的上下文切换开销。
源码:
当获取锁失败,进入acquire()
方法
tryAcquire(int arg)
非公平锁(non-fair lock)尝试获取锁的实现
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 获取当前线程
int c = getState(); // 获取 AQS 的同步状态 state
if (c == 0) { // 如果 state 为 0,表示锁未被任何线程持有
if (compareAndSetState(0, acquires)) { // 尝试使用 CAS 将 state 从 0 改为 acquires
setExclusiveOwnerThread(current); // 如果 CAS 成功,则设置当前线程为锁的持有者
return true; // 返回 true 表示获取锁成功
}
} else if (current == getExclusiveOwnerThread()) { // 如果当前线程已经是锁的持有者
int nextc = c + acquires; // 计算新的 state 值
if (nextc < 0) { // 检查是否溢出
throw new Error("Maximum lock count exceeded"); // 如果溢出,则抛出异常
}
setState(nextc); // 更新 state
return true; // 返回 true 表示重入锁成功
}
return false; // 如果以上条件都不满足,则返回 false 表示获取锁失败
}
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
-
addWaiter(Node.EXCLUSIVE), arg)
;将我们执行CAS操作失败的线程存放在AQS类中双向链表尾部 -
acquireQueued()
: 执行CAS操作失败的线程阻塞。
acquireQueued(final Node node, int arg)
-
释放锁时:唤醒头节点的下一个节点,如果头节点的下一个节点被唤醒之后为了代码的严谨性,重试CAS操作时唤醒的节点的上一个节点如果是为头节点,则可以执行重试获取锁。
-
shouldParkAfterFailedAcquire(p, node)
改头节点状态值为-1,如果修改成功之后它返回True
,最后parkAndCheckInterrupt()
让我们当前t1线程阻塞。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true; // 初始化失败标志
try {
boolean interrupted = false; // 初始化中断标志
for (;;) { // 无限循环,直到线程成功获取锁或被中断
final Node p = node.predecessor(); // 获取当前节点的前驱节点
if (p == head && tryAcquire(arg)) { // 如果前驱节点是头结点并且成功获取锁
setHead(node); // 将当前节点设置为新的头结点
p.next = null; // 断开与前驱节点的链接,帮助垃圾回收
failed = false; // 标记获取锁成功
return interrupted; // 返回是否被中断过
}
if (shouldParkAfterFailedAcquire(p, node) && // 如果应该阻塞当前线程
parkAndCheckInterrupt()) { // 阻塞当前线程并检查是否被中断
interrupted = true; // 标记线程被中断
}
}
} finally {
if (failed) // 如果获取锁失败
cancelAcquire(node); // 取消获取锁的操作,并清理等待队列中的节点
}
}
6.2 非公平锁释放锁的过程
-
检查状态并释放锁:
- 减少
state
的值 (state - releases
)。 - 如果
state
减少后变为 0,则表示锁可以被释放。
- 减少
-
使用 CAS 修改状态:
- 使用 CAS(Compare and Swap)操作尝试将
state
从当前值减少releases
。 - 如果 CAS 操作成功,则锁被成功释放。
- 使用 CAS(Compare and Swap)操作尝试将
-
唤醒后续线程:
- 唤醒等待队列中头结点的下一个节点所对应的线程。
- 这个线程将重新参与到锁的竞争中。
-
清理队列:
- 如果被唤醒的线程成功获取锁,则它将成为新的头结点。
- 如果在没有其他线程竞争锁的情况下,头结点会被设置为
null
,以表示当前没有线程持有锁。
总结:
- 当一个线程想要释放锁时,它会减少
state
的值,如果state
变为 0,则表示锁可以被释放。 - 使用 CAS 操作来确保状态更改的原子性。
- 成功释放锁后,唤醒等待队列中头结点的下一个节点,使其有机会获取锁。
- 如果没有其他线程竞争锁,则头结点会被设置为
null
。
这样的实现方式确保了锁的正确释放,并且能够高效地唤醒下一个等待的线程,同时保持队列的整洁。
当调用unlock()
方法时:
源码:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) { // 尝试释放锁
Node h = head; // 获取当前头结点
if (h != null && h.waitStatus != 0) { // 如果头结点存在且其 waitStatus 不为 0
unparkSuccessor(h); // 唤醒头结点的下一个等待线程
}
return true; // 返回 true 表示锁被成功释放
}
return false; // 如果 tryRelease 失败,则返回 false
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases; // 获取当前的同步状态并减去要释放的数量
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException(); // 如果当前线程不是锁的持有者,则抛出异常
}
boolean free = false;
if (c == 0) { // 如果更新后的状态为 0,则表示锁可以被完全释放
free = true;
setExclusiveOwnerThread(null); // 清空锁的持有者
}
setState(c); // 更新同步状态
return free; // 返回是否完全释放了锁
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus; // 获取当前节点的 waitStatus
if (ws < 0) // 如果 waitStatus 小于 0
compareAndSetWaitStatus(node, ws, 0); // 将 waitStatus 设置为 0
Node s = node.next; // 获取当前节点的下一个节点
if (s == null || s.waitStatus > 0) { // 如果下一个节点不存在或者其 waitStatus 大于 0
s = null; // 将 s 置为 null
for (Node t = tail; t != null && t != node; t = t.prev) { // 从尾部开始遍历队列
if (t.waitStatus <= 0) // 如果找到 waitStatus 小于等于 0 的节点
s = t; // 更新 s 为该节点
}
}
if (s != null) // 如果找到了合适的节点
LockSupport.unpark(s.thread); // 唤醒该节点对应的线程
}
当释放完锁成功之后唤醒下一个节点
进入循环后该前驱节点是头结点并且成功获取锁进入
if (p == head && tryAcquire(arg)) { // 如果前驱节点是头结点并且成功获取锁
setHead(node); // 将当前节点设置为新的头结点
p.next = null; // 断开与前驱节点的链接,帮助垃圾回收
failed = false; // 标记获取锁成功
return interrupted; // 返回是否被中断过
}
private void setHead(Node node) {
head = node; // 将当前节点设置为新的头结点
node.thread = null; // 清空节点中的线程引用,帮助垃圾回收
node.prev = null; // 断开与前驱节点的链接,进一步帮助垃圾回收
}
返回 interrupted = true
进入 acquire(int arg)
恢复中断状态
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 尝试获取锁
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { // 如果尝试失败,则将线程加入等待队列
selfInterrupt(); // 如果线程被中断,则恢复中断状态
}
}
6.3 公平锁和非公平锁在获取锁时的区别
公平锁
final void lock() {
acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 尝试获取锁
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { // 如果尝试失败,则将线程加入等待队列
selfInterrupt(); // 如果线程被中断,则恢复中断状态
}
}
非公平锁
final void lock() {
if (compareAndSetState(0, 1)) { // 尝试使用 CAS 将 state 从 0 设置为 1
setExclusiveOwnerThread(Thread.currentThread()); // 如果 CAS 成功,则设置当前线程为锁的持有者
} else {
acquire(1); // 如果 CAS 失败,则调用 acquire 方法尝试获取锁
}
}
6.4 公平锁和非公平锁释放锁
注意:公平和非公平锁底层都只会唤醒当前头节点的下一个节点重新进入到获取锁的状态。
6.5 公平锁与非公平锁的实现原理区别
-
非公平锁:
- 当调用
lock
方法时,非公平锁首先尝试使用一次 CAS 操作来获取锁。 - 如果 CAS 操作成功,则当前线程获取到锁。
- 如果 CAS 失败,但在后续的重试过程中发现 AQS 的锁状态为 0,则非公平锁允许继续尝试 CAS 操作,以提高当前线程再次获取锁的可能性。
- 当调用
-
公平锁:
- 当调用
lock
方法时,如果已经有其他线程获取到该锁,则当前线程会直接追加到等待队列(双向链表)的末尾,并等待被唤醒。 - 公平锁不允许当前线程在等待队列中尝试 CAS 操作,以确保锁的分配遵循 FIFO(先进先出)原则。
- 当调用
这样的实现方式确保了非公平锁更倾向于让当前持有锁的线程优先获得锁,即使有新来的线程尝试获取锁,也更有可能让当前线程再次获得锁。而公平锁则确保了线程按照它们加入等待队列的顺序来获取锁,从而实现了更公平的锁分配策略。
7. Condition
7.1 锁池
锁池: 假设线程A已经拥有了某个对象的锁,而其它的线程想要调用这个对象的某个synchronized
方法(或者synchronized
块),由于这些线程在进入对象的synchronized
方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
EntryList
(锁池) 当前的线程获取锁失败,会阻塞等待,使用链表数据结构存放在锁池中
7.2 等待池
等待池: 假设一个线程A调用了某个对象的wait()
方法,线程A就会释放该对象的锁(因为wait()
方法必须出现在synchronized
中,这样自然在执行wait()
方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。
- 如果另外的一个线程调用了相同对象的
notifyAll()
方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。 - 如果另外的一个线程调用了相同对象的
notify()
方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.
-
如果线程调用了对象的
wait()
方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。 -
当有线程调用了对象的
notifyAll()
方法(唤醒所有wait
线程)或notify()
方法(只随机唤醒一个wait
线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。 -
优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用
wait()
方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了synchronized
代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。