java锁:AQS详解(一)

2021/8/23 8:28:29

本文主要是介绍java锁:AQS详解(一),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

AQS全称是AbstractQueuedSynchronizer,是一个用来构建锁和同步器的框架,它底层用了CAS技术来保证操作的原子性,同时利用FIFO队列实现线程间的锁竞争,将基础的同步相关抽象细节放在AQS,这也是 ReentrantLock、CountDownLatch 等同步工具实现同步的底层实现机制。它能够成为实现大部分同步需求的基础,也是JUC并发包同步的核心基础组件。

在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getstate,setState以及compareAndSetState等 protected类型方法来进行操作。这个整数可以用于表示任意状态。

AQS结构

队列同步器AQS(下文简称为同步器)主要是依赖于内部的一个FIFO(first-in-first-out)双向队列来对同步状态进行管理的,当线程获取同步状态失败时,同步器会将当前线程和当前等待状态等信息封装成一个内部定义的节点Node,然后将其加入队列,同时阻塞当前线程;当同步状态释放时,会将同步队列中首节点唤醒,让其再次尝试去获取同步状态。同步队列的基本结构如下:

http://img4.sycdn.imooc.com/61223ee60001116a08950295.jpg下面是 AQS 类的几个重要字段与方法:

public abstract class AbstractQueuedSynchronizer
  extends AbstractOwnableSynchronizer
    implements java.io.Serializable {

    private transient volatile Node head;

    private transient volatile Node tail;

    private volatile int state;

    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

    // ...

 }

1.head字段为等待队列的头节点,表示当前正在执行的节点; 2.tail字段为等待队列的尾节点; 3.state字段为同步状态,其中state > 0为有锁状态,每次加锁就在原有state基础上加1,即代表当前持有锁的线程加了state次锁,反之解锁时每次减一,当statte = 0为无锁状态; 4.通过compareAndSetState方法操作 CAS 更改state状态,保证state的原子性。 这几个字段都用volatile关键字进行修饰,以确保多线程间保证字段的可见性。

因为获取锁是有条件的,没有获取锁的线程就要阻塞等待,那么就要存储这些等待的线程。在AQS中使用CLH队列储存这些等待的线程,但它并不是直接储存线程,而是储存拥有线程的node节点。 下面是同步队列节点的结构:

static final class Node {
    //共享模式的标记,标识一个节点在共享模式下等待
    static final Node SHARED = new Node();
    //独占模式的标记,标识一个节点在独占模式下等待
    static final Node EXCLUSIVE = null;

    // waitStatus变量的值,标志着线程被取消,后续将不会获取到锁
    static final int CANCELLED = 1;
     // waitStatus变量的值,标志着后继线程(即队列中此节点之后的节点)需要被阻塞.(用于独占锁)
    static final int SIGNAL = -1;
    // waitStatus变量的值,标志着线程在Condition条件上等待阻塞.(用于Condition的await等待)
    static final int CONDITION = -2;
    // waitStatus变量的值,标志着下一个acquireShared方法线程应该被无条件传播。(用于共享锁)
    static final int PROPAGATE = -3;

     // 标记着当前节点的状态,默认状态是0, 小于0的状态都是有特殊作用,大于0的状态表示已取消
     //SIGNAL:此节点的继任节点被阻塞,故当此节点释放锁或中断时需要换继任节点的线程。
     //CANCELLED:当获取锁超时或被中断时节点状态会被设置成此状态,此状态的节点不会被再次阻塞;
     //CONDITION:标识此节点在条件队列中,在同步队列的节点不会出现此状态,
     //当节点从条件队列移到同步队列时此状态会被设置为0;
     //PROPAGATE:一个releaseShared应该被传播到其他节点,此状态在doReleaseShared()中调用,
     //以确保传播传播在其他插入时保持继续。
     //总结:状态小于0表示节点无需被通知唤醒;状态为0表示普通同步节点;CONDITION表示节点在
     //等待队列中,状态通过CAS进行原子更新。
    volatile int waitStatus;

    /**
     * 前驱节点,在enqueue时设置,在dequeue或前驱节点取消时清除;
     */
    volatile Node prev;

    /**
     * 后继节点,在enqueue时设置,在dequeue或前驱节点取消时清除;
     * 入队操作不会设置前驱节点的后继节点,直到节点连接到队列;
     * 故next节点为null不一定表示此节点为队列尾部,当next节点为null时,
     * 可遍历prev节点进行双重检查;已经取消的节点的next指向自己而不是null
     */
    volatile Node next;

