AQS源码解析

  并发编程的目的是为了让程序运行得更快,增加了用户体验与程序的效率。比如tomcat服务器的多线程模型,它使得多个用户可以同时发送请求。但是,在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(同步是指程序中用于控制不同线程间操作发生相对顺序的机制)。在这里,我们只讲解线程之间如何同步的问题。如果线程之间没有实现同步,很容易导致许多问题(当然对于非共享资源的访问不需要考虑同步)。比如,当多个线程同时访问共享变量,这样很容易引起可见性、原子性和有序性等线程安全问题。那么,我们该如何处理这些问题呢?A moment minutes later,既然无法解决问题,那就解决出问题的人。
  很自然的我们会想到一种方法,即实现多线程对共享资源的互斥访问,即同一时间只能有一个线程对共享资源进行访问。当然,我们需要根据实际情况具体分析,比如多个线程(读线程)读一个变量是可以的,这种情况下允许同一时间存在多个线程同时访问共享资源。那么,具体地,我们应当如何实现多线程对共享资源的互斥访问呢?我们可以使用锁来控制多个线程访问共享资源的方式。然而,实现锁并不是一件简单的事情,我们既要考虑锁是否能够保证线程安全,又要考虑锁对于性能的影响。接下来我将会给大家带来一件轻松实现锁的法宝,并对其进行解析。
  这件法宝其实是AQS,队列同步器AbstractQueuedSynchronizer(简称AQS,下面使用AQS或者同步器来代指)是用来构建锁或者其他同步组件的基础框架,AQS实现了若干同步状态获取和释放的功能。有了AQS,我们自身也可以根据实际应用场景轻松地构建锁。比如,读写锁,可重入锁等等;
  在解析AQS之前,我们先来聊聊一些无关紧要(很重要)的事情。如果是你,你会怎样设计AQS来实现这些功能呢?
  先做个有味道的类比,假如有三个人想要上厕所,可是厕所只有一个,要怎样处理呢?正常来说,往往是第一个抢到厕所的人进入厕所。然后,关上厕所门。其他俩个人在厕所外等待。但是,剩余的人要怎么等待呢?是随意站着,然后等占有厕所的人出来后进行抢厕所吗?我们可以让他们排队,这样不至于引起下一轮的争吵。等到当前占有厕所的人上完厕所,队列的第一个人可以进入厕所。
  通过这个类比,我们会联想到可以让第一个获取共享资源的线程独占式地享有,而使其他线程阻塞(这里不一定会将线程阻塞,也可以是自旋地请求是否可以获取共享资源)。可是这样当第一个线程占有共享资源后,其他线程其实并不知道第一个线程已经获取了共享资源。所以,我们应该设置一个对所有线程可见的变量,而且提供一个保证原子修改该变量的操作。这样可以通过该变量来告诉其他线程是否存在线程已经获取了共享资源。对应上面的类比,有一个人抢到厕所,就会把厕所门关上,这样其他人就知道厕所里有人,这里门对所有人可见,且我们应保证在同一个时刻只有一个人会执行关门操作,毕竟不能俩个人一起上厕所。另一个问题是,当第一个线程获取共享资源后,其他线程我们该如何处理呢?我们可以设置一个容器来管理其他未能获取共享资源的线程,对应上面其他人在厕所外排队。AQS的设计思路大致也是这样,接下来我们来看看AQS的具体实现。
  AQS使用了一个int成员变量(volatile修饰,保证对所有线程可见,但是volatile变量不能保证复合修改操作具有原子性)表示同步状态(线程获取同步状态成功即表示获取到共享资源,下面一律使用同步状态的说法),那么如何保证原子修改操作呢?AQS使用CAS(CAS的实现解析将会在以后进行更新,CAS方法说明:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值)来保证原子修改操作。
  AQS通过内置的FIFO队列(容器,下面使用同步队列的说法来表示该FIFO队列)来完成资源获取线程的排队工作。当前线程若获取同步状态失败,AQS会将当前线程以及等待状态等信息构造成为一个节点并将其加入同步队列尾部,同时会阻塞当前线程。当成功获取同步状态的线程执行完相应的逻辑并释放同步状态后,会把同步队列首节点中的线程唤醒,使其再次尝试获取同步状态。当然,我们在实现同步队列时,应当考虑到线程安全问题(使用CAS解决)。因为在多线程环境下,会存在多个节点同时加入同步队列尾部等操作。那么我们该如何解决问题呢?同上述一样,AQS使用volatile和CAS来保证加入同步队列尾部的原子操作。
  AQS拥有首节点和尾节点,没有成功获取同步状态的线程将会成为节点加入同步队列的尾部,同步队列的基本结构如下图所示:

  接下来我们来看看AQS的部分实现源码(此次只讨论独占式地获取同步状态,不涉及共享式地获取同步状态、等待队列等知识),并对其进行解析,来看看AQS是如何实现这些功能的。
  节点是构成同步队列的基础。节点类的源码如下所示,它定义了节点的属性以及构造方法。


