前言
要理解JUC,我们离不开Java设计的底层类——AbstractQueuedSynchronizer,也就是我们常说的AQS。
为什么需要设计出AQS,因为synchronized的行为是不可干预的,synchronized加锁释放锁的过程,程序员没法进行干预,当需要一些定制化的功能时(比如我需要一个公平锁),就需要功能更丰富的一些类,这些类都在JUC(java.util.concurrent)包里面.
AQS提供了原子性的、支持手动阻塞/唤醒的队列模型框架,同时它应用了模块方法,将抽象方法交给子类实现,如此,便可支持公平锁/非公平锁、独占锁/非独占锁等等的丰富多样的锁需求.
AQS
原理
AQS将每一个请求的线程都看作一个节点,每个节点都维护在一个队列中,当线程争夺资源的时候,会抢占共享资源——state,获取不到资源的时候,进入阻塞队列。
一旦有线程争夺到资源,state会进入锁定状态,这个时候其他线程处于阻塞状态。
数据结构
Node成员属性
- waitStatus: 当前节点在队列中的状态.
- thread: 处于节点中的线程.
- prev: 指向上一个节点的指针.
- predecessor(): 返回上一个节点.
- nextWaiter: 指向下一个处于
CONDITION状态的节点. - next: 指向下一个节点
锁的常量值含义
- SHARED: 线程以共享的方式等待锁
- EXCLUSIVE: 线程以独占的方式等待锁
waitStatus枚举值含义
- 0: 初始值
- CANCELLED: 值为1,表示线程获取锁的请求已经取消了
- CONDITION: 值为-2,表示节点在等待队列中,在等待唤醒
- PROPAGATE: 值为-3,释放共享资源的时候需要通知其他节点
- SIGNAL: 值为-1,线程准备好了,等待唤醒
同步状态state
private volatile int state;
state使用了volatile进行修饰,它作为AQS众多线程抢夺的共享资源.
在每一个不同的锁和不同的子类中,state有着不一样的含义:
ReentrantLock: state用来表示当前线程获取锁的可重入次数.
ReentrantReadWriteLock: state的高16位表示读状态(也即是获取该读锁的次数),低16位表示获取到写锁的线程的可重入次数.
Semaphore: state用来表示当前可用信号的个数.
CountDownlatch: state用来表示计数器当前的值.
加锁
独占锁
如何处理state值将AQS的子类实现分为独占锁和共享锁,它们分别有各种的加锁和解锁方式:
| method | description |
|---|---|
| protected boolean tryAcquire(int arg) | 独占模式的加锁方法,arg为获取锁的次数 |
| boolean tryRelease(int arg) | 独占模式的解锁方式,arg为获取锁的次数 |
| protected int tryAcquireShared(int arg) | 共享模式的加锁方式,负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 |
| protected boolean tryReleaseShared(int arg) | 共享模式的解锁方式,如果释放后允许唤醒后续等待结点返回True,否则返回False。 |
我们经常讨论的ReentrantLock是独占锁的代表,它实现了tryAcquire和tryRelease方法,AQS没有提供这两个方法的实现,是由子类ReentrantLock去实现的.
这里讨论一下独占锁模式下,state值的变化:
线程进入代码块进行资源抢夺时,会尝试操作state来判断当前state资源是不是可以获取的,如果state的值为0,说明可以获取,则尝试CAS操作将state状态值从0改成1,然后设置当前锁的显常持有者为当前线程。
在可重入的实现方案里面,当同一个线程再次进入到同一个方法块,通过判断自己是当前线程的持有者,则无需重新加锁,只需要state做自增+1就行,当退出方法块的时候,state再进行-1操作
源码分析
上面说了,AQS是一个顶层的抽象,这里我们从子类ReentrantLock去解析AQS.
package com.tea.modules.java8.thread.lock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 学习ReentrantLock源码
*
* @author jaymin
* @create 2023-06-12 11:13
*/
public class ReentrantLockDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
lock.lock();
try {
System.out.println("锁定");
} finally {
lock.unlock();
}
}
}
ReentrantLock默认是非公平锁,在它的构造函数中已经声明了这个,所以看源码的时候我们先看ReentrantLock#NonfairSync的实现.
- java.util.concurrent.locks.ReentrantLock.NonfairSync#lock
/**
* Sync object for non-fair locks
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
先从大体上看流程,再去看细节实现,当调用了lock方法后,先尝试做CAS将state值改为1,如果成功,将锁的持有者设置成当前线程。
否则,调用acquire()方法.注意,这里的acquire()方法执行的是AQS类的方法。AQS采用的是模板设计模式,大体的执行流程交由父类实现,子类负责实现抽象方法.
- java.util.concurrent.locks.AbstractQueuedSynchronizer#acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这里有点奇妙,tryAcquire是子类自己实现的方法,acquireQueued()则由AQS自己提供,最后一个是自我中断.
在 acquireQueued() 方法中,会先通过 tryAcquire() 方法尝试获取锁,如果 tryAcquire() 方法返回 false,说明当前锁已被占用,需要将当前线程加入到等待队列中,并进一步尝试获取锁。
需要注意的是,如果在等待期间有其他线程释放了锁,那么等待队列中的线程会被唤醒,并尝试再次获取锁。因此,acquireQueued() 方法会在等待期间多次尝试获取锁,直到获取成功或者线程被中断为止。
- java.util.concurrent.locks.AbstractQueuedSynchronizer#addWaiter
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;
}
- 首先根据当前线程创建一个节点
- pred指针指向AQS队列的尾节点
- 将new出来的Node节点的前置指针指向pred,也就是尾节点
- 通过compareAndSetTail将尾节点设置成new出来的Node节点
由于是在多线程的环境下进行设置,那么会存在pred指针为空(当前队列尚没有元素)、当前pred指针和tail指向的位置不同(被其他线程修改)那么就需要enq方法来介入。
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;
}
}
}
}
如果等待队列为空,那么进行初始化,初始化的头节点是一个无参构造函数的节点.
如果等待队列不为空,再进行一次设置尾节点的流程,直到设置成功跳出循环.
好了,到了这里,有个大概印象,线程在获取锁的时候,先尝试获取锁,如果获取不到进行排队。
如果线程无法获取到锁,那么它会经过addWaiter()这个方法去将当前线程包装成一个Node节点,然后做为参数再调用acquireQueued()这个方法.
final boolean acquireQueued(final Node node, int arg) {
// 是否成功获取资源
boolean failed = true;
try {
// 是否发生中断
boolean interrupted = false;
// 自旋操作,获取到锁的时候会跳出
for (;;) {
// 获取节点的前驱节点
final Node p = node.predecessor();
// 如果p是头节点,尝试获取锁,这里的头节点是指除去虚节点后
if (p == head && tryAcquire(arg)) {
// 获取锁成功,头指针移动到当前node
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// p节点是头节点,但是目前没法获取锁或者p节点不是头节点
// 这里会尝试讲线程进行阻塞(waitStatus=-1),防止无限循环浪费资源
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
总结: 当调用了acquireQueued()后,Node节点会一直自旋尝试去获取锁,但是避免无限循环浪费资源,在达到某个条件的时候会将线程阻塞.
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// SIGNAL表示前置节点已经准备就绪了,等待唤醒
if (ws == Node.SIGNAL)
return true;
// >0 就是CANCELLED状态
if (ws > 0) {
// 往前一直查找处于取消状态的线程节点,通过指针指向将它们从队列中移除
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// // 设置前置节点状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
获取前驱节点的waitStatus,如果为SIGNAL状态,那么该node直接进入阻塞状态(返回true后会触发parkAndCheckInterrupt)。如果waitStatus为CANCELLED状态(大于0的就是取消状态,具体的看上面的枚举值映射),那么继续往前寻找前置节点的前置节点,然后通过指针指向将它们从队列中移除。
如果为其他状态,CAS设置为SIGNAL状态.如果shouldParkAfterFailedAcquire返回true,那么会调用parkAndCheckInterrupt将线程进行阻塞:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
流程图
解锁
解锁的过程中,并不区分公平锁和非公平锁
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
// tryRelease说明当前锁没有被任一线程持有
if (tryRelease(arg)) {
Node h = head;
// 头节点不为空并且头节点的状态不为初始值,解除线程阻塞状态
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这里的判断我们来看一下:h != null && h.waitStatus != 0,它是为了确保队列中有需要唤醒的节点,因为当获取不到锁的时候,节点会被设置waitStatus为SIGNAL.
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
这里看到会将state进行自减,加锁的时候从0->1,解锁的时候从1->0(如果多次重入,那么state不是1,而是重入的次数)
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
// 清除可能存在的等待状态,并预先准备好进行信号通知
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 获取头节点的后继节点 s = node.next
Node s = node.next;
// 如果下个节点是空或者下个节点被取消,那么从后往前找到不是cancelled状态的节点
if (s == null || s.waitStatus > 0) {
s = null;
// 这是为了确保将信号通知发送给真正需要被唤醒的后继节点。
// 通过遍历尾节点 tail 的前驱节点,找到等待状态小于等于0的节点 s。
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// 唤醒
LockSupport.unpark(s.thread);
}
这里会找到等待状态<0并且离头节点最近的节点进行唤醒,唤醒后,会回到acquireQueued这个方法里面的parkAndCheckInterrupt(也就是阻塞的地方),执行中断,这块知识我查了一下,是所谓的Java特有的协作式中断方式。
然后进入下一次循环,当前获取锁,执行任务。
流程图
常见问题
1. synchronized和ReentrantLock的区别是什么?
- synchronized是关键字,线程的加锁、解锁的时机由JVM决定(隐式),ReentrantLock是一个类,程序可以需要显示声明加锁和解锁来控制锁。
- synchronized是非公平锁,ReentrantLock可以通过构造函数(默认非公平锁)来选择公平和非公平性。
- ReentrantLock支持Condition。
- 性能上,synchronized的性能通常优于ReentrantLock,在高竞争情况下,ReentrantLock可能更优,不过JDK并没有放弃优化synchronized,JDK6之后,减小锁的粒度、引入偏向锁、轻量级锁和重量级锁等机制,提高了synchronized的性能,这里我们可以从CHM这个并发类去看到。
2. 可重入锁在ReentrantLock中是怎么实现的
AQS内部维护了一个状态值state,线程在抢占锁的时候会尝试将其从0改成1,然后设置当前锁的持有线程为自己,如果同一个再次进入了抢夺锁的代码块,这时在
nonfairTryAcquire方法中会判断自己是否持有当前线程,如果是,那么state = state + 1,当退出代码块的时候,tryRelease会将state进行自减1.
3. 公平锁和非公平锁是什么?具体是怎么体现的?
公平锁: 先到达临界区的线程比后到的线程更优先获得锁。
非公平锁: 先到达临界区的线程不一定比后到的线程更优先获得锁。
在AQS中,公平锁的实现会先调用hasQueuedPredecessors这个方法,去判断当前等待队列是否存在在排队的线程,如果有,那么排队。