    //该节点拥有的线程
    volatile Thread thread;

    /**
     * 1、值为null或非SHARED;为null时表示独占模式;非SHARED时表示在Condition中等待的队列;
     * 2、值为SHARED,表示共享模式;
     */
    Node nextWaiter;

    //是否为共享模式
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
    //当前节点的前驱节点,如果前驱节点为null,则抛出NPE异常
    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
    }

    //用于在addWaiter()中使用,创建同步队列中的节点
    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    //在Condition中使用,创建等待队列的节点
    Node(Thread thread, int waitStatus) { // Used by Condition
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

waitStatus:表示当前节点的状态,会有如下五中状态:

CANCELLED(1):当获取锁超时或被中断时节点状态会被设置成此状态,此状态的 节点会被unpark,不会参与锁的获取,不会被再次阻塞;

0:表示普通节点,当节点初始插入到同步队列时的状态;

SIGNAL(-1):此节点的继任节点被阻塞,故当此节点释放锁或中断时需要换继任节点的线程。

CONDITION(-2):标识此节点在条件队列中,在同步队列的节点不会出现此状态,当节点从条件队列移到同步队列时此状态会被设置为0;

PROPAGATE(-3):一个releaseShared应该被传播到其他节点,此状态在doReleaseShared()中调用,以确保传播传播在其他插入时保持继续。

head节点可以表示成当前持有锁的线程的节点,其余线程竞争锁失败后,会加入到队尾,tail始终指向队列的最后一个节点。

AQS的结构大概可总结为以下 3 部分:

1.用volatile修饰的整数类型的 state 状态,用于表示同步状态,提供getState和setState来操作同步状态;

2.提供了一个FIFO等待队列,实现线程间的竞争和等待,这是AQS的核心;

3.AQS内部提供了各种基于CAS原子操作方法,如compareAndSetState方法,并且提供了锁操作的acquire和release方法。

独占锁

独占锁主要包括两方面功能:

获取锁的功能:既当多个线程一起获取锁的时候,只有一个线程能获取到锁,其他线程必须在当前位置阻塞等待。 释放锁的功能:获取锁的线程释放锁资源,而且还必须能唤醒正在等待锁资源的一个线程。 独占锁的原理是如果有线程获取到锁,那么其它线程只能是获取锁失败,然后进入等待队列中等待被唤醒。

获取锁

获取独占锁代码如下:

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

1.通过tryAcquire(arg)方法尝试获取锁,这个方法需要实现类自己实现获取锁的逻辑,获取锁成功后则不执行后面加入等待队列的逻辑了; 2.如果尝试获取锁失败后,则执行addWaiter(Node.EXCLUSIVE)方法将当前线程封装成一个Node节点对象,并加入队列尾部; 3.把当前线程执行封装成Node节点后,继续执行acquireQueued的逻辑,该逻辑主要是判断当前节点的前置节点是否是头节点,来尝试获取锁,如果获取锁成功,则当前节点就会成为新的头节点,这也是获取锁的核心逻辑。

private Node addWaiter(Node mode) {
  // 创建一个基于当前线程的节点,该节点是 Node.EXCLUSIVE 独占式类型
  Node node = new Node(Thread.currentThread(), mode);
  // Try the fast path of enq; backup to full enq on failure
  Node pred = tail;
  // 这里先判断队尾是否为空,如果不为空则直接将节点加入队尾
  if (pred != null) {
    node.prev = pred;
    // 采取 CAS 操作,将当前节点设置为队尾节点,由于采用了 CAS 原子操作,无论并发怎么修改,都有且只有一条线程可以修改成功,其余都将执行后面的enq方法
    if (compareAndSetTail(pred, node)) {
      pred.next = node;
      return node;
    }
  }
  enq(node);
  return node;
}

addWaiter方法做了以下事情:1.创建基于当前线程的独占式类型的节点; 2.利用CAS原子操作,将节点加入队尾。 再看enq方法:

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;
                }
            }
        }
    }

enq(final Node node)方法主要做了以下事情:

1.采用自旋机制,这是 aqs 里面很重要的一个机制; 2.如果队尾节点为空,则初始化队列,将头节点设置为空节点,头节点即表示当前正在运行的节点; 3.如果队尾节点不为空,则继续采取CAS操作,将当前节点加入队尾,不成功则继续自旋,直到成功为止;

对比了上面两段代码,不难看出,首先是判断队尾是否为空,先进行一次CAS入队操作,如果失败则进入 enq(final Node node) 方法执行完整的入队操作。

