前言
在synchronized的轻量级锁居然不会自旋?深度解析synchronized实现原理一文中我们已经知道了synchronized的实现原理,但是在面试官:从零开始设计个JMM吧,说说你的思路一文中我们说过,Java除了synchronized,在locks包下还提供了各种各样的锁和同步工具类。为啥有了synchronized还不行呢?
为什么有synchronized还不够
- synchronized是「排他锁」,也就是:只有一个线程能够持有锁,无法满足我们需要多个线程同时持有锁的「共享锁」的需求
- synchronized虽然使用简单方便,但不够灵活,暴露的方法也比较少,比如:无法知道线程有没有成功获取到锁
- 我们说过synchronized是「非公平锁」,很多场景需要「公平锁」,即先来的线程先拿到锁资源
java.util.concurrent.locks这个包就是为了提供不一样的锁的。
从高处看locks包的设计
我们现在需要能够提供各种各样功能的锁,目的是解决synchronized的缺陷,该如何设计呢?我们当然可以先参照synchronized的实现原理的优点:
我们先回顾一下synchronized的锁结构
synchronized是独占的,我们希望我们的锁是能够共享的,可以分别实现两套API
synchronized是不公平的,我们希望我们的锁能够提供公平性:
- 那就不能上来直接自旋那么多次,如果发现竞争可能要直接陷入阻塞
- cxq的先进后出的结构不好用了
synchronized的wait/notify还不错,但局限性在于WaitSet只有一个,我们希望线程await去不同的WaitSet。
带着这样的思想,我们来看看locks包下的类都是如何设计的。
Lock接口
Lock接口是JDK层面的锁必须实现的接口:可以看到提供了四种加锁的方式,以及一种解锁unlock,和一个newCondition方法,实际上Condition就是实现Lock的等待通知机制的关键,我们后面再说
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
不同的加锁方式
lock与trylock的区别
- lock() : 一直阻塞,直到获得锁,且等待获取锁期间被interrupt不能中断
- trylock() : 不会阻塞,获取不到锁直接返回false
- tryLock(10, TimeUnit.SECONDS) :等待10s,被interrupt会中断等待,抛出InterruptedException,超时还没获取到锁会返回false
lock与lockInterruptibly()的区别
lockInterruptibly允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException。
lock方法不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程。
Condition接口:等待通知机制
可以看到,提供了五种await方法,功能类似Object的wait方法,因此说是加强版的wait也没问题
Condition功能上基于wait/notify提供了加强,实现上也十分类似,Condition会维护所有因调用await方法而阻塞的线程,这与WaitSet十分类似。
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
实际上,实现Condition接口的类只有两个:AQS和AQLS。我们很快就会分析AQS,也会来看看如何实现Condition功能。
Lock接口下最重要的实现类就是ReentrantLock,也是被人们拿来和synchronized比较最多的锁,我们直接看ReentrantLock。
ReentrantLock:可重入锁
ReentrantLock的中文译名就是可重入锁,同时支持公平和非公平,默认非公平。属于「排他锁」,不能共享
类关系并不复杂,只实现了Lock接口,源码也很少
基本使用
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
注意:执行加锁必须在try代码块之外,避免异常时无故释放锁
核心字段
private final Sync sync;
Sync是一个抽象静态内部类,继承自AbstractQueuedSynchronizer,这个玩意就是大名鼎鼎的AQS,Sync有两个实现类,分别对应是否公平。我们马上就会分析AQS的源码。
构造方法
提供两种,可以看到无参默认非公平,也可以指定公平
而公不公平的区别仅仅是sync的实例对象不同
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
实例方法
public void lock() {
sync.lock();
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
public void unlock() {
sync.release(1);
}
就暂时列这么多,要不然篇幅太长了。可以看到,其实几乎所有方法,都以委托的方式交给sync来执行,因此我们需要聚焦于sync才能明白ReentrantLock的实现原理了,而sync继承自AQS,AQS继承自AOS,我们一个个来看
AOS:独占锁的持有者
AOS:AbstractOwnableSynchronizer,源码很简单
只有一个字段,以及一对get/set方法,AOS就干了一件事:保存持有独占锁的线程
private transient Thread exclusiveOwnerThread;
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
AOS类比synchronized的话,就是monitor结构中的running thread
AQS:自定义同步器的框架🚩
AQS是AbstractQueuedSynchronizer的简称,中文名「抽象队列同步器」,是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的同步器。
内部类Node:封装线程
Node是对竞争锁资源的线程的封装,用
// 线程状态
volatile int waitStatus;
// 双向链表
volatile Node prev;
volatile Node next;
// 封装的线程
volatile Thread thread;
Node nextWaiter;
// 如果是独占模式,nextWaiter维护condition内的下一个节点
// 如果是共享模式,nextWaiter为特殊值SHARED,SHARED是AQS的类变量
// 这样做的意义是节约一个字段,保存了节点的
static final Node SHARED = new Node();
// 判断是否共享
final boolean isShared() {
return nextWaiter == SHARED;
}
Node.waitStatus:线程状态
源码关于waitStatus的含义说的很清楚了:
| waitStatus值 | 含义 |
|---|---|
| 0 | 一个普通的在CLH队列的线程的WaitStatus值 |
| 1 | CANCELLED,表示线程取消获取资源请求(可能是等待超时,或者被中断) |
| -2 | CONDITION,表示线程调用了await方法,在Condition队列里阻塞 |
| -3 | PROPAGATE,解决共享模式的一个没能顺利唤醒线程的Bug而有的,细节问题 |
| -1 | SIGNAL,表示后继结点阻塞了,此时你如果释放了资源,需要unpark它(唤醒) |
-2和1很好理解,0和-1你可能会有点犯迷糊,在后面「获取资源」时会再详细分析的
Propagate是JDK6引入解决共享模式无法唤醒后续节点的。复现Bug的场景是:
两个线程A、B,A首先释放锁,唤醒首节点,设置首节点状态为0。此时B也释放锁。发现首节点状态为0,不继续唤醒,并且修改状态为-1,此时回到A,A也不会继续唤醒了。
// 这是JDK6的源码 private void setHeadAndPropagate(Node node, int propagate) { //将当前节点设置为头节点 setHead(node); if (propagate > 0 && h.waitStatus != 0 ) { Node s = node.next; //获取当前节点的后继节点,如果它为null或者它是共享节点,则唤醒头节点的后继节点 if (s == null || s.isShared()) { //唤醒后继节点 doReleaseShared(); } } public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { Node h = head; if (h != null && h.waitStatus != 0) { //头节点不为空,且状态不为0时,调用唤醒节点方法 unparkSuccessor(h); } return true; } return false; }
核心字段
// 双向链表 也叫CLH head节点一般指向null或者已经获取资源的Node
private transient volatile Node head;
private transient volatile Node tail;
// 锁同步的状态,state是控制同步的关键
// 提供了三种读写API:
// getState()、setState(int newState)和compareAndSetState(int expect,int update))
private volatile int state;
// spin 不就是自旋的意思么 最大自旋时间为1000纳秒
static final long spinForTimeoutThreshold = 1000L;
模板方法
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
throw new UnsupportedOperationException();
}
分别是:已独占的方式获取/释放资源;已共享的方式获取/释放资源;
isHeldExclusively判断是否独占资源,只有用到condition才需要去实现它
AQS核心工作流程
1、独占模式
独占模式只有一个线程能够获取到资源
获取资源
acquire方法在尝试获取资源的过程中,即使被interrupt,也仍然会继续运行直到获取到锁,相对的acquireInterruptibly方法一旦被interrupt,会立刻抛出中断异常,其它逻辑几乎完全一样
// arg代表要获取资源的个数
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 获取资源的过程中被中断,在获取到以后才真正中断
selfInterrupt();
}
- 调用tryAcquire方法尝试获取资源,如果成功直接返回,失败继续
- 以自旋CAS的方式向CLH队列尾部插入线程(addWaiter方法)
- 自旋(死循环)尝试获取资源,每次自旋检查自己是否需要阻塞,阻塞的条件是这样的:
- 如果你的前驱节点状态为SIGNAL,阻塞;
- 否则,不阻塞,并且用CAS的方式将前驱节点状态改为SIGNAL(然后再次循环)
当前节点为SIGNAL表示,当 当前节点释放资源时,需要唤醒后继节点,因此CLH的线程不能那么自私的直接阻塞不管了。这就像排队,你要睡觉,你得告诉排在你前面的兄弟等轮到他了记得把自己叫醒。
// 只有当
// selfInterrupt();就是中断线程自己
Thread.currentThread().interrupt();
释放资源
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
// 唤醒head的next节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease释放资源
- 成功,唤醒CLH队列的下一个节点,返回true
- 失败,返回false
2、共享模式
获取资源
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
doAcquireShared几乎和acquireQueued差不多,但可能会唤醒后续的节点
当线程自旋成功获取到资源后,
- 首先调用tryAcquireShared,成功直接返回,失败继续
- 以自旋CAS的方式插入CLH队列尾部
- 陷入死循环,自旋获取锁,每次自旋检查自己是否需要阻塞
如果成功获取到锁,可能唤醒CLH的下一个节点(doReleaseShared方法)
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
// 这里就是需要我们唤醒后续节点
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
// 这里ws=0,说明可能是别的线程CAS成功了,把状态设置为PROPAGATE
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// 如果head没变 说明后续没有值得唤醒的线程了
// 唤醒成功,unparkSuccessor(h);会移动节点从而改变head的值
if (h == head) // loop if head changed
break;
}
}
释放资源
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
就是释放资源成功,然后doReleaseShared,这个方法在获取资源的setHeadAndPropagate方法已经见到过,就是可能唤醒后续节点。
为什么采用双向链表
线程出现异常,不需要继续参与竞争资源,而是直接从CLH中删除,因此需要找到前驱节点,提高效率。
共享与独占的区别
在获取资源成功后,独占不需要考虑唤醒CLH的后续节点,但共享模式必须考虑虽然获取资源成功,但此时后续节点也完全有可能能够获取资源,因此需要doReleaseShared去唤醒
在释放资源后,独占只需要唤醒一个节点,而共享模式唤醒的节点个数是不确定的
公平 or 非公平
AQS是完全保证自己的公平性的。
final Node p = node.predecessor();
if (p == head && tryAcquire(arg))
可以看到,在进入了CLH队列以后的自旋,尝试获取资源都需要做一个判断:前驱节点为head头节点
这是一个对公平性非常强有力的保证,因为所有尝试获取资源失败的节点都会通过addWaiter方法插入到CLH尾部,保证了CLH的顺序严格按照获取资源的顺序排序。
但是
自定义的tryAcquire方法保证吗?不知道,这取决于实现者。而AQS的acquire方法,首先会调用tryAcquire,失败才放入CLH,那么这一次调用tryAcquire一旦成功,就先于CLH队列中的线程拿到资源,因此不公平。
所以,要想实现公平也很简单了:自定义tryAcquire时,也做一个if (head == preNode)的判断不就好了,当然我们还没插入CLH没有preNode,因此ReentrantLock的fairSync是这样判断的:
// 在try前需要!hasQueuedPredecessors() ,即下面表达式返回false
Node t = tail; Node h = head; Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
// CLH队列还有别的节点在等待就返回true
AQS的等待通知机制:Condition🚩
因为Condition的基本使用和wait/notify完全一样,就不再赘述了
AQS的内部类ConditionObject实现了Condition接口,这个类只对外暴露了Condition接口的六个方法,所以核心逻辑就两大类:等待awaiit,唤醒signal
注意:只有独占模式可以用Condition,共享模式不允许
核心字段
/** First node of condition queue. */
private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;
可以看到,同样是个双向链表结构,注意:head节点为空不存储信息,而tail指向同步队列的尾部
获取Condition对象
public ConditionObject() { }
虽然构造方法是public的,但ReentrantLock提供了newCondition方法获取Condition对象,尽管本质也是无参构造方法而已。
final ConditionObject newCondition() {
return new ConditionObject();
}
await方法
public final void await() throws InterruptedException {
// 插入到Condition双向链表尾部
Node node = addConditionWaiter();
// 释放锁
int savedState = fullyRelease(node);
// 死循环
while (!isOnSyncQueue(node)) {
// 阻塞当前线程
LockSupport.park(this);
// 阻塞被中断 退出循环
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
- 插入到Condition双向链表尾部
- 调用release方法释放资源
- 陷入死循环阻塞,被唤醒时如果发现自己在CLH队列中,就退出循环
- 调用acquireQueued方法,这个就是AQS的acquire的核心方法
acquire方法是先执行tryAcquire然后acquireQueued
acquireQueued就是自旋获取锁/阻塞。
signal方法
public final void signal() {
// 其实核心方法是doSignal 下面是doSignal方法
do {
if ( (firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
// 删除头节点
first.nextWaiter = null;
// transferForSignal 添加到CLH尾部...
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
从Condition队列里将头节点移除,插入到CLH队列尾部
这里和synchronized的notify就不一样了,signal就是插入尾部,而notify至少会比cxq的线程更优先拿到锁
细节是:调用signal后,如果你是唯一的线程,你是需要被唤醒的,但这个属于实现的细节了,知道即可
signalAll方法
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);
first = next;
} while (first != null);
可以看到,也是按顺序一个个唤醒,注意并不是无序的。
那么至此,Condition实现等待通知机制也就差不多了,收个尾:
为什么Condition不需要CAS
Condition的方法必须在拿到锁的情况才可以调用,也就是lock和unlock之间,并且Condition是独占锁特有的工具,至多只有一个线程会拿到锁。因此调用await/signal方法不需要CAS。
笼统地看AQS行为模式
AQS有两个自定义方法,分别代表获取/释放资源逻辑tryacquire,tryrelease
AQS维护一个CLH队列,类似synchronized的Waiting Queue,但是严格保证顺序;有多个等待队列Condition,类似synchronized的WaitSet,但是数量可以根据需要来。
- 获取资源时,只调用一次tryacquire,失败就进CLH,自旋,通常自旋不了几次就会陷入阻塞
- 释放资源时,只调用一次tryrelease,成功,唤醒CLH的头部节点
- 调用await时,线程进Condition队列尾,释放锁资源,阻塞
- signal方法,将Condition队列的头放到CLH的尾
更多的细节就不在这里说了
AQS和synchronized的比较
你会发现,AQS和synchronized的行为模式差不多
在获取资源上:
- synchronized会「自适应自旋」很多次(10000次左右),失败在进入cxq队列阻塞
- AQS只会自旋一次(先调用一次tryAcquire),然后几乎直接阻塞
在唤醒线程上:
synchronized中被notify的线程比起AQS中被signal的线程「优先级更高」,更确定地说,是更容易获取到资源/锁
在释放资源和阻塞线程上区别不大
基于AQS自定义同步器
很显然,我们只需要继承 AbstractQueuedSynchronizer(AQS),然后自定义获取/释放逻辑就行了
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
因为像ReentrantLock,CountDownLatch这些鼎鼎大名的工具类都是基于AQS写的,所以直接看它们的源码,就可以很好的理解如何自定义同步器了。下篇文章会带大家来看一些除ReentrantLock以外的同步器,下面先看ReentrantLock
熟悉AQS后再看ReentrantLock原理
ReentrantLock是独占锁,可以公平/非公平,可重入。
抽象类Sync
独占锁要用Condition要实现isHeldExclusively这个方法
protected final boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
公共的释放资源逻辑
修改state(对volatile域的写)
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
非公平Sync
获取资源
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
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;
}
加锁lock
会先CAS一次,失败再acquire
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
公平Sync
在「公平 or 非公平」这个章节已经分析过 公平Sync 的原理了,核心就是!hasQueuedPredecessors()这个方法
// 在tryAcquire前做个判断
Node t = tail; Node h = head; Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
// 要么头节点的next为空,要么头节点的next就是线程自己
加锁lock:少做一次CAS
直接acquire,非公平会多一次CAS
final void lock() {
acquire(1);
}
tryLock(time)实现原理
虽然sync写了lock方法,但是没写trylock方法呀,也就是除了lock其余的加锁方式是如何实现的?
这些方法其实也被封装在AQS内部
tryLock(time,unit)直接调用tryAcquireNanos,会先tryAcquire一次,失败再doAcquireNanos
public final boolean tryAcquireNanos(int arg, long nanosTimeout) {
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
doAcquireNanos方法是不是很眼熟呢,实际上和acquireQueued差不多,只是自旋失败后阻塞前会检查是否超时,并且阻塞时也给了个时间避免一直阻塞下去
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return true;
}
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false;
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
ReentrantLock小结
特点:可选的公平性,可重入,排他锁,重,悲观锁
原理:依托AQS实现,抽象Sync,对应两种公平/非公平Sync
非公平:
lock:
- 自旋一次,然后acquire方法(自旋就是指tryAcquire方法)
- acquire方法又会自旋一次,然后CAS加入CLH队列尾,CAS将前面节点改为SIGNAL然后阻塞
unlock:
- 修改state
- 唤醒CLH队列头节点
await:
- 线程进Condition队列尾
- 释放锁资源(unlock的逻辑一模一样走一遍)
- 阻塞
signal:
- 将Condition队列的头放到CLH的尾
- 没了
当然signal的实现上还有其他细节,但细节是说不完的,理解核心就可以了
Synchronized和ReentrantLock对比
原理弄清楚了,顺便总结了几点Synchronized和ReentrantLock的区别:
- Synchronized是JVM层次的锁实现,ReentrantLock是JDK层次的锁实现;
- Synchronized的锁状态是无法在代码中直接判断的,但是ReentrantLock可以通过
ReentrantLock#isLocked判断; - Synchronized是非公平锁,ReentrantLock是可以是公平也可以是非公平的;
- Synchronized是不可以被中断的,而
ReentrantLock#lockInterruptibly方法是可以被中断的; - 在发生异常时Synchronized会自动释放锁(由javac编译时自动实现),而ReentrantLock需要开发者在finally块中显示释放锁;
- ReentrantLock获取锁的形式有多种:如立即返回是否成功的tryLock(),以及等待指定时长的获取,更加灵活;
- Synchronized在特定的情况下对于已经在等待的线程是后来的线程先获得锁,而ReentrantLock对于已经在等待的线程一定是先来的线程先获得锁;
Synchronized和ReentrantLock怎么选
显然ReentrantLock是一把比Synchronized悲观的多的锁,但AQS为了能提供公平性这个特点,也是不得不悲观的。在有的场景下,这种悲观也不是坏事,反而Synchronized的接近万次的自旋在竞争激烈的场景下对CPU的计算资源是个不小的负担。因此能否说竞争激烈就用ReentrantLock呢?也仅仅是理论上可能优秀,还需要考虑的点有很多,实践才能出真知。