/**
*AQS的静态内部类,节点类定义了节点的属性以及构造方法等。
*当前线程若获取同步状态失败,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上,当其他线程对Conditon调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中。
         */
        static final int CONDITION = -2;
        
        /**
         *表示下一次共享式同步状态获取将会无条件地被传播下去。
         */
        static final int PROPAGATE = -3;

        //等待状态,用来标识当前节点的状态
        volatile int waitStatus;

        //前驱节点
        volatile Node prev;

        //后继节点
        volatile Node next;

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

        /**
         *等待队列中的后继节点。
         *如果当前节点是共享的,那么这个字段将是一个SHARED常量,
         *也就是说节点类型(独占和共享)和等待队列中的后继节点共用一个字段。
         *其实这个属性也会出现在等待队列中,等待队列也使用该节点类来构造节点。
         */
        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() {    // Used to establish initial head or SHARED marker
        }

        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
}

  上面说过,AQS使用int成员变量表示同步状态,并通过同步队列来完成对获取同步状态失败的线程的管理。实际上,AQS包含了俩个节点类型的引用,一个指向头节点,而另一个指向尾节点。通过利用这俩个节点的引用,可以实现节点的入队与出队操作。

  接下来,我们来看看AQS如何实现同步队列的入队操作呢?

  获取同步状态失败的线程构造成节点并将其加入同步队列后,我们需要考虑的是,让该线程不断自旋请求获取同步状态,还是选择将其阻塞,等到获取同步状态成功的线程执行完相应的逻辑并释放同步状态后将其唤醒。我们知道,长时间地进行自旋请求会严重消耗CPU资源,而使得其他线程无法获得时间片来执行操作。若选择阻塞线程,又会引起操作系统从用户态到内核态的转换,非常消耗系统资源。所以我们应当选择一个择中策略,在短时间内自旋请求,时间长就将其阻塞。synchronized的锁升级类似于这样。接下来让我们来看看AQS是如何选择这一策略的。


  获取同步状态成功的线程执行完相应逻辑后,就需要释放同步状态,同时唤醒同步队列的后续节点,使得后续节点能够继续获取同步状态。


  以上便是对AQS源码的部分解析,当然还有很多未解析的,敬请期待下次更新。
  大概(很简陋)地解析了AQS的实现之后,我们来看看如何使用AQS轻松地实现锁——AQS的使用方式。
  AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。在这里子类推荐被定义为自定义同步组件(包含锁)的静态内部类。在抽象方法的实现过程中需要对同步状态进行修改,AQS提供了三个线程安全的修改同步状态的方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))。我们来看看这三个方法的源码。

  在自定义同步组件中,加锁与解锁通过调用AQS的acquire(int arg)和release(int arg)来实现。上面已经解析过release(int arg)方法,现在我们来看看AQS中的acquire(int arg)方法。

  通过这个,我们可以看出AQS类似于一个过滤器,将获取同步状态成功的线程放行;而使获取同步状态失败的线程自旋等待或者阻塞在内置的同步队列中。
  最后,使用AQS来构建一个互斥锁(只写关键代码,其他可自己实现)。


  参考书籍《Java并发编程的艺术》

posted @ 2020-11-06 17:17  Polaris_Coding  阅读(310)  评论(0编辑  收藏  举报