完整的入队操作简单来说就是:如果队列为空,初始化队列,并将头节点设为空节点,表示当前正在运行的节点,然后再将当前线程的节点加入到队列尾部。

经过上面CAS不断尝试,这时当前节点已经成功加入到队尾了,接下来就到了acquireQueued 的逻辑:

//调用tryAcquire尝试获取锁,当获取失败就将线程节点入队并阻塞节点线程;
//直到线程被中断或被唤醒,会再次尝试获取锁;
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方法总结就是: 1.判断当前节点的pred节点是否为head节点,如果是,则尝试获取锁; 2.获取锁失败后,进入挂起逻辑。

//判断当前节点线程是否需要阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //前驱节点的状态
    int ws = pred.waitStatus;
    
    //前驱节点状态为SIGNAL,表示前驱节点在等待获取锁的信号
    //故本节点可以安全的阻塞
    if (ws == Node.SIGNAL)
        return true;
    
    //前驱节点waitStatus>0,即为waitStatus=CANCELLED;
    //表示前驱节点已经被取消,需需要前向遍历前驱节点,直到状态
    //不为CANCELLED的节点,并将此节点设为node节点的前驱节点;
    //返回false,让上层调用继续尝试获取锁
    if (ws > 0) {
        //循环遍历前驱节点,寻找状态不为CANCELLED的节点,并设为当前节点的
        //前驱节点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        //当前驱节点状态为0或PROPAGATE时,通过CAS设置前驱节点状态为SIGNAL
        //并返回fase,等待下个循环阻塞当前节点线程;
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

//通过LockSupport.park()阻塞当前线程,直到线程被unpark或被中断
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

shouldParkAfterFailedAcquire(Node pred, Node node)方法主要做了以下事情:

1.判断pred节点状态,如果为SIGNAL状态,则直接返回true执行挂起; 2.删除状态为CANCELLED的节点; 3.若 pred 节点状态为0或者PROPAGATE,则将其设置为为SIGNAL,再从acquireQueued方法自旋操作从新循环一次判断。

通俗来说就是:根据pred节点状态来判断当前节点是否可以挂起,如果该方法返回false,那么挂起条件还没准备好,就会重新进入acquireQueued(final Node node, int arg) 的自旋体,重新进行判断。如果返回 true,那就说明当前线程可以进行挂起操作了,那么就会继续执行挂起。

这里需要注意的时候,节点的初始值为0,因此如果获取锁失败,会尝试将节点设置为SIGNAL。

释放锁

//释放独占锁
public final boolean release(int arg) {
    //调用tryRelease方式通过CAS尝试释放锁,tryRelease由子类实现
    if (tryRelease(arg)) {
        Node h = head;
        
        //头结点不为空且头节点状态不为0,应该为SIGNAL
        //表示队列中有需要唤醒的节点,调用unparkSuccessor进行头节点线程
        //唤醒操作
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

通过tryRelease(arg) 方法尝试释放锁,这个方法需要实现类自己实现释放锁的逻辑,释放锁成功后则执行后面的唤醒后续节点的逻辑了,然后判断 head 节点不为空并且head节点状态不为0,因为addWaiter方法默认的节点状态为0,此时节点还没有进入就绪状态。

继续往下看源码:

//唤醒节点线程处理
private void unparkSuccessor(Node node) {
    //获取当前节点状态
    int ws = node.waitStatus;
    //状态小于零,则将状态重置为0,表示节点处理已经完成
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 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.
     */
    //获取后继节点,当后继节点为空或后继节点状态为CANCELLED时;
    //由tail前向遍历队列,找到当前节点的下个有效节点,即waitStatus <= 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;
    }
    //下个节点非空,表示为等待信号的节点,执行unpark唤醒节点线程
    if (s != null)
        LockSupport.unpark(s.thread);
}

从源码可看出:释放锁主要是将头节点的后继节点唤醒,如果后继节点不符合唤醒条件,则从队尾一直往前找,直到找到符合条件的节点为止。

总结

从源码中可以看出:在独占锁模式下,用state值表示锁并且 0 表示无锁状态,0 -> 1 表示从无锁到有锁,仅允许一条线程持有锁,其余的线程会被包装成一个Node节点放到队列中进行挂起,队列中的头节点表示当前正在执行的线程,当头节点释放后会唤醒后继节点,这也说明了AQS的队列是一个FIFO同步队列。


作者:西vvi
链接:https://juejin.cn/post/6998690584177147934
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。




这篇关于java锁:AQS详解(一)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程