并发编程艺术笔记:Java中的锁(Lock、AQS)

目录

线程状态变化

1、Lock接口

2、队列同步器

1.同步队列

核心结构

2.独占式同步状态获取与释放

3.共享式同步状态获取与释放

4.独占式获取响应中断

5.独占式超时获取同步状态


线程状态变化

1、Lock接口

Lock接口提供的synchronized关键字不具备的主要特性

Lock接口的API

2、队列同步器

1.同步队列

AQS提供几个可重写的方法供实现类自己定制功能:

  • boolean tryAcquire(int arg):独占式的获取同步状态,通常通过以CAS的方式修改state的值来实现特定功能。
  • boolean tryRelease(int arg):独占式的释放同步状态,通常也是修改state的值。
  • int tryAcquireShared(int arg):共享式的获取同步状态,返回值>=0表示成功,否则失败。
  • boolean tryReleaseShared(int arg):共享式的释放同步状态,同样通过修改state值来实现。
  • boolean isHeldExclusively():表示AQS是否被当前线程独占。

同步队列是一种CLH锁变种的锁定队列。

  • 它是CLH锁的一个变种(variant),通常用于自旋锁,用以代替阻塞同步器
  • 在节点中的 status 字段来跟踪线程是否处于阻塞状态,但是 status 字段不控制线程是否被授予锁等
  • 每个节点在其前面一个节点释放同步状态后,前驱节点会通知该节点去获取同步状态,即发出信号通知该节点
  • 队列中的每个节点中的线程都有机会尝试获取同步状态,但是并不能保证一定获取成功
  • 是一个双向链表队列
  • 新节点会被加入到队列的尾部,并保证原子性操作
  • 节点弹出队列时只需要将头节点head移除即可,但需要耗费一定的操作,以确定继任者是谁,部分是为了处理因超时或中断而可能取消的情况
  • 队列中的每个节点都有一个唯一的线程,用该线程来获取同步状态
  • 使用“next”链接来实现阻塞机制。 每个节点的线程ID保存在自己的节点中,因此前驱者通过遍历下一个链接来通知下一个节点以确定它是哪个线程。 后继者的确定必须避免使用新排队节点的比赛来设置其前任的“next”字段。 必要时,当节点的后继者看起来为空时,通过从原子更新的“tail”向后检查来解决这个问题。 (或者,换句话说,next链接是一个优化,因此通常不需要向后扫描。)

核心结构

/**
 * Head of the wait queue, lazily initialized.  Except for
 * initialization, it is modified only via method setHead.  Note:
 * If head exists, its waitStatus is guaranteed not to be
 * CANCELLED.
 */
private transient volatile Node head;

/**
 * Tail of the wait queue, lazily initialized.  Modified only via
 * method enq to add new wait node.
 */
private transient volatile Node tail;

/**
 * The synchronization state.
 */
private volatile int state;


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上,当其他线程调用对Conditino调用了signal()方法后,该节点将从等待队列中转移到同步队列中,加入到对同步状态的获取中 */
    static final int CONDITION = -2;

    /** 表示下一次共享式同步状态获取将会无条件地被传播下去 */
    static final int PROPAGATE = -3;

    /* 当前节点的等待状态,取值为上述几个常量之一,另外,INITIAL值为0表示初始状态 */
    volatile int waitStatus;

    /* 前驱节点,当节点加入同步队列时被设置(尾部添加)*/
    volatile Node prev;

    /* 后继节点 */
    volatile Node next;

    /* 获取同步状态的线程 */
    volatile Thread thread;

    /* 等待队列中的后继节点。如果当前节点是共享的,则这个字段将是一个SHARED常量,也就是说节点类型(独占或共享)和等待队列中的后继节点共用同一字段 */
    Node nextWaiter;
    
    // ...
}

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

  • 确定只唤醒了首节点,的确这个获取方式相对比较公平,虽然新节点也可能优先获取到锁
  • 首节点不一定能获取到,因为可能有新线程正好进入,然后获取了锁

