目录
Lock 锁
介绍
锁是用来控制多个线程访问共享资源的方式,锁能够防止多个线程同时访问共享资源。在
Lock
接口出现之前,java 程序主要是靠synchronized
关键字实现同步功能。Java 1.5 后,推出了Lock
接口,它拥有与synchronized
相同的并发性和内存语义,在实现线程安全的控制中。与synchronized
不同的是Lock
需要显式的加锁与释放锁,但它比synchronized
更加灵活,且效率更高。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized
关键字所不具备的同步特性。其中最常使用的是其实现类ReentrantLock
。
Lock lock = new ReentrantLock();
lock.lock();
try {
.......
} finally {
lock.unlock();
}
注意:
在
finally
块中释放锁,目的是保证在获取到锁之后,锁最终能够被释放【即便是发生异常】。不要将获取锁的过程写在
try
块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放【还没获取到锁】。
Lock
接口提供的synchronized
关键字所不具备的主要特性如下表所示:
Lock
是一个接口,它定义了锁获取和释放的基本操作,下面列出了Lock
接口的 API:
了解 AQS
AQS
即AbstractQueuedSynchronizer
。通过查看ReentrantLock
源码可知,ReentrantLock
的加锁、解锁等操作都是基于其静态内部类Sync
实现的,而Sync
继承了AbstractQueuedSynchronizer
抽象类。由此可知AbstractQueuedSynchronizer
即同步队列器是实现Lock
的核心,AbstractQueuedSynchronizer
是理解ReentrantLock
的关键。
通过查看
AbstractQueuedSynchronizer
源码以及 jdk1.8 文档,我们发现AbstractQueuedSynchronizer
是用来构建锁和其它同步组件的基础,用于实现依赖先进先出(FIFO)等待队列的阻塞锁和相关同步器,这些同步器依赖于单个原子 int 值来表示同步状态,以及使用一个 FIFO 队列构成同步队列。它的子类通过实现其protected
修饰的几个方法来改变同步状态。状态的获取与更新主要使用getState()
,setState()
以及compareAndSetState()
这三个方法。
其子类应定义为非公共的静态内部类,用于实现其封闭类的同步属性。
AbstractQueuedSynchronizer
类不实现任何同步接口,它仅仅是定义了若干个同步锁的获取和释放的方法用来供自定义同步组件使用,此类支持默认独占模式
和共享模式
,方便实现不同的同步组件,同步器是实现锁的关键。
Lock
与AQS
对比:
Lock
面向使用者,它定义了使用者与锁交互的接口,隐藏了实现细节,让使用者不必去关注锁的底层实现。AQS
面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待和唤醒等底层操作,提供了一套约定好的模板,使开发者更方便的实现自定义锁的逻辑。Lock
和AQS
很好的隔离了锁的使用者和实现者所需关注的领域。
AQS 的模板方法
AQS
提供了一套模板方法开放给子类重写,用以实现子类自定义的同步语义。
由上图可知,
AQS
提供的模板方法主要分为以下几类:
- 独占式获取和释放同步状态
- 共享式获取和释放同步状态
- 查询同步队列中等待线程的状态
自定义同步组件
以下为自定义的一个
独占式
的同步锁
public class MutexLock {
private static CustomLock customLock = new CustomLock();
public static void main(String[] args) {
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
customLock.lock();
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName() + " ----- " + System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
customLock.unlock();
}
}, "alieay-" + i).start();
}
}
}
class CustomLock implements Lock, Serializable {
private static class Sync extends AbstractQueuedSynchronizer {
/**
* 尝试以独占模式获取锁
*
* @param acquires
* @return
*/
@Override
protected boolean tryAcquire(int acquires) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
/**
* 尝试以独占模式释放锁
*
* @param releases
* @return
*/
@Override
protected boolean tryRelease(int releases) {
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
/**
* 当前线程是否获取到锁
*
* @return
*/
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
/**
* 获取 condition 对象,拥有等待通知操作
*/
Condition newCondition() {
return new ConditionObject();
}
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
setState(0);
}
}
private final Sync sync = new Sync();
@Override
public void lock() {
sync.acquire(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public void unlock() {
sync.release(1);
}
public boolean isLocked() {
return sync.isHeldExclusively();
}
public boolean hasQueuedThreads() {
return sync.hasQueuedThreads();
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
@Override
public Condition newCondition() {
return sync.newCondition();
}
}
以上,
CustomLock
定义了一个继承AQS
的私有的静态内部类Sync
,并且实现了tryAcquire
、tryRelease
、isHeldExclusively
这几个独占式获取和释放的方法。并通过setState()
,getState()
,compareAndSetState()
这几个方法来获取和修改同步状态。而自定义的独占锁CustomLock
通过调用AQS
提供的模板方法来实现同步语义。运行程序,查看线程状态,如下图:同一时刻只能有一个线程占用锁,其它线程则进入等待状态。下面图中
akieay-1
获取锁并执行sleep
方法进入阻塞状态,其它线程则进入等待状态。查看控制台信息:控制台每隔 2 秒打印一条提示信息,说明这10个线程一起竞争锁,但是同一时刻只能有一个线程获取到锁,并且执行完成释放锁后其它线程才能再去竞争锁。
总结:在同步组件的实现上主要是利用了
AQS
,而AQS
“屏蔽” 了同步状态的修改、线程排队等底层实现,通过AQS
的模板方法可以很方便的对同步组件的实现者进行调用。而针对使用者来说,只需要调用同步组件提供的方法来实现并发编程即可。使用AQS
实现同步组件主要把握以下两点:
- 实现同步组件时,推荐使用私有的静态内部类继承
AbstractQueuedSynchronizer
并重写需要实现的protected
修饰的方法;- 同步组件的实现依赖于
AQS
的模板方法,其中的方法基本上都是通过调用AQS
的模板方法来实现,而AQS
的模板方法又依赖于其子类实现。
队列同步器(AQS)详解
上面我们介绍了
AbstractQueuedSynchronizer
的作用以及使用它实现了自定义的同步组件,下面我们将介绍AbstractQueuedSynchronizer
的原理。由于AQS
支持两种模式:独占式锁
与共享式锁
,这里我们将分别介绍这两种模式的实现原理。
同步队列
在介绍
独占锁
与共享锁
之前我们首先介绍一下同步队列(FIFO)。同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下表:
在
AQS
有一个静态内部类Node
,其属性如下:
static final class Node {
/** 标记,指示节点正在共享模式下等待 */
static final Node SHARED = new Node();
/** 标记,指示节点正在独占模式下等待 */
static final Node EXCLUSIVE = null;
/** 节点从同步队列中取消 */
static final int CANCELLED = 1;
/** 表示后继节点的线程处于等待状态,如果当前节点释放同步状态,则会通知后继节点,使得后继节点的线程能够运行 */
static final int SIGNAL = -1;
/** 表示当前节点处于等待队列中 */
static final int CONDITION = -2;
/** 表示下一次共享式同步状态获取将会无条件传播下去 */
static final int PROPAGATE = -3;
/** 表示节点状态:SIGNAL、CANCELLED、CONDITION、PROPAGATE */
volatile int waitStatus;
/** 当前节点/线程的前驱节点 */
volatile Node prev;
/** 当前节点/线程的后继节点 */
volatile Node next;
/** 当前节点的线程 */
volatile Thread thread;
/** 等待队列的下一个节点 */
Node nextWaiter;
根据节点的属性我们可以推导出,同步队列(FIFO)是一个使用
链表实现的双端队列
,通过打断点的方式我们也可以看到队列中如下信息【运行的是我们上面自定义的 独占锁】:
节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如图:
上图:同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于
CAS
的设置尾节点的方法:compareAndSetTail(Node expect,Node update)
,它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。同步器将节点加入到同步队列的过程如图:
同步队列遵循 FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,该过程下图所示:
上图:设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用
CAS
来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next
引用即可。
独占锁
独占锁相关的方法如下:
/** 独占式获取同步状态,如果获取失败则插入同步队列进行等待 */
void acquire(int arg);
/** 与acquire方法相同,但在同步队列中进行等待的时候可以检测中断 */
void acquireInterruptibly(int arg);
/** 在acquireInterruptibly基础上增加了超时等待功能,在超时时间内没有获得同步状态返回false */
boolean tryAcquireNanos(int arg, long nanosTimeout);
/** 释放同步状态,该方法会唤醒在同步队列中的下一个节点 */
boolean release(int arg);
独占锁的获取
通过调用同步器的
acquire(int arg)
方法可以获取同步状态,该方法对中断不敏感。也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 中断线程
selfInterrupt();
}
上述代码主要完成了:同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:首先调用自定义同步器实现的
tryAcquire(int arg)
方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE
,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)
方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)
方法,使得该节点以 “死循环” 的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
tryAcquire(arg)
:获取同步状态,获取成功则方法结束返回addWaiter(Node node)
:将该节点加入到同步队列的尾部acquireQueued(Node node,int arg)
:使得该节点以 “死循环” 的方式获取同步状态,若等待过程中线程被中断,则返回 true,反之返回 false。
加入同步队列
当线程获取独占式锁失败后就会将当前线程构造成节点加入同步队列尾部。主要关注同步器的
addWaiter
和enq
方法:
private Node addWaiter(Node mode) {
// 1、将当前线程构建成 Node 节点
Node node = new Node(Thread.currentThread(), mode);
// 获取尾节点
Node pred = tail;
// 2、判断尾节点是否为 null
if (pred != null) {
// 2.1、尾节点不为空,将当前节点插入同步队列尾部,使用 CAS 保证同步安全
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 2.2 当前同步队列尾节点为 null,说明当前线程是第一个加入同步队列进行等待的线程
enq(node);
return node;
}
上述代码通过使用
compareAndSetTail(Node expect,Node update)
方法来确保节点能够被线程安全添加。试想一下:如果使用一个普通的LinkedList
来维护节点之间的关系,那么当一个线程获取了同步状态,而其他多个线程由于调用tryAcquire(int arg)
方法获取同步状态失败而并发地被添加到LinkedList
时,LinkedList
将难以保证Node
的正确添加,最终的结果可能是节点的数量有偏差,而且顺序也是混乱的。另外注意一点:上面使用
compareAndSetTail(Node expect,Node update)
从尾部插入节点,若 CAS 操作返回 false,执行插入操作失败,这时还会继续执行enq
方法。所以enq
承担了两个任务,即:
- 处理当前同步队列尾节点为 null 时进行入队操作
- 如果 CAS 队尾插入节点失败后负责自旋进行尝试
enq
方法源码如下:
private Node enq(final Node node) {
for (;;) {
// 1、获取尾部节点
Node t = tail;
// 2、判断尾部节点是否为空
if (t == null) {
// 2.1、构造头节点
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 2.2、使用 CAS 方式在同步队列尾部插入节点
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
在
enq(final Node node)
方法中,同步器通过 “死循环"【for (;;)
死循环,return 才能退出】 来保证节点的正确添加,在 “死循环” 中只有通过CAS
将节点设置成为尾节点之后,当前线程才能从该方法返回。否则,当前线程不断地尝试设置。可以看出,enq(final Node node)
方法将并发添加节点的请求通过CAS
变得“串行化”了,对enq(final Node node)
方法进的操作总结为以下几点:
- 在当前节点是第一个加入同步队列时,调用 compareAndSetHead(new Node()) 方法,完成链式队列的头结点的初始化【尾节点为 null 时,即第一个加入同步的节点,CAS 创建头节点】
- 自旋不断尝试 CAS 同步队列尾部插入节点直至成功为止
排队获取锁
节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。
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;
}
// 检查并更新未能获取节点的状态【若前驱节点状态为SIGNAL,即当前节点的线程阻塞,则返回true】 且
// 若 shouldParkAfterFailedAcquire 方法返回 true,则阻塞线程并检查线程是否中断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 取消正在进行的获取锁的尝试
cancelAcquire(node);
}
}
private void setHead(Node node) {
head = node;
node.thread = null;
// 将当前节点的前驱节点引用置空
node.prev = null;
}
/** 只有在前驱节点状态为 SIGNAL,即当前节点的线程阻塞时,返回 true */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱节点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
// 若前驱节点状态为 SIGNAL(-1),则不用操作,表示其后继节点处于线程等待状态
return true;
if (ws > 0) {
// 若前驱节点状态 > 0,即为1 表示前驱节点从同步队列中取消
do {
// 将前驱节点的前驱节点设置为当前节点的前驱节点,并判断状态,直到状态值 <= 0【即没从同步队列中取消】
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 前驱节点状态为 0 or PROPAGATE,将前驱节点状态设置为 SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
private final boolean parkAndCheckInterrupt() {
// 禁止当前线程进行线程调度
LockSupport.park(this);
// 测试当前线程是否中断
return Thread.interrupted();
}
在
acquireQueued(final Node node,int arg)
方法中,当前线程在 “死循环” 中尝试获取同步状态。但是只有先驱节点是头节点才能够尝试获取同步状态,原因如下:
第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点,如果是则尝试获取同步状态。
第二,维护同步队列的 FIFO 原则。该方法中,节点自旋获取同步状态的行为如下图所示:
为什么需要判断节点的前驱节点是否为头节点呢?
为了便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒),这时就需要判断其先驱节点是否为头节点,只有先驱节点为头节点才能去获取同步状态。
可以看到节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合 FIFO【先进先出原则】。
总结:
- 如果当前节点的前驱节点是头节点,并且能够获得同步状态的话,当前线程能够获得同步状态/锁,该方法执行结束退出
- 获取锁失败的话,先将前驱节点状态设置成 SIGNAL,然后调用 LookSupport.park() 方法使得当前线程阻塞
独占式同步状态获取流程,也就是
acquire(int arg)
方法调用流程,如下图所示:
上图:
前驱节点为头节点
且能够获取同步状态
的判断条件和线程进入等待状态
是获取同步状态的自旋过程。当同步状态获取成功之后,当前线程从acquire(int arg)
方法返回,如果对于锁这种并发组件而言,代表着当前线程获取了锁。
可中断式获取独占锁
当一个线程获取不到锁而被阻塞在
synchronized
之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在synchronized
上,等待着获取锁。在 Java 5 中,同步器提供了acquireInterruptibly(int arg)
方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException
。acquireInterruptibly(int arg)
方法源码如下:
public final void acquireInterruptibly(int arg)
throws InterruptedException {
// 测试当前线程是否中断,若已中断,则抛出异常
if (Thread.interrupted())
throw new InterruptedException();
// 独占式获取同步状态
if (!tryAcquire(arg))
// 获取同步状态失败,调用 doAcquireInterruptibly 方法
doAcquireInterruptibly(arg);
}
private void doAcquireInterruptibly(int arg)
throws InterruptedException {
// 将当前节点加入到同步队列的尾部
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;
}
// 检查并更新未能获取节点的状态【若前驱节点状态为SIGNAL,即当前节点的线程阻塞,则返回true】 且
// 若 shouldParkAfterFailedAcquire 方法返回 true,则阻塞线程并检查线程是否中断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 若中断,则抛出异常
throw new InterruptedException();
}
} finally {
if (failed)
// 取消正在进行的获取锁的尝试
cancelAcquire(node);
}
}
流程与
acquire
方法基本类似,唯一的区别是线程中断时的处理,会抛出InterruptedException
异常。
独占式超时获取同步状态
通过调用同步器的
tryAcquireNanos(int arg, long nanosTimeout)
方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回 true;若在超时时间内被中断,则抛出异常InterruptedException
;若超时时间结束,则返回 false。该方法提供了传统 Java 同步操作(比如synchronized
关键字)所不具备的特性。方法调用结果总结:
- 在超时时间内,获取到同步状态返回 true
- 在超时时间内被中断,抛出 InterruptedException
- 超时时间结束,仍未获得同步状态返回 false
超时获取同步状态过程可以被视作响应中断获取同步状态过程的 “增强版”,
doAcquireNanos(int arg,long nanosTimeout)
方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout
,为了防止过早通知,nanosTimeout
计算公式为:nanosTimeout = deadline - System.nanoTime()
,其中deadline
为计算出来的终止时间,System.nanoTime()
为当前时间,如果nanosTimeout
大于0 则表示超时时间未到,需要继续睡眠nanosTimeout
纳秒,反之,表示已经超时,该方法源码如下:
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
// 若线程处于中断状态,则抛出 InterruptedException 异常
if (Thread.interrupted())
throw new InterruptedException();
// 获取同步状态,若成功则返回;若获取失败,则调用 doAcquireNanos 方法实现超时等待的效果
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
// 判断超时时间
if (nanosTimeout <= 0L)
return false;
// 根据当前时间 + 超时时间计算出 截止时间
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();
// 已经超时返回 false
if (nanosTimeout <= 0L)
return false;
// 检查并更新未能获取节点的状态【若前驱节点状态为SIGNAL,即当前节点的线程阻塞,则返回true】
// 且超时时间大于 1秒【默认】
if (shouldParkAfterFailedAcquire(p, node) &&
nanosTimeout > spinForTimeoutThreshold)
// 线程阻塞等待,禁用当前线程进行线程调度,直到指定的等待时间,除非许可证可用
LockSupport.parkNanos(this, nanosTimeout);
// 若线程处于中断状态,则抛出 InterruptedException
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
// 取消正在进行的获取锁的尝试
cancelAcquire(node);
}
}
该方法在自旋过程中,当前节点的
前驱节点为头节点
时尝试获取同步状态
,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上有所不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout
小于等于0 表示已经超时),如果没有超时,重新计算超时间隔nanosTimeout
,然后使当前线程等待nanosTimeout
纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Object blocker,long nanos)
方法返回)。 如果nanosTimeout
小于等于spinForTimeoutThreshold(1000纳秒)
时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因在于:非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout
的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。
独占式超时获取同步态的流程图如下:
从上图可以看出,独占式超时获取同步状态
tryAcquireNanos(int arg, long nanosTimeout)
和独占式获取同步状态acquire(int args)
在流程上非常相似,其主要区别在于未获取到同步状态时的处理逻辑。acquire(int args)
在未获取到同步状态时,将会使当前线程一直处于等待状态,而tryAcquireNanos(int arg, long nanosTimeout)
会使当前线程等待nanosTimeout
纳秒,如果当前线程在nanosTimeout
纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。
独占锁的释放
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的
release(int arg)
方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。该方法源码如下:
public final boolean release(int arg) {
// 子类自定义的释放锁的方法,true:锁释放成功,false:锁释放失败
if (tryRelease(arg)) {
// 获取头节点
Node h = head;
// 头节点不为空,且其状态值不为0【初始状态】
if (h != null && h.waitStatus != 0)
// 唤醒节点的后续节点【如果存在的话】
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
// 获取头节点状态
int ws = node.waitStatus;
if (ws < 0)
// 状态若为负数,则尝试将状态修改为 0,如果更新失败也没关系
compareAndSetWaitStatus(node, ws, 0);
// 获取头节点的后继节点为要被唤醒的节点
Node s = node.next;
// 后继节点不存在,或者状态大于 0,即状态为1 节点从同步队列中取消了
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;
}
if (s != null)
// 节点不为空时,为给定的线程提供许可证,唤醒线程
LockSupport.unpark(s.thread);
}
该方法执行时,会唤醒头节点的可用的后继节点线程【如果有的话】,
unparkSuccessor(Node node)
方法使用LockSupport.unpark(Thread thread)
来唤醒处于等待状态的线程。
总结
至此,我们学习了独占锁的获取和释放,总结如下:
- 线程获取同步状态失败时,线程被封装成 Node 节点添加到同步队列队尾,核心方法在于 addWaiter() 和 enq(),同时 enq() 完成对同步队列的头结点初始化工作 以及 CAS 将节点添加到同步队列时失败后的重试
- 线程获取同步状态是一个自旋的过程,当且仅当 当前节点的前驱节点是
头结点
并且成功获得同步状态
时,节点出队且该节点引用的线程获得锁;否则,当不满足条件时就会调用LookSupport.park()
方法使得线程阻塞;- 释放锁的时候会唤醒后继节点,并调用
LockSupport.unpark(Thread thread)
方法唤醒线程;在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列尾部并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用
tryRelease(int arg)
方法释放同步状态,然后唤醒头节点的后继节点。
共享锁
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例:如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问。两种不同的访问模式在同一时刻对文件或资源的访问情况,如下图所示:
左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞【无论是共享式还是独占式】。
AQS
中共享锁相关方法如下:
/* 共享式获取同步状态,与独占式的区别在于同一时刻有多个线程获取同步状态 */
void acquireShared(int arg);
/* 在 acquireShared 方法基础上增加了能响应中断的功能 */
void acquireSharedInterruptibly(int arg);
/* 在 acquireSharedInterruptibly 基础上增加了超时等待的功能 */
boolean tryAcquireSharedNanos(int arg, long nanosTimeout)
/* 共享式释放同步状态 */
boolean releaseShared(int arg);
共享锁的获取
通过调用同步器的
acquireShared(int arg)
方法可以共享式地获取同步状态,其源码如下:
public final void acquireShared(int arg) {
// 子类自定义的共享锁的获取方法
if (tryAcquireShared(arg) < 0)
// 获取失败调用 doAcquireShared
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
// 该节点加入到同步队列的尾部
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
// 自旋获取锁
for (;;) {
// 获取当前节点的先驱节点
final Node p = node.predecessor();
// 若先驱节点为头节点
if (p == head) {
// 尝试获取共享锁
int r = tryAcquireShared(arg);
if (r >= 0) {
// 共享锁获取成功,则设置节点为头节点,并检查后继队列是否在共享模式下等待,如果是,则传播
setHeadAndPropagate(node, r);
// 释放先驱节点
p.next = null;
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 检查并更新未能获取节点的状态【若前驱节点状态为SIGNAL,即当前节点的线程阻塞,则返回true】 且
// 若 shouldParkAfterFailedAcquire 方法返回 true,则阻塞线程并检查线程是否中断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 取消正在进行的获取锁的尝试
cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
// 设置新的头节点
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
// 获取头节点后继节点
Node s = node.next;
// 若后继节点不为空 或 头节点在共享模式下等待
if (s == null || s.isShared())
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;
//唤醒后继节点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
在
acquireShared(int arg)
方法中,同步器调用tryAcquireShared(int arg)
方法尝试获取同步状态,tryAcquireShared(int arg)
方法返回值为 int 类型,当返回值大于等于 0 时,表示能够获取到同步状态。因此,在共享式获取同步状态的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)
方法返回值大于等于 0。否则,表明获取同步状态失败,会执行doAcquireShared(int arg)
方法;可以看到,在doAcquireShared(int arg)
方法的自旋过程中,如果当前节点的先驱节点为头节点时,尝试获取同步状态,如果返回值大于等于 0,表示该次获取同步状态成功并从自旋过程中退出【流程大致与独占锁差不多】。由于共享锁的
acquireSharedInterruptibly
与tryAcquireSharedNanos
方法的实现原理与独占式对应方法的原理及流程基本一致,就不单独介绍了,可自行查阅AbstractQueuedSynchronizer
源码。只要理解了上面独占锁的原理,这里的也是非常容易弄懂的。
共享锁的释放
与独占式一样,共享式获取也需要释放同步状态,通过调用
releaseShared(int arg)
方法可以释放同步状态,该方法源码如下:
public final boolean releaseShared(int arg) {
// 子类自定义的共享锁的释放方法
if (tryReleaseShared(arg)) {
// 同步状态释放成功则执行 doReleaseShared
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
/*
确保发布的传播,即使有其他正在进行的获取/发布。如果head需要信号,则按照通常的方式尝试释放它的继承者。但如果没有,则将status设置为PROPAGATE, 以确保在发布时继续传播。此外,我们必须循环,以防止在执行此操作时添加新节点。另外,与 unparkSuccessor 的其他用途不同,我们需要知道CAS重置状态是否失败, 如果失败,则重新检查。
*/
for (;;) {
// 获取头节点
Node h = head;
// 头节点不为空且不为尾节点
if (h != null && h != tail) {
// 获取头节点状态
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
// CAS 将头节点状态修改为 0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
// 唤醒节点的后继节点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, 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;
}
if (s != null)
LockSupport.unpark(s.thread);
}
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于
tryReleaseShared(int arg)
方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS
来保证的,因为释放同步状态的操作可能同时来自多个线程。
重入锁
基本概念
重入锁
ReentrantLock
,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。回忆上面我们在
Lock 锁
章节通过AQS
实现的自定义同步组件MutexLock
,考虑如下场景:当一个线程调用MutexLock
的lock()
方法获取锁之后,如果再次调用lock()
方法,则该线程会被所自己阻塞;其原因是MutexLock
在实现tryAcquire(int acquires)
方法时没有考虑占有锁的线程再次获取锁的场景,而在调用tryAcquire(int acquires)
方法重复获取锁时返回了 false,导致该线程被阻塞。简单地说,MutexLock
是一个不支持重进入的锁。而synchronized
关键字隐式的支持重进入,比如一个synchronized
修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像MutexLock
一样由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。
ReentrantLock
虽然没能像synchronized
关键字一样支持隐式的重进入,但是在调用lock()
方法时,已经获取到锁的线程,能够再次调用lock()
方法获取锁而不被阻塞,但是需要注意的是:调用一次lock()
方法加锁,就必须有对应的一次unlock()
方法解锁。
重入锁 与 不可重入锁
这里我们使用
MutexLock
与ReentrantLock
来演示重入锁与不可重入锁。
public class MutexLock {
private static CustomLock customLock = new CustomLock();
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
customLock.lock();
customLock.lock();
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName() + " ----- " + System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
customLock.unlock();
customLock.unlock();
}
}, "alieay-" + i).start();
}
}
}
class CustomLock implements Lock, Serializable {
private static class Sync extends AbstractQueuedSynchronizer {
/**
* 尝试以独占模式获取锁
*
* @param acquires
* @return
*/
@Override
protected boolean tryAcquire(int acquires) {
assert acquires == 1;
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
/**
* 尝试以独占模式释放锁
*
* @param releases
* @return
*/
@Override
protected boolean tryRelease(int releases) {
assert releases == 1;
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
/**
* 当前线程是否获取到锁
*
* @return
*/
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
Condition newCondition() {
return new ConditionObject();
}
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
setState(0);
}
}
private final Sync sync = new Sync();
@Override
public void lock() {
sync.acquire(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public void unlock() {
sync.release(1);
}
public boolean isLocked() {
return sync.isHeldExclusively();
}
public boolean hasQueuedThreads() {
return sync.hasQueuedThreads();
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
@Override
public Condition newCondition() {
return sync.newCondition();
}
}
上面我们获取了
CustomLock
两次的锁,启动应用程序:应用程序一直处于阻塞状态,无法往下执行。
修改 main 方法,使用
ReentrantLock
,启动应用程序:应用程序可以正常执行
通过上面的演示我们了解了 重入锁 与 不可重入锁。
公平锁 与 非公平锁
上面我们提到了
ReentrantLock
重入锁还支持获取锁时的公平和非公平性选择。这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock
提供了一个构造函数,能够控制锁是否是公平的。事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以 TPS 作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
下面是一个用来演示公平锁与非公平锁的案例:
public class FairAndUnfairTest {
/**
* 公平锁
*/
private static Lock fairLock = new ReentrantLock(true);
/**
* 非公平锁
*/
private static Lock unfairLock = new ReentrantLock(false);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
fairLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " start run ---------");
} catch (Exception e) {
e.printStackTrace();
} finally {
fairLock.unlock();
}
}, "fairLock-" + i).start();
}
}
}
以上是一个使用 公平锁 的案例,运行应用程序:线程是按顺序执行的,即先加入的先执行【先进先出】,符合 FIFO。
修改 main 方法,使用非公平锁。
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
unfairLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " start run ---------");
} catch (Exception e) {
e.printStackTrace();
} finally {
unfairLock.unlock();
}
}, "unfairLock-" + i).start();
}
}
运行应用程序:这时可以看到,线程的运行就不是按添加顺序执行的了。
重入锁的实现原理
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题:
线程再次获取锁
。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。锁的最终释放
。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。
锁的获取
ReentrantLock
是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认的)实现为例,获取同步状态的核心方法为nonfairTryAcquire
,其源码如下:
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
int c = getState();
// 若锁未被占用
if (c == 0) {
// CAS 修改同步状态值
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;
}
该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功,否则返回 false,表示获取同步状态失败。
锁的释放
成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求
ReentrantLock
在释放同步状态时减少同步状态值,核心方法为tryRelease
,该方法的源码如下:
protected final boolean tryRelease(int releases) {
// 同步状态值减少
int c = getState() - releases;
// 若当前线程不是占用锁的线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 若同步状态为 0,即:所有获取的锁都已释放完成
if (c == 0) {
free = true;
// 设置占用锁的线程为 null
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
如果该锁被获取了 n 次,那么前 (n-1) 次
tryRelease(int releases)
方法必须返回 false,而只有同步状态完全释放了,才能返回 true。可以看到,该方法将同步状态是否为 0 作为最终释放的条件,当同步状态为 0 时,将占有线程设置为 null,并返回 true,表示释放成功。
公平锁 与 非公平锁原理
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO。
ReentrantLock
提供了一个构造函数,能够控制锁是否是公平的【默认非公平】。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
公平锁的获取
上面的关于重入锁的介绍中,我们以非公平锁为例介绍了锁的获取,这里将介绍公平锁的获取,其核心方法为静态内部类
FairSync
的tryAcquire
方法,源码如下:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
int c = getState();
// 若锁未被占用
if (c == 0) {
// 判断是否存在前驱节点,若存在返回 true,反之返回 false
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 若占用锁的线程的当前线程
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
该方法与非公平锁的
nonfairTryAcquire(int acquires)
方法基本一样,唯一不同的是:当锁未被占用时,获取锁的判断条件多了hasQueuedPredecessors()
方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。
问题:为什么 ReentrantLock
的默认实现是 非公平锁呢?
如果把每次不同线程获取到锁定义为 1 次切换,公平锁为了保证"公平" 会频繁的进行上下文切换,而非公平性锁由于不用保证"公平",极少的线程切换。并且上下文的切换是非常耗费资源的,这说明非公平性锁的开销更小。
我们通过开启 10 个线程 每个线程进行 100000 次获取与释放锁的操作,分别使用公平锁与非公平锁,查看运行结果。
公平锁
public class FairAndUnfairTest {
private static volatile int a = 0;
/**
* 公平锁
*/
private static Lock fairLock = new ReentrantLock(true);
/**
* 非公平锁
*/
private static Lock unfairLock = new ReentrantLock(false);
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 100000; i1++) {
fairLock.lock();
try {
a ++;
System.out.println(Thread.currentThread().getName() + " start run ---------");
} catch (Exception e) {
e.printStackTrace();
} finally {
fairLock.unlock();
}
}
}, "fairLock-" + i).start();
}
while (a != 1000000) {
}
System.out.println("运行耗时:"+(System.currentTimeMillis() - start));
}
}
以上为公平锁的案例,运行查看结果:运行耗时为 17257 毫秒,且存在频繁的线程切换。
非公平锁
修改 main 方法,改为使用非公平锁。
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 100000; i1++) {
unfairLock.lock();
try {
a ++;
System.out.println(Thread.currentThread().getName() + " start run ---------");
} catch (Exception e) {
e.printStackTrace();
} finally {
unfairLock.unlock();
}
}
}, "unfairLock-" + i).start();
}
while (a != 1000000) {
}
System.out.println("运行耗时:"+(System.currentTimeMillis() - start));
}
运行查看结果:运行耗时为 5559 毫秒,且线程切换很少。
总结:
公平锁每次都是从同步队列中的第一个节点获取到锁,保证请求资源时间上的绝对顺序【FIFO】;而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁,甚至可能导致其他线程永远无法获取到锁,造成
饥饿
现象。公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,
ReentrantLock
默认选择的是非公平锁,减少一部分上下文切换,保证了系统更大的吞吐量。