Loading

16-AQS 应用之 Lock

1. 引入

在 Java 5.0 之前,在协调对共享对象的访问时可以使用的机制只有 synchronized 和 volatile。Java 5.0 增加了一种新的机制:ReentrantLock。与之前提到过的机制相反,ReentrantLock 并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。

如下给出的 Lock 接口中定义了一组抽象的加锁操作。与内置加锁机制不同的是,Lock 提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。在 Lock 的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit);
    void unlock();
}

ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。在获取 ReentrantLock 时,有着与进入同步代码块相同的内存语义,在释放 ReentrantLock 时,同样有着与退出同步代码块相同的内存语义。ReentrantLock 支持在 Lock 接口中定义的所有获取锁模式,并且与 synchronized 相比,它还为处理锁的不可用性问题提供了更高的灵活性。

为什么要创建一种与内置锁如此相似的新加锁机制?

在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限地等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用 synchronized 的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。

AQS 是实现 Lock 的基础,两者之间的主要区别在于:

  • Lock 面向锁的使用者,它聚焦的问题是使用者如何更好地使用锁处理并发问题,而使用者不需要知道锁的实现细节就可以实现互斥同步;
  • AQS 面向锁的开发者,它关注的两个主要问题是同步状态管理以及维护线程的同步等待队列

2. AQS 特性

Java 并发编程核心在于 java.concurrent.util 包,而 juc 当中的大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于 AbstractQueuedSynchronizer(简称 AQS),AQS 定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器。

  • 阻塞等待队列
  • 共享/独占
  • 公平/非公平
  • 可重入
  • 允许中断

(1)除了 Lock 外,Java.concurrent.util 当中同步器的实现如 Latch、Barrier、BlockingQueue 等, 都是基于 AQS 框架实现:

  • 一般通过定义内部类 Sync 继承 AQS;
  • 将同步器所有调用都映射到 Sync 对应的方法;

(2)AQS 内部维护属性 volatile int state

  • state 表示资源的可用状态;
  • state 的 3 种访问方式:getState()、setState()、compareAndSetState();

(3)AQS 定义两种资源共享方式

  • 【Exclusive-独占】只有一个线程能执行,如 ReentrantLock;
  • 【Share-共享】多个线程可以同时执行,如 Semaphore/CountDownLatch;

(4)AQS 定义两种队列

  • 同步等待队列
  • 条件等待队列

(5)不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively() 该线程是否正在独占资源。只有用到 Condition 才需要去实现它。
  • tryAcquire(int) 独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
  • tryRelease(int) 独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
  • tryAcquireShared(int) 共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int) 共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。

可以看到,AQS 利用模板方法主要做了两件事:① 尝试获取或设置同步状态;② 维护同步队列。而留给开发者的可重写方法更多聚焦于如何设置、获取同步状态,这种设计思想与双亲委派机制有点相似:loadClass() 中实现了双亲委派机制,而用户可以通过重写 findClass() 去实现自己加载类的具体逻辑。

(6)同步等待/同步阻塞队列

  • 【同步等待队列】AQS 当中的同步等待队列也称 CLH 队列,CLH 队列是 Craig、Landin、Hagersten 三人发明的一种基于双向链表数据结构的队列,是 FIFO 先入先出线程等待队列,Java 中的 CLH 队列是原 CLH 队列的一个变种,线程由原自旋机制改为阻塞机制。
  • 【条件等待队列】Condition 是一个多线程间协调通信的工具类,使得某个或者某些线程一起等待某个条件(Condition),只有当该条件具备时,这些等待线程才会被唤醒,从而重新争夺锁。

3. 基于 AQS 实现一个锁

摘自公众号:低并发编程

3.1 先写个框架

关于锁,我还算有一个模糊的认识的,要让使用者可以获取锁、释放锁,来实现多线程访问时的安全性。于是我赶紧先把一个框架写了出来。

public class FlashLock {
    // 释放锁
    public void lock() {}
    // 释放锁
    public void unlock() {}
}

工具类已经完成一半了。

public void doSomeThing() {
    // 获取锁,表示同一时间只允许一个线程执行这个方法
    flashLock.lock();
    try {
        ...
    } finally {
        // 优雅地在 finally 里释放锁
        flashLock.unlock();
    }
}

继续想,我怎么在这俩方法里实现这种锁的效果呢?脑子一片空白呀,诶不过刚刚说要基于 AQS。搜搜百度,了解到 AQS 的全称叫 AbstractQueuedSynchronizer(抽象的队列式同步器),是一个 JDK 源码中的一个类。

3.2 使用 AQS 实现最简单的锁

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state. Subclasses must define the protected methods that change this state, and which define what that state means in terms of this object being acquired or released.

第一句话说这是个框架,之后说这个类是基于一个原子变量,这说的都是原理我先不管。

后面又说子类(Subclasses)必须实现一些改变状态(change this state)和获取释放锁(acquired or released)的方法。

哦!看来我需要用一个子类继承它,然后实现它指定的一些方法,其他的事情这个父类都会帮我做好的。敏锐的我马上察觉到,这用的模板方法这种设计模式,这是我最喜欢的设计模式了,因为只需要读懂需要让子类实现的模板方法的含义,即可以很好地使用这个类的强大功能。