同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证。

2.独占式同步状态获取与释放

独占式的意思就是说同一时间只能有一个线程获得同步状态。

通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。

首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node) 方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)

shouldParkAfterFailedAcquire()方法的主要用途是,当线程在获取同步状态失败时,线程并不是立马进行阻塞,而是根据前驱节点的等待状态,决定后续的动作。比如前驱节点状态为SINGAL,表名当前节点线程应该被阻塞住了,不能老是尝试,避免CPU忙等。

SINGAL->阻塞。CANCELLED->向前遍历,移除前面所有为该状态的节点。waitStatus<0,将前驱节点状态设为SINGAL,并在此尝试获取同步状态。

如果在获取同步状态中出现异常,failed=true,cancelAcquire方法会被执行:

  • 第一种异常处理情况就是将前驱节点与后继节点连接在一起
  • 第二种就是由出现异常情况的节点唤醒其后继节点(即头结点是异常节点的前驱节点情形下)
/**
 * 取消获取同步状态
 */
private void cancelAcquire(Node node) {
    if (node == null)
        return;

    node.thread = null;

    // 前驱节点等待状态为 CANCELLED,则向前遍历并移除其他为该状态的节点
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

    // 记录 pred 的后继节点,后面会用到
    Node predNext = pred.next;

    // 将当前节点等待状态设为 CANCELLED
    node.waitStatus = Node.CANCELLED;

    /*
     * 如果当前节点是尾节点,则通过 CAS 设置前驱节点 pred 为尾节点。设置成功后,再利用 CAS 将 
     * pred 的 next 引用置空,断开与后继节点的联系,完成清理工作。
     */ 
    if (node == tail && compareAndSetTail(node, pred)) {
        /* 
         * 执行到这里,表明 pred 节点被成功设为了尾节点,这里通过 CAS 将 pred 节点的后继节点
         * 设为 null。注意这里的 CAS 即使失败了,也没关系。失败了,表明 pred 的后继节点更新
         * 了。pred 此时已经是尾节点了,若后继节点被更新,则是有新节点入队了。这种情况下,CAS 
         * 会失败,但失败不会影响同步队列的结构。
         */
        compareAndSetNext(pred, predNext, null);
    } else {
        int ws;
        // 根据条件判断是唤醒后继节点,还是将前驱节点和后继节点连接到一起
        if (pred != head &&
            ((ws = pred.waitStatus) == Node.SIGNAL ||
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            pred.thread != null) {
            
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                /*
                 * 这里使用 CAS 设置 pred 的 next,表明多个线程同时在取消,这里存在竞争。
                 * 不过此处没针对 compareAndSetNext 方法失败后做一些处理,表明即使失败了也
                 * 没关系。实际上,多个线程同时设置 pred 的 next 引用时,只要有一个能设置成
                 * 功即可。
                 */
                compareAndSetNext(pred, predNext, next);
        } else {
            /*
             * 唤醒后继节点对应的线程。考虑下面一种情况:
             *        head          node1         node2         tail
             *        ws=0          ws=1          ws=-1         ws=0
             *      +------+  prev +-----+  prev +-----+  prev +-----+
             *      |      | <---- |     | <---- |     | <---- |     |  
             *      |      | ----> |     | ----> |     | ----> |     |
             *      +------+  next +-----+  next +-----+  next +-----+
             *      
             * 头结点初始状态为 0,node1、node2 和 tail 节点依次入队。node1 自旋过程中调用 
             * tryAcquire 出现异常,进入 cancelAcquire。head 节点此时等待状态仍然是 0,它
             * 会认为后继节点还在运行中,所它在释放同步状态后,不会去唤醒后继等待状态为非取消的
             * 节点 node2。如果 node1 再不唤醒 node2 的线程,该线程面临无法被唤醒的情况。此
             * 时,整个同步队列就回全部阻塞住。
             */
            unparkSuccessor(node);
        }

        node.next = node; // help GC
    }

其中唤醒后继节点方法为unparkSuccessor(),代码如下:

由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态。可以看到节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)。

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程如下:

前驱节点为头节点且能够获取同步状态的判断条件和线程进入等待状态是获取同步状态的自旋过程。当同步状态获取成功之后,当前线程从acquire(int arg)方法返回,如果对于锁这种并发组件而言,代表着当前线程获取了锁。

当前线程获取同步状态并执行了相关逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

  • head==null 情况下:head还未初始化,如果还没节点入队就调用release释放同步状态,就会出现head == null 的qi
  • head!=null  &&  h.waitStatus == 0 情况下:后继节点对应的线程正在运行中,不需要唤醒
  • head!=null  &&  h.waitStatus < 0 情况下:后继节点对应的线程可能被阻塞了,需要唤醒

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

3.共享式同步状态获取与释放

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况。

通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态。

/**
 * 这个方法做了两件事情:
 * 1. 设置自身为头结点
 * 2. 根据条件判断是否要唤醒后继节点
 */ 
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    // 设置头结点
    setHead(node);
    
    /*
     * 这个条件分支由 propagate > 0 和 h.waitStatus < 0 两部分组成。
     * h.waitStatus < 0 时,waitStatus = SIGNAL 或 PROPAGATE。
     */
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        /*
         * 节点 s 如果是共享类型节点,则应该唤醒该节点
         */ 
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态。

对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg) 方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。

