目录
一.Lock接口
1.1Lock的使用
1.2Lock接口提供的 synchronized 不具备的主要特性
1.3Lock接口的所有方法
二.队列同步器(AQS)
2.1队列同步器的接口与示例
2.2AQS实现源码分析
①同步队列
②独占锁的获取与释放
获取锁
释放锁
③共享锁的获取与释放
获取锁
释放锁
说起锁,你肯定会想到 synchronized 关键字, 没错,这是在jdk1.5之前java程序用来实现锁功能的。而 jdk1.5 之后,并发包中增加了 Lock 接口用来实现锁功能,它的功能和 synchronized 类似,不过使用时需要显示的获取和释放锁。虽然它缺少了(通过synchronized 块或方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
Lock 的使用也很简单,如下所示:
在 finally 块中释放锁,目的是保证在获取到锁之后,最终能够被释放。注意:
队列同步器 AbstractQueuedSynchronizer,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。
队列同步器的主要使用方式是继承,子类通过继承队列同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用队列同步器提供的3个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,队列同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来 供自定义同步组件使用,队列同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch等)。
队列同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合队列同步器,利用队列同步器实现锁的语义。可以这样理解二者之间的关系:
队列同步器AQS的设计是基于模板方法的,即使用者需要继承队列同步器并重写指定的方法,然后将队列同步器组合在自定义同步组件中,并调用同步器提供的模板方法,这些模板方法会调用使用者的重写方法。
同步器为了让使用者重写指定的方法,提供了三个基础方法:
同步器可重写的方法分为 独占式获取锁 和 共享式获取锁,如下图所示:
实现自定义同步组件时,将会调用同步器提供的模板方法(这些模板方法会调用使用者的重写方法),部分模板方法如下:
同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法 来实现自己的同步语义。
独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。
接下来,我们自己实现一个独占锁,采用组合自定义同步器AQS的方式,只有搞懂了AQS才能更加深入的去学习理解 并发包中的其它同步组件。
public class Mutex implements Lock {
private static class Sync extends AbstractQueuedSynchronizer {
//是否处于独占状态
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
//当状态为 0 时获取锁
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
//释放锁,将状态置为 0
@Override
protected boolean tryRelease(int arg) {
if(getState()==0||getExclusiveOwnerThread()!=Thread.currentThread()){
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
//返回一个 Condition,每个 condition 都包含了一个 condition 队列
Condition newCondition(){
return new ConditionObject();
}
}
//仅需要将操作代理到 Sync 上即可
private final Sync sync=new Sync();
@Override
public void lock() {
sync.acquire(1);
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
public void unlock() {
sync.release(1);
}
@Override
public Condition newCondition() {
return sync.newCondition();
}
public boolean isLocked(){
return sync.isHeldExclusively();
}
public boolean hasQueuedThreads(){
return sync.hasQueuedThreads();
}
}
如示例代码所示,Mutex中定义了一个静态内部类,它继承了同步器实现了独占式获取和释放同步状态。
在 tryAcquire(int acquires) 方法中,如果经过CAS设置成功(同步状态设置为1),则代表获 取了同步状态,而在 tryRelease(int releases) 方法中只是将同步状态重置为0。
用户使用Mutex时并不会直接和内部同步器的实现打交道,而是调用Mutex提供的方法,在Mutex的实现中,以获取锁的 lock() 方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args) 即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样大大简化了实现一个可靠自定义同步组件的门槛。
接下来将从实现角度分析队列同步器是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。
我们先看看AQS中的一些重要属性:
/**
* 指向head头结点,即持有当前锁的线程,head节点不属于同步阻塞队列
*/
private transient volatile Node head;
/**
* 获取锁失败时,都会构造节点放入同步队列,tail指向尾结点
*/
private transient volatile Node tail;
/**
* 实现锁的关键,即同步状态
* 0表示未被占用,大于0表示有线程占用锁资源
*/
private volatile int state;
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态管理,流程是这样的:当线程获取同步状态失败时,同步器会将当前线程以及等待状态构造成为一个节点(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;
//节点在等待队列中,节点线程等候在condition上
//当其它线程调用了condition的signal方法后,该节点从等待队列移到同步队列
static final int CONDITION = -2;
//共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点
static final int PROPAGATE = -3;
//等待状态
volatile int waitStatus;
//前驱结点
volatile Node prev;
//后继结点
volatile Node next;
volatile Thread thread;
//等待队列中的后继结点。如果当前节点是共享的,那么这个字段将是个shared常量
//也就是说节点类型(独占和共享)和等待队列中的后继结点共用同一个字段
Node nextWaiter;
......
}
其实就是5个属性:thread(获取同步状态的线程) + waitStatus(等待状态) + prev(前驱结点) + next(后继结点) + nextWaiter(等待队列中的后继结点)
节点是构成同步队列的基础,同步器拥有首尾节点,获取同步失败的线程将会成为节点加入到该队列尾部,同步队列的基本结构如下图:
同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
获取锁分为独占式和共享式,这里我们只讲独占式获取锁模式。通过调用AbstractQueuedSynchronizer的模板方法 public final void acquire(int arg){}可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。
如下图所示:
//独占式同步状态获取,不响应中断
public final void acquire(int arg) {
//tryAcquire(arg)需要同步组件自己实现
if (!tryAcquire(arg) &&
//tryAcquire(arg)没有成功,将该线程放入同步队列的尾部
//acquireQueued方法返回的是线程在获取锁的过程中是否发生过中断,返回true则证明发生过中断
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//selfInterrupt() 如果线程在等待过程中被中断,调用 selfInterrupt() 方法将线程的中断状态重新设置
selfInterrupt();
}
private Node addWaiter(Node mode) {
//将当前线程包装成一个Node节点
Node node = new Node(Thread.currentThread(), mode);
// 快速尝试在尾部添加,失败则进入完备的enq方法
Node pred = tail;
//尾结点不为空,则尝试将新节点插入到最后
if (pred != null) {
//将当前节点的前置指针指向pred(原尾结点)
node.prev = pred;
//CAS操作将当前节点设为尾结点,成功后tail将指向当前节点
if (compareAndSetTail(pred, node)) {
//将pred(原尾结点)的下一节点指针指向当前节点,完成双向设置
pred.next = node;
//返回当前节点(此时当前节点就已经是尾结点了)
return node;
}
}
//如果尾结点为空或者CAS操作失败
enq(node);
return node;
}
private Node enq(final Node node) {
//死循环
for (;;) {
//声明一个t的指针指向tail
Node t = tail;
//如果队列为空
if (t == null) { // Must initialize
//则CAS设置一个空节点为头结点(空结点中没有包装线程),这也是延迟初始化头结点
if (compareAndSetHead(new Node()))
//将指针指向头结点
tail = head;
} else {//如果尾结点不为空,说明是CAS失败的情况
//将当前节点的前置指针指向t(原尾结点)
node.prev = t;
//CAS操作将当前节点设为尾结点,成功后tail将指向当前节点
if (compareAndSetTail(t, node)) {
//将t(原尾结点)的下一节点指针指向当前节点,完成双向设置
t.next = node;
//返回原尾结点,即队列中倒数第二个节点
return t;
}
}
}
}
final boolean acquireQueued(final Node node, int arg) {
//标记是否成功拿到资源
boolean failed = true;
try {
//标记等待过程中是否被中断过
boolean interrupted = false;
//自旋
for (;;) {
//获得当前节点的上一个节点
final Node p = node.predecessor();
//head节点表示获取了同步状态的线程,它不属于同步阻塞队列,如果它的前驱节点是head,说明排队马上排到自己了,可以尝试获取资源
if (p == head && tryAcquire(arg)) {
//将当前节点设置为头结点
setHead(node);
//将其next置空,以方便虚拟机回收掉该前继节点
p.next = null; // help GC
//标识获取资源成功
failed = false;
//返回等待过程中是否被中断过
return interrupted;
}
//若前继节点不是头结点,或者获取资源失败时
//shouldParkAfterFailedAcquire(p, node) 判断是否需要阻塞该节点持有的线程
if (shouldParkAfterFailedAcquire(p, node) &&
//parkAndCheckInterrupt()将该线程阻塞并检查是否可以被中断
parkAndCheckInterrupt())
//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
interrupted = true;
}
} finally {
// 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
if (failed)
cancelAcquire(node);
}
}
如上,假如当前node本来就不是队头或者就是 tryAcquire(arg) 没有抢赢别人,就是走到下一个分支判断:shouldParkAfterFailedAcquire(p, node) 当前线程没有抢到锁,是否需要挂起当前线程?
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)//Node.SIGNALd的意义就是让前驱拿完号后通知自己一下
/*
* 前驱节点已经设置了SIGNAL,闹钟已经设好,现在我可以安心睡觉(阻塞)了,不用自旋频繁地来打听消息
* 如果前驱变成了head,并且head的代表线程exclusiveOwnerThread释放了锁,
* 就会来根据这个SIGNAL来唤醒自己
*/
return true;
if (ws > 0) {
/*
* 发现传入的前驱的状态大于0,即CANCELLED。说明前驱节点已经因为超时或响应了中断,而取消了自己。
* 所以需要跨越掉这些CANCELLED节点,一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 进入此分支说明前驱结点的waitStatus只能是0或者PROPAGATE(-3),每个新node入队时,waitStatus都是0
* 用CAS将前驱节点的waitStatus设置为Node.SIGNAL(-1),以保证下次自旋时,shouldParkAfterFailedAcquire直接返回true
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
//由于shouldParkAfterFailedAcquire函数在acquireQueued的调用中处于一个死循环中
//shouldParkAfterFailedAcquire函数若返回false,那么此函数必将至少执行两次才能阻塞自己
return false;
}
这里我们分析下private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) 这个方法返回值的情况:
private final boolean parkAndCheckInterrupt() {
//挂起当前线程,线程阻塞在这里不再往下执行,线程进入waiting状态,直到被unpark唤醒
LockSupport.park(this);
//将中断状态消耗掉,并将这个中断状态暂时保存到一个局部变量中去
return Thread.interrupted();
}
“如果中断状态为true,那么park无法阻塞”。由于acquireQueued是阻塞式的抢锁,线程可能重复着 阻塞->被唤醒 的过程,所以在这个过程中,如果遇到了中断,一定要用Thread.interrupted()将中断状态消耗掉,并将这个中断状态暂时保存到一个局部变量中去。不然只要遇到中断一次后,线程在抢锁失败后却无法阻塞了。
在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个,如下:
由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态。可以看到节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程 由于中断而被唤醒)。 独占式同步状态获取流程,也就是acquire(int arg)方法调用流程,如图所示:
总结
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。
该方法执行时,会唤醒头节点的后继节点线程,unparkSuccessor(Node node)方法使用LockSupport 来唤醒处于等待状态的线程。
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况,如图所示:
左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被 阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。
通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态。
public final void acquireShared(int arg) {
/** tryAcquireShared()尝试获取锁
* 返回值是小于0的时候获取锁失败
* 返回的是0表示获取共享模式成功但是它下一个节点的共享模式无法获取成功
* 返回的是正数表示当前线程获取共享模式成功,并且它后面的线程也可以获取共享模式
*/
if (tryAcquireShared(arg) < 0)
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) {
//如果前继节点是head,则尝试获取锁
int r = tryAcquireShared(arg);
if (r >= 0) {
//设置当前节点为头节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
//如果在获取锁自旋的过程中中断过,那么那么将当前线程中断
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//如果当前节点的前驱节点不是头节点
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法返回值大于等于0。
可以看到,在doAcquireShared(int arg)方法的自 旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出。
与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。
参考:
《java并发编程的艺术》
并发编程 6:AQS很难? (qq.com)