于是我赶紧去找,有哪些这样的模板方法,需要子类去实现,果然在注释中发现了这样一段话。

 * To use this class as the basis of a synchronizer, redefine the following methods:
 *
 * <li> {@link #tryAcquire}
 * <li> {@link #tryRelease}
 * <li> {@link #tryAcquireShared}
 * <li> {@link #tryReleaseShared}
 * <li> {@link #isHeldExclusively}

在源码中找到这几个类。

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

一看清一色都是抛出异常我就放心了,这正是留给我们子类实现的模板方法呀,接下来就是我写个类实现他们就好咯,可是怎么写...

正想去百度,突然发现注释中居然给出了一段基于 AQS 的实现小 demo,还挺长,我理解了它的意思,并且把我看不懂的都去掉了,写出了很简洁的锁。

public class FlashLock {

    // 获取锁
    public void lock() {
        sync.acquire(1);
    }
    // 释放锁
    public void unlock() {
        sync.release(1);
    }

    private final Sync sync = new Sync();

    // 这个内部类就是继承并实现了 AQS 但我这里只先实现两个方法
    private static class Sync extends AbstractQueuedSynchronizer {

        @Override
        public boolean tryAcquire(int acquires) {
            // CAS 方式尝试获取锁,成功返回true,失败返回false
            if (compareAndSetState(0, 1)) {
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int releases) {
            // 释放锁,这里为什么不像上面那样也是 CAS 操作呢?请读者思考
            setState(0);
            return true;
        }
    }
}

lock 和 unlock 方法都实现了,我赶紧写个经典的测试代码:

// 可能发生线程安全问题的共享变量
private static long count = 0;

// 两个线程并发对 count++
public static void main(String[] args) throws Exception {
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()-> add());
    Thread th2 = new Thread(()-> add());
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    // 这里应该是 20000 就对了,说明锁生效了
    System.out.println(count);
}

// 我画了一上午写出来的锁,哈哈
private static ExampleLock exampleLock = new ExampleLock();

// 循环 count++,进行 10000 次
private static void add() {
    exampleLock.lock();
    for (int i = 0; i < 10000; i++) {
        count++;
    }
    add2();
    // 没啥异常,我就直接释放锁了
    exampleLock.unlock();
}

测了好几次,发现都是 20000,哈哈,大功告成。

3.3 不得不研究下 AQS 的原理

【收到反馈】有个问题,就是我用你的这个锁工具,有的线程总是抢不到锁,有的线程总是能抢到锁。虽说线程们抢锁确实看命,但能不能加入一种设计,让各个线程机会均等些,起码不要出现某几个线程总是特倒霉抢不到锁的情况呢?

@Override
public boolean tryAcquire(int acquires) {
    // 一上来就 CAS 抢锁
    if (compareAndSetState(0, acquires)) {
        return true;
    }
    return false;
}

我发现这段代码中在尝试获取锁时,一上来就 CAS 抢锁,一旦成功就返回了 true。那我这里是否能加入某些机制,使这些线程不要一有机会就开始直接开始抢锁,而是先考虑一下其他线程的感受再决定是否抢锁呢?

我发现此时不得不研究一下 AQS 的内部实现逻辑了,也就是原理,看看能不能得到一些思路。

我看 AQS 虽然方法一大堆,但属性一共就四个(有一个是内部类 Node):

public abstract class AbstractQueuedSynchronizer {
    private transient volatile Node head;
    private transient volatile Node tail;
    private volatile int state;
    static final class Node {}
}

static final class Node {
    // ... 省略一些暂不关注的
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
}

结合最开始看那段对 AQS 高度概括的注释:

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state.

不难猜到这里的内部类 Node 以及其类型的变量 head 和 tail 就表示 AQS 内部的一个等待队列,而剩下的 state 变量就用来表示锁的状态。

等待队列应该就是线程获取锁失败时,需要临时存放的一个地方,用来等待被唤醒并尝试获取锁。再看 Node 的属性我们知道,Node 存放了当前线程的指针 thread,也即可以表示当前线程并对其进行某些操作,prev 和 next 说明它构成了一个双向链表,也就是为某些需要得到前驱或后继节点的算法提供便利。

太好了,仅仅看一些属性和一段注释,就得到了一个关于 AQS 大致原理的猜测,看起来还挺靠谱,我赶紧把它画成几张图来加深理解(由于这里非常重要,就不再卖关子了,直接画出最正确的图理解,但不会过于深入细节)。以下的图是 AQS 最为核心的几行代码的直观理解过程,请大家仔细品味。

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
        selfInterrupt();
    }
}

看完图没太消化的,这里还有一次机会,我来捋一捋。

首先初始状态 AQS 的 state=0,表示没有线程持有锁。head 和 tail 也都为空,表示此时等待队列里也没有线程。

这时第一个线程(线程1)来了,没有任何线程和它抢,直接拿到了锁(state=1);

然后线程 2 也来了,假设此时线程 1 没有释放锁,那么线程 2 抢锁失败(执行你自己写的 tryAcquire)。失败后,剩下的事都是 AQS 帮你做的,首先加入等待队列的队尾 addWaiter(此时队列为空,所以要先初始化一个占位的头结点),然后在循环里尝试获取锁 acquireQueue(注意这里面有相当多的细节,首先前驱结点是头结点的才能尝试获取锁,即排在队头的才有机会。再有循环里获取锁并不是一直 CAS,而是通过一个标志控制了次数,使得 CAS 两次都失败后就将线程挂起 park,之后等待持有锁的线程释放锁之后再唤醒 unpark。其余各种细节,希望读者阅读源码,不是一句两句说清楚的)。

然后线程 3 也来了,经历了和线程 2 一样的经历,只不过它的前驱结点不是头结点,因此还不能有机会尝试获取锁,只有等线程 2 抢到了锁并且出队,自己的前驱结点变成了头结点,才可以。

这时线程 1 终于释放了锁(state=0),与此同时找到队列的头结点进行唤醒 unpark。此时头结点是线程 2 表示的 Node,因此对线程 2 进行了唤醒操作。如果此时线程 2 没有被挂起,说明还在尝试获取锁的过程中,那么就尝试好了。如果已经被挂起了,那么唤醒线程 2,使得线程 2 继续不断尝试 CAS 获取锁,直到成功为止。

如此,循环往复... 你大概懂了么?

3.4 实现一个公平锁

仔细看上面的倒数第二张图。

原本在队列中等待的线程 2,被线程 1 释放锁之后唤醒了,但它仍然需要抢锁,而且有可能抢失败。

那如果每次这个线程 2 尝试抢锁时,都有其他新来的线程把锁抢去,那线程 2 就一直得不到运行机会,而且排在线程 2 后面的等待线程,也都没有机会运行。

导致有的线程一直得不到运行机会的,就是这个新进来的线程每次都不管有没有人排队,都直接上来就抢锁导致的。

妥了,刚刚反馈的那个问题,我终于有了思路,就是让新来的线程抢锁时,先问一句,“有没有人排队呀?如果有人排队那我先排到队尾好了”。

@Override
public boolean tryAcquire(int acquires) {
    // 原有基础上加上这个
    if (有线程在等待队列中) {
        // 返回获取锁失败,AQS 会帮我把该线程放在等待队列队尾的
        return false;
    }
    if (compareAndSetState(0, 1)) {
        return true;
    }
    return false;
}

怎么判断是否有线程在等待队列呢?机智的我觉得,AQS 这么优秀的框架一定为上层提供了一个方法,不会让我们深入到它实现的内部的,果然我找到了。

public final boolean hasQueuedPredecessors()

再经过优化结构后,最终的代码变成了这样:

@Override
public boolean tryAcquire(int acquires) {
    if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) {
        return true;
    }
    return false;
}

哈哈,大功告成,赶紧去显摆一下。

等等...

那我原来的那种实现方式就没了,肯定有其他人找我质问,emmm,我两种方式都暴露给大家吧,随大家选。

我将原来的暴力抢锁方式起了个名,叫非公平锁,因为线程抢锁不排队,纯看脸。按小宇需求实现的排队获取锁,我叫它公平锁,因为只要有线程在排队,新来的就得乖乖去排队,不能直接抢。

// 想要公平锁,就传 true 进来
public FlashLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

3.5 要求方法可以重入

又收到反馈,用你这工具导致一个线程卡死了呀,一直获取不到锁。

我打开了锁了屏的电脑,点开了发来的代码:

public void doSomeThing2() {
    flashLock.lock();
    doSomeThing2();
    flashLock.unlock();
}

public void doSomeThing2() {
    flashLock.lock();
    ...
    flashLock.unlock();
}

我恍然大悟,原来一个线程执行了一个方法,获取了锁,这个方法没有结束,又调用了另一个需要锁的方法,于是卡在这再也不走了。

这个原理很容易理解,但这似乎用起来确实不太友好,怪不得老板那么生气。有没有办法,让同一个线程持有锁时,还能继续获取锁(可重入),只有当不同线程才互斥呢?

我苦思冥想,感觉不对呀,现在 AQS 里面的所有变量我都用到了,没见哪个变量可以记录当前线程呀。

哦对!AQS 本身还继承了 AbstractOwnableSynchronizer 这个类!我很快在这个类里面发现了这个属性!

/**
 * The current owner of exclusive mode synchronization.
 */
private transient Thread exclusiveOwnerThread;

熟悉了之前的套路,我很快又找到了这两个方法!

protected final void setExclusiveOwnerThread(Thread thread);
protected final Thread getExclusiveOwnerThread();

大功告成,此时我只要在一个线程发现锁已经被占用时,不直接放弃,而是再看一下占用锁的线程是不是正是我自己,就好了。有了前面的经验,这次我直接写出了最终的可重入的公平锁代码。

@Override
public boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) {
            // 拿到锁记得记录下持锁线程是自己
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 看见锁被占了(state!=0)也别放弃,看看是不是自己占的
        setState(c + acquires);
        return true;
    }
    return false;
}