问题一:PROPAGATE 状态用在哪里,以及怎样向后传播唤醒动作的?

答:PROPAGATE 状态用在 setHeadAndPropagate。当头节点状态被设为 PROPAGATE 后,后继节点成为新的头结点后。若 propagate > 0 条件不成立,则根据条件h.waitStatus < 0成立与否,来决定是否唤醒后继节点,即向后传播唤醒动作。

问题二:引入 PROPAGATE 状态是为了解决什么问题?

答:引入 PROPAGATE 状态是为了解决并发释放信号量所导致部分请求信号量的线程无法被唤醒的问题。

4.独占式获取响应中断

AQS提供了acquire(int arg)方法以供独占式获取同步状态,但是该方法对中断不响应,对线程进行中断操作后,该线程会依然位于CLH同步队列中等待着获取同步状态。为了响应中断,AQS提供了acquireInterruptibly(int arg)方法。

首先校验该线程是否已经中断了,如果是则抛出InterruptedException,否则执行tryAcquire(int arg)方法获取同步状态,如果获取成功,则直接返回,如果再次获取失败,则执行doAcquireInterruptibly(int arg)。doAcquireInterruptibly(int arg)定义如下:

获取锁响应中断和acquire( )原理几乎一样,唯一区别在于获取锁响应中断的parkAndCheckInterrupt( )返回true时即该线程阻塞时被中断,抛中断异常后线程退出,不会执行后面语句。

5.独占式超时获取同步状态

超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”。

doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。

针对超时控制,程序首先记录唤醒时间deadline ,deadline = System.nanoTime() + nanosTimeout(时间间隔)。如果获取同步状态失败,则需要计算出需要休眠的时间间隔nanosTimeout(= deadline - System.nanoTime()),如果nanosTimeout <= 0 表示已经超时了,返回false,如果大于spinForTimeoutThreshold(1000L)则需要休眠nanosTimeout ,如果nanosTimeout <= spinForTimeoutThreshold ,就不需要休眠了,直接进入快速自旋的过程。原因在于 spinForTimeoutThreshold 已经非常小了,非常短的时间等待无法做到十分精确,如果这时再次进行超时等待,相反会让nanosTimeout 的超时从整体上面表现得不是那么精确,所以在超时非常短的场景中,AQS会进行无条件的快速自旋。

独占式超时获取同步态的流程图如下:

参考:Java并发编程的艺术

           AbstractQueuedSynchronizer 原理分析 - 独占/共享模式

AbstractQueuedSynchronizer源码解读

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值