锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。
一、Lock接口
在Lock接 口出现之前,Java程序是靠synchronized
关键字实现锁功能的,而Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized
关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。
Lock接口提供的synchronized
关键字所不具备的主要特性如下表所示:
特性 | 描述 |
---|---|
尝试非阻塞地获取锁 | 当前线程尝试获取锁, 如果这一时刻锁没有被其它线程获取到,则成功获取并持有锁 |
能被中断地获取锁 | 与synchronized 不同,获取到锁的线程能够响应中断,当获取到的锁的线程被中断时,中断异常将会被抛出,同时锁也会释放。 |
尝超时获取锁 | 在指定的截止时间之前获取锁,如果截止时间到了仍旧没有获取锁则释放锁。 |
//Lock的使用方式
Lock lock = new ReentrantLock();
lock.lock();
try {
//do something
} finally {
lock.unlock();
}
注意:不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常, 异常抛出的同时,也会导致锁无故释放。
Lock接口中定义了锁获取和释放的基本操作:
public interface Lock {
/**
* Acquires the lock.
*
* If the lock is not available then the current thread becomes
* disabled for thread scheduling purposes and lies dormant until the
* lock has been acquired.
*/
void lock();
//可中断地获取锁,和lock()方法不同的是,该方法可以响应中断,即在锁的获取过程中可以中断该线程
void lockInterruptibly() throws InterruptedException;
//尝试非阻塞获取锁,调用该方法后立即返回,如果成功返回true,如果失败返回false
boolean tryLock();
/**
*超时获取锁,有以下三种情况中的一种会返回:
*(1)当前线程在超时时间内获取了锁
*(1)当前线程在超时时间内被中断
*(1)超时时间结束返回false
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* Releases the lock.
*/
void unlock();
/**
* Returns a new {@link Condition} instance that is bound to this
* {@code Lock} instance.
*/
Condition newCondition();
}
二、队列同步器AQS
AQS (Abstract Queued Synchronizer),即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),它是JUC并发包中的核心基础组件。
AQS是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法:
getState()
, setState(int newState)
, compareAndSetState(int expect,int update)
来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、 ReentrantReadWriteLock和CountDownLatch)
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:
- 锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;
- 同步器面向的是锁的实现者, 它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。
锁和同步器很好地隔离了使用者和实现者所需关注的领域。
1. 队列同步器的接口与示例
同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。
重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态。
getState()
:获取当前同步状态。setState(int newState)
:设置当前同步状态。compareAndSetState(int expect,int update)
:使用CAS设置当前状态,该方法能够保证状态 设置的原子性。
同步器可重写的方法与描述如下表所示:
方法名称 | 描述 |
---|---|
protected boolean tryAcquire(int arg) | 独占式获取同步状态,实现该方法需要 查询当前状态并判断同步状态是否符合预期, 然后再进行CAS设置同步状态 |
protected boolean tryRelease(int arg) | 独占式获释放同步状态, 等待获取同步状态的线程将有机会获取同步状态 |
protected boolean tryAcquireShared(int arg) | 共享式获取同步状态, 返回大于等于0的值,表示获取成功, 反之获取失败。 |
protected boolean tryReleaseShared(int arg) | 共享式释放同步状态 |
protected boolean isHeldExclusively() | 当前同步器是否在独占模式下被线程占用, 一般该方法表示是否被当前线程所独占 |
实现自定义同步组件时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述如下表所示:
方法名称 | 描述 |
---|---|
void Acquire(int arg) | 独占式获取同步状态, 如果当前线程获取同步状态成功, 则由该方法返回, 否则,将会进入同步队列等待, 该方法将会调用重写的tryAcquire(int arg)方法 |
void acquireInterruptibly(int arg) | 与acquire(int arg)相同,但是该方法响应中断, 当前线程未获取到同步状态而进入同步队列中, 如果当前线程被中断, 则该方法会抛出 InterruptedException并返回 |
boolean tryAcquireNanos(int arg, long nanos) | 在acquireInterruptibly(int arg)基础上增加了超时限制, 如果当前线程在超时时间内没有获取到同步状态, 那么将会返回false,如果获取到了返回true |
void acquireShared(int arg) | 共享式的获取同步状态, 如果当前线程未获取到同步状态, 将会进入同步队列等待, 与独占式获取的主要区别是在同一时刻 可以有多个线程获取到同步状态 |
void acquireSharedInterruptibly(int arg) | 与acquireshared(int arg)相同,该方法响应中断 |
boolean tryAcquireSharedNanos(int arg, long nanos) | 在acquireSharedInterruptibly(int arg) 基础上增加了超时限制 |
boolean release(int arg) | 独占式的释放同步状态, 该方法会在释放同步状态之后, 将同步队列中第个节点包含的线程唤醒 |
boolean releaseShared(int arg) | 共享式的释放同步状态 |
Collection<Thread> getQueuedThreads() | 获取等待在同步队列上的线程集合 |
只有掌握了同步器的工作原理才能更加深入地理解并发包中其他的并发组件,所以下面 通过一个独占锁
的示例来深入了解一下同步器的工作原理。
顾名思义,独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能 处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁,代码如下:
class Mutex implements Lock {
// 静态内部类,自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
// 是否处于占用状态
protected boolean isHeldExclusively() {
return getState() == 1;
}
// 当状态为0的时候获取锁
public boolean tryAcquire(int acquires) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// 释放锁,将状态设置为0
protected boolean tryRelease(int releases) {
if (getState() == 0)
throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
// 返回一个Condition,每个condition都包含了一个condition队列
Condition newCondition() { return new ConditionObject(); }
}
// 仅需要将操作代理到Sync上即可
private final Sync sync = new Sync();
public void lock() { sync.acquire(1); }
public boolean tryLock() { return sync.tryAcquire(1); }
public void unlock() { sync.release(1); }
public Condition newCondition() { return sync.newCondition(); }
public boolean isLocked() { return sync.isHeldExclusively(); }
public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); }
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
}
上述示例中,独占锁Mutex是一个自定义同步组件,它在同一时刻只允许一个线程占有锁。Mutex中定义了一个静态内部类,该内部类继承了同步器并实现了独占式获取和释放同步状态。
- 在tryAcquire(int acquires)方法中,如果经过CAS设置成功(同步状态设置为1),则代表获取了同步状态
- 而在tryRelease(int releases)方法中只是将同步状态重置为0。
用户使用Mutex时并不会直接和内部同步器的实现打交道,而是调用Mutex提供的方法,在Mutex的实现中,以获取锁的lock()方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args)即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现 一个可靠自定义同步组件的门槛。
2. 队列同步器的实现分析
接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。
2.1 同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下表所示:
属性类型与名称 | 描述 |
---|---|
int waitStatus | 等待状态。包含如下状态: 1. CANCELLED , 值为1,由于在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,节点进入该状态将不会变化2. SIGNAL , 值为-1,后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行3. CONDITION , 值为-2,节点在等待队列中,节点线程等待在Condition上,当其他线程对 Condition调用了signal)方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中4. PROPAGATE , 值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去5. INITAL , 值为0,初始状态 |
Node prev | 前驱节点,当节点加入同步队列时被设置(尾部添加) |
Node next | 后继节点 |
Node nextWaiter | 等待队列中的后继节点。如果当前节点是共享的, 那么这个字段将是一个SHARED常量, 也就是说节点类型(独占和共享) 和等待队列中的后继节点共用一个字段 |
Thread thread | 获取同步状态的线程 |
节点是构成同步队列的基础,同步器拥有首节点(head) 和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如下图所示。
如上图所示,同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。 试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此
同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
2.2 独占式同步状态获取与释放
通过调用同步器的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)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
下面分析一下相关工作。首先是节点的构造以及加入同步队列, 源码如下:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 快速尝试在尾部添加
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
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; //通过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; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
在acquireQueued(final Node node,int arg)
方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个:
- 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会 唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
- 维护同步队列的FIFO原则。该方法中,节点自旋获取同步状态的行为如图所示:
如上,由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态,可以看到节点和节点之间在循环检查 的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释 放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程 由于中断而被唤醒)。
独占式同步状态获取流程,也就是acquire(int arg)
方法调用流程如下图所示:
上图中所示,前驱节点为头节点且能够获取同步状态的判断条件和线程进入等待状态是获取同步状态的自旋过程。当同步状态获取成功之后,当前线程从acquire(int arg)
方法返回,如果对于锁这种并发组件而言,代表着当前线程获取了锁。
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能 够继续获取同步状态。通过调用同步器的release(int arg)
方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。该方法代码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
该方法执行时,会唤醒头节点的后继节点线程,unparkSuccessor(Node node)
方法使用 LockSupport来唤醒处于等待状态的线程。
分析了独占式同步状态获取和释放过程后,适当做个总结:
- 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;
- 移出队列 (或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。
- 在释放同步状态时,同步器调用
tryRelease(int arg)
方法释放同步状态,然后唤醒头节点的后继节点。
2.3 共享式同步状态获取与释放
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状 态。以文件的读写为例:
- 如果一个程序在对文件进行读操作,那么这一时刻任何对于该文件的写操作均被阻塞,而读操作能够同时进行。
- 写操作要求对资源的独占式访问,如果一个程序在对文件进行写操作,那么这一时刻任何对文件的读写操作都被阻塞。
同步器的acquireShared
和doAcquireShared
方法如下:
public final void acquireShared(int arg) {
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) {//只有当前节点的前驱为头节点时,才尝试获取同步状态
int r = tryAcquireShared(arg);
if (r >= 0) {// 大于等于0,表示该次获取同步状态成功,准备从自旋过程中退出。
setHeadAndPropagate(node, r);
p.next = null;
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。
与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以 释放同步状态,该方法代码如下所示:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线 程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg) 方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为 释放同步状态的操作会同时来自多个线程。
2.4 独占式超时获取同步状态
通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)
方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该 方法提供了传统Java同步操作(比如synchronized关键字)所不具备的特性。
在Java 5之前,当一个线程获取不到锁而被阻塞在
synchronized
之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在synchronized
上,等待着获取锁。在Java 5中,同步器提供了acquireInterruptibly(int arg)
方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。
doAcquireNanos(int arg,long nanosTimeout)
方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知, nanosTimeout计算公式为:nanosTimeout-=now-lastTime,其中now为当前唤醒时间,lastTime为上 次唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒, 反之,表示已经超时,该方法源码如下:
private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException {
long lastTime = System.nanoTime();
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;
}
if (nanosTimeout <= 0) return false;
if (shouldParkAfterFailedAcquire(p, node)
&& nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
/**
*计算时间, 当前时间now减去睡眠之前的时间lastTime得到已
*经睡眠的时间delta, 然后被原有超时时间nanosTimeout减去,得到了还应该睡眠的时间
*/
long now = System.nanoTime();
nanosTimeout -= now - lastTime;
lastTime = now;
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的超时从整体上表现得反而不精确。因此,在超 时非常短的场景下,同步器会进入无条件的快速自旋。独占式超时获取同步态的流程如下图所示:
从上图可以看出,独占式超时获取同步状态doAcquireNanos(int arg,long nanosTimeout)
和独占式获取同步状态acquire(int args)
在流程上非常相似,其主要区别在于未获取到同步状 态时的处理逻辑。acquire(int args)
在未获取到同步状态时,将会使当前线程一直处于等待状 态,而doAcquireNanos(int arg,long nanosTimeout)
会使当前线程等待nanosTimeout纳秒,如果当 前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。
参考《java并发编程的艺术》-方腾飞