3.6 尾声

虽然这只是皮毛,但如果你是第一次接触这两个概念,那本篇文章的最大意义在于对他们有了一个三观很正的第一印象。我希望 AQS 的给你的第一印象不是什么抽象的队列式同步器,而只是一个为了更方便实现各种锁而提供的包含几个模板方法的类而已,虽然并不准确,而且显得很 low,但实则可能恰恰是说到了本质。

另外,我也推荐你,用跟踪源码或 debug 的方式,从头到尾自己跟一遍下面三行代码,是几乎 AQS 的全部核心逻辑,这个看懂了,其他的都是浮云。

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
        selfInterrupt();
    }
}

4. AQS 实现原理

4.1 概述

AQS 中维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。state 的访问方式有 3 种:getState()setState()compareAndSetState()

AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如 ReentrantLock)和 Share(共享,多个线程可同时执行,如 Semaphore/CountDownLatch)。不同的自定义同步器争用共享资源的方式也不同。开发自定义同步器时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。自定义同步器实现时主要实现的几种方法开头也已经提过。

以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是“可重入”的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS 减 1。等到所有子线程都执行完后(即 state=0),会 unpark() 主调用线程,然后主调用线程就会从 await() 返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现 tryAcquire~tryReleasetryAcquireShared~tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。

4.2 独占式同步状态的获取和释放

主要通过独占式同步状态的获取和释放、共享式同步状态的获取和释放来看下 AQS 是如何实现的。

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

上述代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:

  • 首先调用子类实现的 tryAcquire() 方法,该方法保证线程安全的获取同步状态;
  • 如果同步状态获取失败,则构造独占式同步节点 Node.EXCLUSIVE 并通过 addWaiter() 方法将“当前线程”加入到”CLH 队列(非阻塞的 FIFO 队列)”末尾;
  • 最后调用 acquireQueued() 使得该节点以自旋的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠〔前驱节点〕的出队或阻塞线程被中断来实现。
  • “当前线程”在执行 acquireQueued() 时,会进入到 CLH 队列中休眠等待,直到获取锁了才返回!如果“当前线程”在休眠等待过程中被中断过,acquireQueued() 会返回 true,此时“当前线程”会调用 selfInterrupt() 来自己给自己产生一个中断标记。至于为什么要自己给自己产生一个中断,后面再介绍。

