天天看点

AQS原理浅析

        锁是最常用的同步方法之一,在高并发的环境下激烈的锁竞争会导致程序的性能下降,所以我们自然有必要深入的学习一下锁的相关知识。

         java的内置锁一直都是备受争议的,在JDK 1.6之前,synchronized这个重量级锁其性能一直都是较为低下,虽然在1.6后,进行大量的锁优化策略,如自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等,但是与Lock相比synchronized还是存在一些缺陷的:虽然synchronized提供了便捷性的隐式获取锁释放锁机制(基于JVM机制),但是它却缺少了获取锁与释放锁的可操作性,可中断、超时获取锁,且它为独占式在高并发场景下性能大打折扣。

         在《深入理解Java虚拟机》这本书上,作者说了这句话:与其说ReentrantLock性能好,还不如说synchronized还有很大优化的余地。在JDK1.6之后,人们发现synchronized与ReentrantLock的性能基本上是完全持平的(但是在JDK是1.8做基础测试时synchronized的性能还是不如ReentrantLock,原因暂未发现)。虚拟机在未来的性能改进中肯定会更加偏向于原生的synchronized,所以还是提倡synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。但是在jdk1.8测试中lock的使用比当确实使用synchroinzed同步时我们的性能瓶颈时,我们可以用ReentrantLock来进行性能的测试,如果确实更优,我们就可以选择用ReetrantLock来进行同步。

        在介绍Lock之前,我们需要先熟悉一个非常重要的基础组件,JUC包下的核心基础组件。也是实现大部分同步需求的基础。学习该组件是学习JUC绕不开的一块内容。该组件就是AQS。

AQS简介

  • AQS:AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等)。
  • AQS解决了子类实现同步器时涉及当的大量细节问题,例如获取同步状态、FIFO同步队列。基于AQS来构建同步器可以带来很多好处。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了,所以使用AQS不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。
  • 在基于AQS构建的同步器中,只能在一个时刻发生阻塞,从而降低上下文切换的开销,提高了吞吐量。同时在设计AQS时充分考虑了可伸缩行,因此J.U.C中所有基于AQS构建的同步器均可以获得这个优势。
  • AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。
  • AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,当然AQS可以确保对state的操作是安全的。
  • AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。

AQS常用方法

    关于state的方法主要有一下三种

  • getState():返回同步状态的当前值;
  • setState(int newState):设置当前同步状态;
  • compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性;

   自定义同步器实现时主要实现以下几种方法

  • tryAcquire(int arg):独占式获取同步状态,获取同步状态成功后,其他线程需要等待该线程释放同步状态才能获取同步状态
  • tryRelease(int arg):独占式释放同步状态;
  • tryAcquireShared(int arg):共享式获取同步状态,返回值大于等于0则表示获取成功,否则获取失败;
  • tryReleaseShared(int arg):共享式释放同步状态;
  • isHeldExclusively():当前同步器是否在独占式模式下被线程占用,一般该方法表示是否被当前线程所独占;

其余方法

  • acquire(int arg):独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用可重写的tryAcquire(int arg)方法;
  • acquireInterruptibly(int arg):与acquire(int arg)相同,但是该方法响应中断,当前线程为获取到同步状态而进入到同步队列中,如果当前线程被中断,则该方法会抛出InterruptedException异常并返回;
  • tryAcquireNanos(int arg,long nanos):超时获取同步状态,如果当前线程在nanos时间内没有获取到同步状态,那么将会返回false,已经获取则返回true;
  • acquireShared(int arg):共享式获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式的主要区别是在同一时刻可以有多个线程获取到同步状态;
  • acquireSharedInterruptibly(int arg):共享式获取同步状态,响应中断;
  • tryAcquireSharedNanos(int arg, long nanosTimeout):共享式获取同步状态,增加超时限制;
  • release(int arg):独占式释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒;
  • releaseShared(int arg):共享式释放同步状态;

CLH

       CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。

在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),其数据结构如下

AQS原理浅析

   其实就是个双端双向链表。

   数据定义如下

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;
    Node nextWaiter;
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }
    Node() {
    }
    Node(Thread thread, Node mode) {
        this.nextWaiter = mode;
        this.thread = thread;
    }
    Node(Thread thread, int waitStatus) {
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}      

    可以看到AQS支持两种同步模式,分别是Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。这样方便使用者实现不同类型的同步组件。简而言之,AQS为使用者提供了多样的底层支撑,具体如何组装实现,使用者可以***发挥。

   入列

   CHL这种链表式结构入列,无非就是tail指向新节点、新节点的前驱节点指向当前最后的节点,当前最后一个节点的next指向当前节点,直接看源码相关操作在addWaiter(Node node)方法里。此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点  

private Node addWaiter(Node mode) {
        //根据给定的模式(独占或者共享)新建Node
        Node node = new Node(Thread.currentThread(), mode);
        //快速尝试添加尾节点
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            //CAS设置尾节点
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        //多次尝试
        enq(node);
        return node;
    }      

    addWaiter(Node node)先通过快速尝试设置尾节点,如果失败,则调用enq(Node node)方法设置尾节点