a. tryAcquire(int)

此方法尝试去获取独占资源。如果获取成功,则直接返回 true,否则直接返回 false。这也正是 tryLock() 的语义,还是那句话,当然不仅仅只限于 tryLock()。如下是 tryAcquire() 的源码:

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

因为 AQS 只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现,所以 AQS 这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了。例如公平锁的 tryAcquire() 在 ReentrantLock#FairSync 类中实现。

这里之所以没有定义成 abstract,是因为独占模式下只用实现 tryAcquire-tryRelease,而共享模式下只用实现 tryAcquireShared-tryReleaseShared。如果都定义成 abstract,那么每个模式也要去实现另一模式下的接口,尽量减少不必要的工作量。

我们可以看看 ReentrantLock 在公平锁状态下是如何实现这个方法的,公平锁的 tryAcquire() 在 ReentrantLock#FairSync 类中实现,源码如下:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    // '锁'没有被任何线程拥有
    if (c == 0) {
        // 无排队线程(公平锁) && 状态设置成功
        // 非公平就是去掉 '!hasQueuedPredecessors()' 这个判断,其他全部一样
        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;
}

根据代码,我们可以分析出 tryAcquire() 的作用就是尝试去获取锁。注意,这里只是尝试!尝试成功的话,返回 true;尝试失败的话,返回 false,后续再通过其它办法来获取该锁。

b. addWaiter(Node.EXCLUSIVE)

在介绍此方法前我们需要对 Node 这个类进行介绍。

Node 就是 CLH 队列的节点。Node 在 AQS 中实现,它的数据结构如下:

private transient volatile Node head;    // CLH队列的队首
private transient volatile Node tail;    // CLH队列的队尾

// CLH 队列的节点
static final class Node {

    // 线程已被取消,对应的 waitStatus 的值
    static final int CANCELLED =  1;
    // “当前线程的后继线程需要被 unpark(唤醒)”所对应的waitStatus的值。一般发生情况是:
    // 当前线程的后继线程处于阻塞状态,而当前线程被release/cancel掉,因此需要唤醒当前线程的后继线程。
    static final int SIGNAL    = -1;
    // 线程(处在Condition休眠状态)在等待Condition唤醒,对应的waitStatus的值
    static final int CONDITION = -2;
    // (共享锁)其它线程获取到“共享锁”,对应的waitStatus的值
    static final int PROPAGATE = -3;

    // waitStatus为“CANCELLED, SIGNAL, CONDITION, PROPAGATE”时分别表示不同状态,
    // 若waitStatus=0,则意味着当前线程不属于上面的任何一种状态。
    volatile int waitStatus;

    // 前一节点
    volatile Node prev;

    // 后一节点
    volatile Node next;

    // 节点所对应的线程
    volatile Thread thread;

    static final Node SHARED = new Node();
    static final Node EXCLUSIVE = null;
    
    // nextWaiter是区别当前CLH队列是‘独占锁’队列还是‘共享锁’队列的标记
    // - nextWaiter=SHARED,则CLH队列是‘独占锁’队列
    // - nextWaiter=EXCLUSIVE,则CLH队列是‘共享锁’队列
    Node nextWaiter;

    // “共享锁”则返回true,“独占锁”则返回false。
    final boolean isShared() {
    	return nextWaiter == SHARED;
    }

    // 返回前一节点
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    Node() {}    // Used to establish initial head or SHARED marker
    