private Node enq(final Node node) {
        //多次尝试,直到成功为止
        for (;;) {
            Node t = tail;
            //tail不存在,设置为首节点
            if (t == null) {
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                //设置为尾节点
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }      

        此方法用于将node加入队尾,该方法核心就是通过CAS自旋的方式来设置尾节点,知道获得预期的结果即添加节点成功,当前线程才会返回。(这种方式很经典AtomicInteger.getAndIncrement()方法也是这样做的)

     出列

     CLH同步队列遵循FIFO(先进先出),首节点的线程释放同步状态后,将会唤醒它的后继节点(next),而后继节点将会在获取同步状态成功时将自己设置为首节点,这个过程非常简单,head执行该节点并断开原首节点的next和当前节点的prev即可,注意在这个过程是不需要使用CAS来保证的,因为只有一个线程能够成功获取到同步状态。

同步状态的获取与释放

       AQS的设计模式采用的模板方法模式,子类通过继承的方式,实现它的抽象方法来管理同步状态,对于子类而言它并没有太多的活要做,AQS提供了大量的模板方法来实现同步,主要是分为三类:独占式获取和释放同步状态、共享式获取和释放同步状态、查询同步队列中的等待线程情况。自定义子类使用AQS提供的模板方法就可以实现自己的同步语义。

    独占式同步状态获取

    此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。也就是说由于线程获取同步状态失败加入到CLH同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移除获取到资源后。下面是acquire()的源码:

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }      
  • tryAcquire:去尝试获取锁,获取成功则设置锁状态并返回true,否则返回false。该方法由自定义同步组件自己实现(通过state的get/set/CAS),该方法必须要保证线程安全的获取同步状态。
  • addWaiter:如果tryAcquire返回FALSE(获取同步状态失败),则调用该方法将当前线程加入到CLH同步队列尾部,并标记为独占模式。
  • acquireQueued:当前线程会根据公平性原则来进行阻塞等待(自旋),直到获取锁为止;如果在整个等待过程中被中断过,则返回true,否则返回false。
  • selfInterrupt:如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

 tryAcquire(int)

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

      该方法直接抛出异常,具体实现交自定义同步器类实现。这里之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。

 acquireQueued

   在执行到此方法时已经说明一点:该线程获取资源失败,已经被放入等待队列尾部了。所以 acquireQueued方法就是让线程进入等待状态休息,直到其他线程彻底释放资源后唤醒该线程,获取所需资源,然后执行该线程所需执行的任务。

   acquireQueued方法为一个自旋的过程,也就是说当前线程(Node)进入同步队列后,就会进入一个自旋的过程,每个节点都会自我观察,当条件满足,获取到同步状态后,就可以从这个自旋过程中退出,否则会一直执行下去。

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)) {
                    /*将head指向该节点*/
                    setHead(node);
                   /* 方便GC回收垃圾 */
                    p.next = null; 
                    failed = false;
                   /*返回等待过程中是否被中断过*/
                    return interrupted;
                }
                /*获取失败,线程就进入waiting状态,直到被unpark()*/
                if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
                    /*如果等待过程中被中断过一次,就标记为true*/
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }      

       从上面代码中可以看到,当前线程会一直尝试获取同步状态,当然前提是只有其前驱节点为头结点才能够尝试获取同步状态,理由:

  • 保持FIFO同步队列原则。
  • 头节点释放同步状态后,将会唤醒其后继节点,后继节点被唤醒后需要检查自己是否为头节点。

shouldParkAfterFailedAcquire(Node, Node)

    此方法主要用于检查状态,查看当前节点是否进入waiting状态

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;//拿到前驱节点的状态
    if (ws == Node.SIGNAL)
        //状态为SIGNAL,如果前驱节点处于等待状态,直接返回true
        return true;
    if (ws > 0) {
        /*
         * 如果前驱节点放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
         * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被GC回收
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
         //如果前驱节点正常,那就把前驱的状态通过CAS的方式设置成SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}      

 这段代码主要检查当前线程是否需要被阻塞,具体规则如下:

  1. 如果当前线程的前驱节点状态为SINNAL,则表明当前线程需要被阻塞,调用unpark()方法唤醒,直接返回true,当前线程阻塞
  2. 如果当前线程的前驱节点状态为CANCELLED(ws > 0),则表明该线程的前驱节点已经等待超时或者被中断了,则需要从CLH队列中将该前驱节点删除掉,直到回溯到前驱节点状态 <= 0 ,返回false
  3. 如果前驱节点非SINNAL,非CANCELLED,则通过CAS的方式将其前驱节点设置为SINNAL,返回false

      整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能被阻塞,需要去找个安心的休息点(前驱节点状态 <= 0 ),同时可以再尝试下看有没有机会去获取资源。

     如果 shouldParkAfterFailedAcquire(Node pred, Node node) 方法返回true,则调用parkAndCheckInterrupt()方法阻塞当前线程: 

private final boolean parkAndCheckInterrupt() {
        //调用park()使线程进入waiting状态
          LockSupport.park(this); 
          //如果被唤醒,查看自己是不是被中断的
          return Thread.interrupted();
 }      

    parkAndCheckInterrupt() 方法主要是把当前线程挂起,从而阻塞住线程的调用栈,同时返回当前线程的中断状态。

实现细节有点复杂 未完待续

继续阅读