    // 构造函数。thread是节点所对应的线程,mode是用来表示thread的锁是“独占锁”还是“共享锁”。
    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    // 构造函数。thread是节点所对应的线程,waitStatus是线程的等待状态。
    Node(Thread thread, int waitStatus) { // Used by Condition
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

Node 是 CLH 队列的节点,代表“等待锁的线程队列”。

  1. 每个 Node 都会与一个线程对应;
  2. 每个 Node 会通过 prev 和 next 分别指向上一个节点和下一个节点,这分别代表上一个等待线程和下一个等待线程;
  3. Node 通过 waitStatus 保存线程的等待状态;
  4. Node 通过 nextWaiter 来区分线程是“独占锁”线程还是“共享锁”线程。如果是“独占锁”线程,则 nextWaiter 的值为 EXCLUSIVE;如果是“共享锁”线程,则 nextWaiter 的值是 SHARED。

下面来首先来看下节点构造加入同步队列是如何实现的。

addWaiter(Node.EXCLUSIVE) 的作用是,创建“当前线程”的 Node 节点,且 Node 中记录“当前线程”对应的锁是“独占锁”类型,并且将该节点添加到 CLH 队列的末尾。代码如下:

private Node addWaiter(Node mode) {
    // 当前线程构造成 Node 节点
    // mode 有两种:EXCLUSIVE(独占)和 SHARED(共享)
    Node node = new Node(Thread.currentThread(), mode);
    // 尝试快速在尾节点后新增节点,提升算法效率,先将尾节点指向pred
    Node pred = tail;
    if (pred != null) {
        // 尾节点不为空,当前线程节点的前驱节点指向尾节点
        node.prev = pred;
        // 并发处理 尾节点有可能已经不是之前的节点 所以需要 CAS 更新
        if (compareAndSetTail(pred, node)) {
            // CAS 更新成功 当前线程为尾节点 原先尾节点的后续节点就是当前节点
            pred.next = node;
            return node;
        }
    }
    // 第一个入队的节点 OR 上一步新增失败 -> 通过 enq 入队
    enq(node);
    return node;
}

private Node enq(final Node node) {
    // CAS自旋直到成功加入队尾
    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() 方法的作用,就是将当前线程添加到 CLH 队列中。这就意味着将当前线程添加到等待获取“锁”的等待线程队列中了。

c. acquireQueued(Node, int)

前面我们已经尝试获取锁失败。将当前线程添加到 CLH 队列中了。而 acquireQueued() 的作用就是逐步的去执行 CLH 队列的线程,如果当前线程获取到了锁,则返回;否则,当前线程进行休眠,直到唤醒并重新获取锁了才返回。下面,我们看看 acquireQueued() 的具体流程。

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);
                // setHead()中node.prev已置为null,此处再将head.next置为null
                // 就是为了方便GC回收以前的head结点,也就意味着之前拿完资源的结点出队了!
                p.next = null;
                failed = false;
                // 返回等待过程中是否被中断过
                return interrupted;
            }
            // 是否阻塞
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                // 如果等待过程中被中断过,哪怕只有那么一次,就将 interrupted 标记为 true
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

再来看看 shouldParkAfterFailedAcquire()parkAndCheckInterrupt() 是怎么来阻塞当前线程的,代码如下:

// 当前线程是否应该阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 前驱节点的状态决定后续节点的行为
    int ws = pred.waitStatus;
    // 1=> 如果前继节点是SIGNAL状态,则意味这当前线程需要被阻塞。此时返回true。
    if (ws == Node.SIGNAL)
        return true;
    // 2=> 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边
    if (ws > 0) {
        // 那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被GC回收
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /* 
         * 3=> 如果前驱正常(为“0”或者“共享锁”状态),则设置前继节点为SIGNAL状态,告诉它拿完号后通知自己一下。
         * waitStatus must be 0 or PROPAGATE. 
         * Indicate that we need a signal, but don't park yet.
         * Caller will need to retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

如果“规则1”发生,即“前继节点是 SIGNAL”状态,则意味着“当前线程”需要被阻塞。接下来会调用 parkAndCheckInterrupt() 阻塞当前线程,直到当前线程被唤醒才从 parkAndCheckInterrupt() 中返回。

private final boolean parkAndCheckInterrupt() {
    // --- 阻塞线程!!!
    LockSupport.park(this);
    // 返回线程的中断状态
    return Thread.interrupted();
}

park() 会让当前线程进入 waiting 状态。在此状态下,有两种途径可以唤醒该线程:

  1. unpark() 唤醒,“前继节点对应的线程”使用完锁之后,通过 unpark() 方式唤醒当前线程;
  2. interrupt() 中断唤醒。其它线程通过 interrupt() 中断当前线程。需要注意的是,Thread.interrupted() 会清除当前线程的中断标记位。

LockSupport 中的 park()/unpark() 的作用 和 Object 中的 wait()/notify() 作用类似,是阻塞/唤醒。它们的用法不同,park()/unpark() 是轻量级的,而 wait()/notify() 是必须先通过 synchronized 获取同步锁。

如果锁被占用,线程阻塞,如果调用阻塞线程的 interrupt() 方法,会取消获取锁吗?

答案是否定的。首先要知道 LockSupport.park() 会响应中断,但不会抛出 InterruptedException,并且 Thread.interrupted() 返回线程的中断状态时会清空中断状态,当前线程会因为自旋再次进入阻塞状态。

如果我们调用 lockInterruptibly(),这个方法必须是当前锁未发生中断的情况下才能获取锁,它的实现原理是,如果 shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt() 判断条件成立的话,不返回 Thread.interrupted(),而是直接抛出 InterruptedException,同时在 finally 语句中将 CLH 队列中代表当前线程的节点状态设置为 Node.CANCELLED。

waitStatus 结点状态

Node 结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。变量 waitStatus 则表示当前 Node 结点的等待状态,共有 5 种取值 CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。

取值 说明
0 新结点入队时的默认状态
CANCELLED(1) 表示当前结点已取消调度。当 timeout 或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
SIGNAL(-1) 表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为 SIGNAL。
CONDITION(-2) 表示结点等待在 Condition 上,当其他线程调用了 Condition#signal() 方法后,CONDITION 状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
PROPAGATE(-3) 共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。

注意,负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用 >0<0 来判断结点的状态是否正常。

d. selfInterrupt()

private static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

这必须结合 acquireQueued() 进行分析。如果在 acquireQueued() 中,当前线程被中断过,则执行 selfInterrupt(),否则不会执行。

acquireQueued() 中,即使是线程在阻塞状态被中断唤醒而获取到 CPU 执行权利,但是它还是需要重新在循环中判断,如果该线程的前面还有其它等待锁的线程,根据公平性原则,该线程依然无法获取到锁。它会再次阻塞,直到该线程被它的前面等待锁的线程锁唤醒;线程才会获取锁,然后“真正执行起来”!

也就是说,在该线程“成功获取锁并真正执行起来”之前,它的中断会被忽略并且中断标记会被清除! 因为在 parkAndCheckInterrupt() 中,我们线程的中断状态时调用了 Thread.interrupted()。该函数不同于 Thread#isInterrupted()isInterrupted() 仅仅返回中断状态,而 interrupted() 在返回当前中断状态之后,还会清除中断状态。 正因为之前的中断状态被清除了,所以这里需要调用 selfInterrupt() 重新产生一个中断,记录线程的中断状态。


好了,我们再重新捋一遍整个获取锁的过程。

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    	selfInterrupt();
}
  1. 调用自定义同步器的 tryAcquire() 尝试直接去获取资源,如果成功则直接返回;
  2. 没成功,则 addWaiter() 将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued() 使线程在等待队列中等待,有机会时(按公平性原则,轮到自己会被 unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回 true,否则返回 false;
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断 selfInterrupt(),将中断补上。

e. release(int)

以 ReentrantLock 为例,它释放锁的操作是 unlock(),当你看它底层具体实现时可以发现,其实调用的正是 AQS 的 release(int) 方法。

此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即 state=0),它会唤醒等待队列里的其他线程来获取资源。源码如下:

public final boolean release(int arg) {
    // 同步状态释放成功
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 直接释放头节点(唤醒等待队列里的下一个线程)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

独占式释放是非常简单而且明确的。有一点需要注意的是,它是根据 tryRelease() 的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计 tryRelease() 的时候要明确这一点。

f. tryRelease()

正常来说,tryRelease() 都会成功的,因为这是独占模式,该线程来释放资源,那么它肯定已经拿到独占资源了,直接减掉相应量的资源即可(state-=arg),也不需要考虑线程安全的问题。但要注意它的返回值,上面已经提到了,release() 是根据 tryRelease() 的返回值来判断该线程是否已经完成释放掉资源了!所以自义定同步器在实现时,如果已经彻底释放资源(state=0),要返回 true,否则返回 false。

同样,我们这里也看一下在公平锁策略下的 ReentrantLock 是如何实现这个方法的,tryRelease() 在 ReentrantLock#Sync 类中实现,源码如下:

protected final boolean tryRelease(int releases) {
    // c 是本次释放锁之后的状态
    int c = getState() - releases;
    // 如果“当前线程”不是“锁的持有者”,则抛出异常!
    if (Thread.currentThread() != getExclusiveOwnerThread())
    	throw new IllegalMonitorStateException();

    boolean free = false;
    
    // 如果“锁”已经被当前线程彻底释放即可重入数为0时,则设置“锁”的持有者为null,即锁是可获取状态。
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // 如果当前重入数不为0表示当前是重入锁的一次释放,所以只更新可重入数为c,不将独占锁线程清空。
    setState(c);
    return free;
}

g. unparkSuccessor()

release() 中“当前线程”释放锁成功的话,会唤醒当前线程的后继线程。根据 CLH 队列的 FIFO 规则,“当前线程”(即已经获取锁的线程)肯定是 head,如果 CLH 队列非空的话,则唤醒锁的下一个等待线程。

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;

    if (ws < 0) // 如果状态<0,则设置状态=0
        compareAndSetWaitStatus(node, ws, 0);

    /* 
     * 获取当前节点的“有效的后继节点”,无效的话,则通过for循环进行获取。
     * 这里的“有效”是指“后继节点对应的线程状态<=0”
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
    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);
}

这个函数并不复杂。一句话概括:unpark() 唤醒等待队列中最前边的那个未放弃线程,这里我们也用 s 来表示这个被唤醒的节点吧。这时候再和 acquireQueued() 联系起来:

s 被唤醒后,进入 if (p == head && tryAcquire(arg)) 的判断(即使 p!=head 也没关系,它会再进入 shouldParkAfterFailedAcquire() 寻找一个离头节点最近的一个等待位置。这里既然 s 已经是等待队列中最前边的那个未放弃线程了,那么通过 shouldParkAfterFailedAcquire() 的调整,s 也必然会跑到 head 的 next 结点,下一次循环判断 p==head 就成立了),然后 s 把自己设置成 head 标杆结点,表示自己已经获取到资源了,acquire() 也返回了。


“释放锁”的过程相对“获取锁”的过程比较简单。释放锁时主要进行的操作是更新当前线程对应的锁的状态。如果当前线程对锁已经彻底释放(即 state=0),则设置“锁”的持有线程为 null,设置当前线程的状态为空,然后唤醒后继线程。

【总结下独占式同步状态的获取和释放】在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用 tryRelease() 方法释放同步状态,然后唤醒头节点的后继节点。

4.3 共享式同步状态的获取和释放

a. acquireShared(int)

此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。源码如下:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

这里 tryAcquireShared() 依然需要自定义同步器去实现。但是 AQS 已经把其返回值的语义定义好了:负值代表获取失败;0 代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里 acquireShared() 的流程就是:

  1. tryAcquireShared() 尝试获取资源,成功则直接返回;
  2. 失败则通过 doAcquireShared() 进入等待队列,直到获取到资源为止才返回。

b. doAcquireShared(int)

此方法用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。源码如下:

private void doAcquireShared(int arg) {
    // 和独占式一样的入队操作
    fi nal Node node = addWaiter(Node.SHARED);
    // 是否成功标志
    boolean failed = true;
    try {
        // 等待过程中是否被中断过的标志
        boolean interrupted = false;
        // 自旋
        for (; ;) {
            // 前驱
            final Node p = node.predecessor();
            // 如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
            if (p == head) {
                // 尝试获取资源
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    // 将head指向自己,还有剩余资源可以再唤醒之后的线程
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    // 如果等待过程中被打断过,此时将中断补上。
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            // 判断状态,寻找安全点,进入waiting状态,等着被unpark()/interrupt()
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

重点分析一下获取锁后的操作 setHeadAndPropagate()

private void setHeadAndPropagate(Node node, int propagate) {
    // Record old head for check below
    Node h = head;
    // head 指向自己
    setHead(node);
    /*
     * 如果 (读锁(共享锁)获取成功 or 头部节点为空 or 头节点取消
     *         or 刚获取读锁的线程的下一个节点为空 or 节点的下个节点也在申请读锁)
     * => 则在CLH队列中传播下去唤醒线程。
     * 怎么理解这个“传播”呢?
     * 就是只要获取成功到读锁,那就要传播到下一个节点!
     * 如果一下个节点继续是读锁的申请,只要成功获取,就再下一个节点,直到队列尾部或为写锁的申请,停止传播
     */
    if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

acquireShared() 也就结束了,让我们再梳理一下它的流程:

  1. tryAcquireShared() 尝试获取资源,成功则直接返回;
  2. 失败则通过 doAcquireShared() 进入等待队列 park(),直到被 unpark()/interrupt() 并成功获取到资源才返回。整个等待过程也是忽略中断的。

其实跟 acquire() 的流程大同小异,只不过多了个自己拿到资源后,还会去唤醒后继队友的操作,这体现了共享锁的定义

c. releaseShared(int)

看了前面获取所得过程,现在来看一下释放锁的过程。在 AQS 中是通过 releaseShared() 来释放共享锁的。此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即 state=0),它会唤醒等待队列里的其他线程来获取资源。代码如下:

public final boolean releaseShared(int arg) {
    // 释放同步状态
    if (tryReleaseShared(arg)) {
        // 唤醒后续等待的节点
        doReleaseShared();
        return true;
    }
    return false;
}

释放掉资源后,唤醒后继。跟独占模式下的 release() 相似,但有一点稍微需要注意:独占模式下的 tryRelease() 在完全释放掉资源(state=0)后,才会返回 true 去唤醒其他线程,这主要是基于可重入的考量;而共享模式下的 releaseShared() 则没有这种要求,一是共享的实质 —— 多线程可并发执行;二是共享模式基本也不会重入吧(至少我还没见过),所以自定义同步器可以根据需要决定返回值。

d. doReleaseShared()

private void doReleaseShared() {
    // 自旋
    for (; ; ) {
        Node h = head;
        // 从队列的头部开始遍历每一个节点 
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            // 如果节点状态为 Node.SIGNAL,则将状态设置为0,设置成功,唤醒线程。
            // 如果一个节点的状态设置为 Node.SIGNAL,则说明它有后继节点,并且处于阻塞状态!
            if (ws == Node.SIGNAL) {
                // 为什么会设置不成功,可能该节点被取消;还有一种情况就是
                // 有多个线程在运行该代码段,这就是PROPAGATE的含义吧。
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                // 唤醒后续节点(和独占式是一样的)
         unparkSuccessor(h);
            // 如果状态为0,则设置为 Node.PROPAGATE(传播),那么该值会在什么时候变化呢?
            // 在判断该节点的下一个节点是否需要阻塞时,会判断如果状态不是Node.SIGNAL或取消状态,
            // 则会为了保险起见,将前置节点状态设置为Node.SIGNAL,然后再次判断,是否需要阻塞。
            } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        /**
         * 如果处理过一次 unparkSuccessor 方法后,头节点没有发生变化,就退出该方法,那head在什么时候会改变呢?
         * 当然在是抢占锁成功的时候,head节点代表获取锁的节点。一旦获取锁成功,则又会进入 setHeadAndPropagate(),
         * 当然又会触发 doReleaseShared(),传播特性应该就是表现在这里吧。再想一下,同一时间可以有多个线程占有锁,
         * 那在锁释放时,写锁的释放比较简单,就是头节点后面的第一个非取消节点,唤醒线程即可。
         * 为了在释放读锁的上下文环境中获取代表读锁的线程,将信息存入在 readHolds-ThreadLocal 变量中。
         */
        if (h == head)                   // loop if head changed
            break;
    }
}

4.4 AQS 应用

AQS 被大量的应用在了同步工具上。

类名 说明
ReentrantLock ReentrantLock 类使用 AQS 同步状态来保存锁重复持有的次数。当锁被一个线程获取时,ReentrantLock 也会记录下当前获得锁的线程标识,以便检查是否是重复获取,以及当错误的线程试图进行解锁操作时检测是否存在非法状态异常。ReentrantLock 也使用了 AQS 提供的 ConditionObject,还向外暴露了其它监控和监测相关的方法。
ReentrantReadWriteLock ReentrantReadWriteLock 类使用 AQS 同步状态中的 16 位来保存写锁持有的次数,剩下的 16 位用来保存读锁的持有次数。WriteLock 的构建方式同 ReentrantLock。ReadLock 则通过使用 acquireShared 方法来支持同时允许多个读线程。
Semaphore Semaphore 类(信号量)使用 AQS 同步状态来保存信号量的当前计数。它里面定义的 acquireShared 方法会减少计数,或当计数为非正值时阻塞线程;tryRelease 方法会增加计数,在计数为正值时还要解除线程的阻塞。
CountDownLatch CountDownLatch 类使用 AQS 同步状态来表示计数。当该计数为 0 时,所有的 acquire 操作(对应到CountDownLatch 中就是 await 方法)才能通过。
FutureTask FutureTask 类使用 AQS 同步状态来表示某个异步计算任务的运行状态(初始化、运行中、被取消和完成)。设置 FutureTask#set() 或取消 FutureTask#cancel() 一个 FutureTask 时会调用 AQS 的 release 操作,等待计算结果的线程的阻塞解除是通过 AQS 的 acquire 操作实现的。
SynchronousQueues SynchronousQueues 类使用了内部的等待节点,这些节点可以用于协调生产者和消费者。同时,它使用 AQS 同步状态来控制当某个消费者消费当前一项时,允许一个生产者继续生产,反之亦然。

除了这些 j.u.c 提供的工具,还可以基于 AQS 自定义符合自己需求的同步器。

5. ReentrantReadWriteLock

无锁 → 独占锁 → 读写锁 → 邮戳锁

读写锁定义为一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。

读写锁 ReentrantReadWriteLock 并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是“读/读”线程间并不存在互斥关系,只有“读/写”线程或“写/写”线程间的操作需要互斥的,因此引入 ReentrantReadWriteLock。

一个 ReentrantReadWriteLock 同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁。也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。

只有在读多写少情境之下,读写锁才具有较高的性能体现。

因此,分析读写锁 ReentrantReadWriteLock 会发现它有个潜在的问题:读锁全完,写锁有望;写锁独占,读写全堵。如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即 ReadWriteLock 读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁,也就是写入必须等待,这是一种悲观的读锁。

锁的严苛程度变强叫做「升级」,反之叫做「降级」。这里说的锁降级指的是将写入锁降级为读锁(类比 Linux 文件读写权限,写权限要高于读权限一样)。

遵循 获取写锁 →再获取读锁→再释放写锁 的次序,写锁能够降级成为读锁。如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁(从读锁定升级到写锁是不可能的!)。

写锁和读锁是互斥的(这里的互斥是指线程间的互斥,当前线程可以获取到写锁又获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性。

因为,如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。

锁降级是为了让当前线程感知到数据的变化,目的是保证数据可见性。

比如,你加写锁修改了一个数据,然后在写锁释放之前,又加了个读锁,这时候相当于当前线程持有的是读锁而非写锁了(锁降级),此时其他线程就可以加读锁去读你最新修改的数据(读读不互斥),同时其他线程也不能来改你这数据(读写互斥), 保证了写后立即可读!

  1. 代码中声明了一个 volatile 类型的 cacheValid 变量,保证其可见性;
  2. 首先获取读锁,如果 cache 不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次 cacheValid 的值,然后修改数据,将 cacheValid 置为 true,然后在释放写锁前获取读锁;此时,cache 中数据可用,处理 cache 中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
  3. 如果违背锁降级的步骤:如果当前的线程 C 在修改完 cache 中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程 D 获取了写锁并修改了数据,那么 C 线程无法感知到数据已被修改,则数据出现错误;
  4. 如果遵循锁降级的步骤:线程 C 在释放写锁之前获取读锁,那么线程 D 在获取写锁时将被阻塞,直到线程 C 完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。

6. StampedLock

它是由“锁饥饿”问题引出的:

ReentrantReadWriteLock 实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,假如当前 1000 个线程,999 个读,1 个写,有可能 999 个读取线程长时间抢到了锁,那 1 个写线程就悲剧了。因为当前有可能会一直存在读锁,而无法获得写锁,根本没机会写。

如何缓解“锁饥饿”问题?

  • 使用“公平”策略可以一定程度上缓解这个问题,但“公平”策略是以牺牲系统吞吐量为代价的。
  • StampedLock 类的“乐观读锁”

锁的优化过程:synchronized → ReentrantReadWriteLock → StampedLock

ReentrantReadWriteLock 允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的 synchronized 速度要快很多,原因就是在于 ReentrantReadWriteLock 支持读并发。

ReentrantReadWriteLock 的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。但是,StampedLock 采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化。所以,在获取乐观读锁后,还需要对结果进行校验。

特点

  • 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp 为 0 表示获取失败,其余都表示成功;
  • 所有释放锁的方法,都需要一个邮戳(Stamp),这个 Stamp 必须是和成功获取锁时得到的 Stamp 一致;
  • StampedLock 是不可重入的,危险(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)。
  • StampedLock 有 3 种访问模式
    • Reading(读模式):功能和 ReentrantReadWriteLock 的读锁类似;
    • Writing(写模式):功能和 ReentrantReadWriteLock 的写锁类似;
    • Optimistic Reading(乐观读模式):无锁机制,类似于数据库中的乐观锁, 支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式;

读的过程中也允许获取写锁介入:

public class StampedLockDemo {
  static int number = 37;
  static StampedLock stampedLock = new StampedLock();

  public void write() {
    long stamp = stampedLock.writeLock();
    System.out.println(Thread.currentThread().getName() + "\t" + "===== 写线程准备修改");
    try {
      number = number + 13;
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      stampedLock.unlockWrite(stamp);
    }
    System.out.println(Thread.currentThread().getName() + "\t" + "===== 写线程结束修改");
  }

  /**
   * 悲观读
   */
  public void read() {
    long stamp = stampedLock.readLock();
    System.out.println(Thread.currentThread().getName() + "\t come in readLock block,4 seconds continue...");
    // 暂停几秒钟线程
    for (int i = 0; i < 4; i++) {
      try {
        TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + "\t 正在读取中......");
    }
    try {
      int result = number;
      System.out.println(Thread.currentThread().getName() + "\t" + " 获得成员变量值result:" + result);
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      stampedLock.unlockRead(stamp);
    }
  }

  /**
   * 乐观读
   */
  public void tryOptimisticRead() {
    long stamp = stampedLock.tryOptimisticRead();
    int result = number;
    // 间隔4秒钟,我们很乐观的认为没有其他线程修改过number值,实际靠判断。
    System.out.println("4s前stampedLock.validate值(true无修改,false有修改)" + "\t" + stampedLock.validate(stamp));
    for (int i = 1; i <= 4; i++) {
      try {
        TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + "\t 正在读取中......" + i +
          "s后stampedLock.validate值(true无修改,false有修改)" + "\t" + stampedLock.validate(stamp));
    }
    if (!stampedLock.validate(stamp)) {
      System.out.println("有人动过!!!存在写操作!!!");
      stamp = stampedLock.readLock();
      try {
        System.out.println("============= 从乐观读升级为悲观读 =============");
        result = number;
        System.out.println("重新悲观读锁通过获取到的成员变量值result:" + result);
      } catch (Exception e) {
        e.printStackTrace();
      } finally {
        stampedLock.unlockRead(stamp);
      }
    }
    System.out.println(Thread.currentThread().getName() + "\t finally value: " + result);
  }

  public static void main(String[] args) {
    StampedLockDemo resource = new StampedLockDemo();

    new Thread(() -> {
      resource.tryOptimisticRead();
    }, "readThread").start();

    // 2秒钟时乐观读失败,6秒钟乐观读取成功resource.tryOptimisticRead();,修改切换演示
    try { TimeUnit.SECONDS.sleep(6); } catch (InterruptedException e) { e.printStackTrace(); }

    new Thread(() -> {
      resource.write();
    }, "writeThread").start();
  }

}

缺点

  • StampedLock 不支持重入(没有 Re 开头);
  • StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意!
  • 使用 StampedLock 一定不要调用中断操作,即不要调用 interrupt() 方法。如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
posted @ 2023-02-16 10:05  tree6x7  阅读(26)  评论(0编辑  收藏  举报