深入理解Java并发编程之Lock和AQS

本文转自个人掘金博客:https://juejin.im/post/5ee37be951882543435a2747

本文主要为《Java并发编程的艺术》第三章的读书记录笔记

Lock接口

Lock vs synchronized

Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能。但是,它们却有以下不同:

synchronized:使用synchronized的关键字将会隐式地获取和释放锁。同时,使用什么类型的锁(偏向,轻量级锁,重量级锁)以及锁的具体实现都是由JVM底层实现

Lock接口:Lock接口相关实现类对于锁的获取和释放需要显示进行。同时,怎样获取和释放是由开发者(包括JDK源码)自行决定的。这样的话,它就拥有了锁获取与释放的可操作性、非阻塞地获取锁可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。

Lock接口API

Lock是一个接口,它定义了锁获取和释放的基本操作,Lock的API如下:

  1. void lock():阻塞获取锁。如果锁获取不到,当前线程就无法进行线程调度只能等待直到获取到锁。
  2. void lockInterruptibly() throws InterruptedException:可中断的阻塞获取锁。和lock()方法的不同是该方法在锁的获取中可以中断当前线程。
  3. boolean tryLock():尝试非阻塞地获取锁。调用该方法后立即返回,如果能够获取锁则返回true,否则返回false。
  4. boolean tryLock(long time, TimeUnit unit) throws InterruptedException:阻塞超时获取锁。当前线程在以下3种情况会返回:
    • 当前线程在超时时间内获得了锁,返回true
    • 当前线程在超时时间内被中断,抛出异常
    • 超时时间结束,返回false
  5. void unlock():释放锁。在Lock接口的实现类中,通常只有拥有锁的线程才能释放它。
  6. Condition newCondition():返回一个和当前锁绑定Condition对象实例。

Lock接口的实现如ReentrantLock基本都是通过聚合了一个 队列同步器(AQS)的子类来完成线程访问控制的。

AbstractQueuedSynchronizer(AQS)

抽象类AbstractQueuedSynchronizer提供了一个基础框架,它可以用来实现阻塞锁和其他相关的依赖于FIFO等待队列同步组件(比如Semaphore)。它的实现主要依赖于一个单一的原子int变量值state来表示同步状态。继承的子类必须重写AQS的几个protected修饰的用来改变同步状态的方法以及基于这些状态进行锁获取和释放的方法。AQS类里面的其他方法主要实现了排队和阻塞的机制。继承AQS的子类可能会维护一些其他的状态值,但是只能通过getState,setState以compareAndSetState这三个方法来原子地更新管理同步状态的state值

继承AQS的子类被推荐定义为阻塞锁或者同步组件实现类的静态内部类。AQS自己本身没有实现任何同步接口。相反地,它仅仅是定义了若干同步状态的获取和释放方法来供阻塞锁或者同步组件的使用,来实现子类的公共方法。

AQS既支持独占式获取同步状态,又支持共享式获取同步状态,也支持两者模式具备。默认情况下是独占式获取同步状态。

  1. 独占模式指的是一旦有一个线程占有,其他线程便无法占有。
  2. 共享模式支持的是多线程占有。

AQS并不关心这些不同模式之间的差异。不同模式下的等待线程共享同一个FIFO队列。通常,AQS的实现子类要么只支持独占模式,要么只支持共享模式。但是,也有例外,比如ReadWriteLock两种都支持。只支持独占模式或者是共享模式的子类不需要定义另一种模式的方法

AQS是实现锁Lock接口或者是同步组件的关键,在锁的实现中聚合AQS,利用AQS实现锁的语义。可以这样理解二者之间的关系:

  1. Lock接口是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节
  2. AQS面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和AQS很好地隔离了使用者和实现者所需关注的领域。

AQS的接口与实例

AQS的接口

同步器的设计是基于模板模式的,也就是说,使用者需要继承AQS并重写指定的方法,随后将AQS组合在自定义同步组件的实现中,并调用AQS提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写AQS指定的方法时,需要使用AQS提供的如下3个原子操作方法来访问或修改同步状态:

  1. getState():获取当前同步状态。
  2. setState(int newState):设置当前同步状态。
  3. compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。

继承AQS必须重写的方法如下所示:

  1. protected boolean tryAcquire(int arg):独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态。
  2. protected boolean tryRelease(int arg):独占式释放同步状态,等待获取同步状态的线程将有机会获取同步锁。
  3. protected int tryAcquireShared(int arg):共享式的获取同步状态,返回大于等于0的值,表示锁获取成功,反之,获取失败
  4. protected boolean tryReleaseShared(int arg):共享式释放同步状态。
  5. protected boolean isHeldExclusively():当前AQS是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占。

继承AQS实现自定义同步组件时,将会调用AQS提供的模板方法。这些模板方法如下所示,注意到这些方法都是final修饰,表示这些方法不能被重写。

  1. public final void acquire(int arg): 独占式的获取同步状态,如果当前线程获取同步状态成功,则由该方法返回。否则,将会进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)。
  2. public final void acquireInterruptibly(int arg): 与acquire(int arg)相同,但是该方法响应中断,当前线程未获取同步状态而进行同步队列中,如果当前线程被中断,则该方法会抛出InterruptedException并返回。
  3. public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException:在acquireInterruptibly(int arg)基础上增加了超时限制,如果当前线程在超时时间内没有获取同步状态,那么将会返回false,如果获取到了返回true。
  4. public final boolean release(int arg):独占式的释放同步队列,该方法会在释放同步状态之后,将同步队列中的第一个节点包含的线程唤醒。
  5. public final void acquireShared(int arg):共享式的获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式获取的主要区别是在同一时刻可以有多少个线程获取到同步状态。
  6. public final void acquireSharedInterruptibly(int arg):与acquireShared(int arg)相同,该方法响应中断。
  7. public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException:在acquireSharedInterruptibly(int arg)基础上增加了超时限制。
  8. public final boolean releaseShared(int arg):共享式的释放同步状态。
  9. public final Collection getQueuedThreads():获取等待在同步队列上的线程集合。

同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态共享式获取与释放同步状态查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

一个简单的继承AQS的独占锁Mutex

独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁,如下所示:

class Mutex implements Lock {
    // 仅需要将操作代理到Sync上即可
    private final Sync sync = new Sync();

    public void lock() {
        sync.acquire(1);
    }

    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    public void unlock() {
        sync.release(1);
    }

    public Condition newCondition() {
        return sync.newCondition();
    }

    public boolean isLocked() {
        return sync.isHeldExclusively();
    }

    public boolean hasQueuedThreads() {
        return sync.hasQueuedThreads();
    }

    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

    // 静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 是否处于占用状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 当状态为0的时候获取锁
        public boolean tryAcquire(int acquires) {
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 释放锁,将状态设置为0
        protected boolean tryRelease(int releases) {
            if (getState() == 0) throw new
                    IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // 返回一个Condition,每个condition都包含了一个condition队列
        Condition newCondition() {
            return new ConditionObject();
        }
    }
}

可以发现:

  1. 独占锁Mutex的内部类Sync只实现了AQS独占模式的相关方法,因为它只使用了独占模式。
  2. 独占锁Mutex实现了Lock接口的所有方法。

下面在给出这个独占锁的使用Demo,可以发现它的实现没问题,确实完成了和synchronized关键字一样的同步功能。

    /**
     * 输出:
     * Running Thread-1
     * Exit Thread-1
     * Running Thread-2
     * Exit Thread-2
     */
    @Test
    public void testCase01() throws InterruptedException {
        Mutex mutex = new Mutex();

        Runnable r = () -> {
            mutex.lock();
            System.out.println("Running " + Thread.currentThread().getName());

            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {
                e.printStackTrace();
            }
            mutex.unlock();
            System.out.println("Exit " + Thread.currentThread().getName());
        };

        Thread thread1 = new Thread(r, "Thread-1");
        Thread thread2 = new Thread(r, "Thread-2");
        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }

我在写这个例子的时候,自己有个疑问。就是,Thread2在阻塞获取锁又获取不到的时候,它的线程状态的什么?此时,MainThread的状态是Waiting(在join的逻辑里面),Thread1的线程状态的是Timed_Waiting(正在sleep)。但是,Thread2的线程状态呢?线程的状态参考深入理解Java并发编程之线程Thread

如果,这里不是用的自己实现的独占锁Mutex,而是用的synchronized,这个时候Thread2的状态明显是blocked

为了回答这个问题,我把sleep的时间设置为666666,同时通过jstack dump出线程状态,很清楚的发现Thread2的状态为waiting,结果如下。

"Thread-2" #15 prio=5 os_prio=0 tid=0x00000000299cd000 nid=0x9498 waiting on condition [0x000000002b81e000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x00000007177a70f0> (a concurrent.aqs.Mutex$Sync)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
	at concurrent.aqs.Mutex.lock(Mutex.java:17)
	at concurrent.aqs.MutexTest.lambda$testCase01$0(MutexTest.java:17)
	at concurrent.aqs.MutexTest$$Lambda$1/385337537.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)

AQS的实现分析

接下来将从实现角度分析AQS是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等AQS的核心数据结构与模板方法。

同步队列

AQS依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理:

  1. 当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程(waiting)。
  2. 当前节点同步状态释放时,会把后续节点中的线程唤醒,使其再次尝试获取同步状态。

同步队列是由一个Node节点的双向链表实现的,Node节点数据接口如下:

volatile int waitStatus Node节点等待状态,包含如下状态:

  1. Cancelled,值为1,表示这个Node节点包裹的线程已经被取消。由于在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,节点进入该状态将不会变化。
  2. Signal,值为-1,该状态的Node节点的后继节点的线程处于等待状态,如果当前节点的线程释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行。
  3. Condition,值为-2,节点在等待队列中,该状态的节点表示节点包裹的线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列(尾部)中,加入到对同步状态的获取中。
  4. Propagate,值为-3,表示共享式同步状态将会被无条件的传播到其他节点,该状态只会被用来设置共享模式的AQS的head节点。

这些数值的设置只是为了简化使用。非负的数值节点表示该节点不用被signal。因此,大多数情况下,不会关心这些具体数值,只会关心数值的符号。

volatile Node prev

前驱节点,当节点从队列尾部加入同步队列时被设置。

volatile Node next 后继节点

Node nextWaiter 等待队列(等待在ConditionObject上的线程节点队列)中的后继节点。

  • 如果当前节点是独占的,这个值指向等待队列中的下一个节点。

  • 如果当前节点是共享的,那么这个字段将是一个SHARED常量。

volatile Thread thread Node节点锁包裹的线程。

Node节点是构成AQS同步队列以及等待队列的基础,其中同步队列的基本结构如下图所示:

 

image.png

 

 

  1. AQS拥有同步同步队列的头节点(head)和尾结点(tail)的引用(head和tail为AQS的成员变量)

  2. 当一个线程成功获取了同步状态(或者锁),其他线程将无法获得从而被构造成为Node节点加入同步队列尾部。

  3. 加入队列的过程必须要保证线程安全(可能有多个线程都没获取到同步状态需要加入同步队列),因此AQS提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。这个过程如下图所示。

 

image.png

 

 

  1. 同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,该过程如下图所示。设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

 

image.png

 

 

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

#####独占式同步状态获取 通过调用AQS的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出,该方法代码如下所示:

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

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

  1. 调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态

  2. 如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部

  3. 最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队并唤醒当前节点阻塞线程被中断来实现。

下面逐个分析相关函数。

    private Node addWaiter(Node mode) {
        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;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
    
    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;
                }
            }
        }
    }
    
    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);
        }
    }
  1. 这里通过使用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。用**CAS保证线程安全的原因是由于可能有多个线程调用tryAcquire(int arg)**方法获取同步状态失败而添加到队列尾部。
  2. 在enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过CAS变得“串行化”了。
  3. 节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自旋地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。这部分对应acquireQueued()函数。

在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?

  1. 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
  2. 维护同步队列的FIFO原则。该方法中,节点自旋获取同步状态的行为如下图所示。

 

image.png

 

 

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

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

 

image.png

 

 

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

独占式同步状态释放

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

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

该方法执行时,会唤醒头节点的后继节点线程,unparkSuccessor(Node node)方法使用LockSupport来唤醒处于等待状态的线程。

独占式AQS高度总结

  1. 在获取同步状态时,AQS维护一个同步队列,获取状态失败的线程都会被加入队列尾部并在队列中进行自旋。
  2. 移出队列(或停止自旋)的条件是前驱节点为头节点且当前成功获取了同步状态,获取之后将自己设置为头节点。
  3. 在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

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

#####共享式同步状态获取 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例

  1. 如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。
  2. 写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况,如下图所示:

 

image.png

 

 

上图中:

  1. 左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞。

  2. 右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。

通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态,该方法代码如下所示:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
    
    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
  1. 在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。
  2. 因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件:当前节点的前序是头结点且tryAcquireShared(int arg)方法返回值大于等于0

#####共享式同步状态释放 与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态,该方法代码如下所示:

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

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

独占式超时获取同步状态

通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(比如synchronized关键字)所不具备的特性。

响应中断的获取同步状态

在Java 5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。在Java 5中,同步器提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。

独占式超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,nanosTimeout计算公式为:nanosTimeout = deadline - System.nanoTime(),其中deadline为计算的最晚唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒,反之,表示已经超时,该方法代码如下:

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                nanosTimeout = deadline - System.nanoTime();
                if (nanosTimeout <= 0L)
                    return false;
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
  1. 该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似。

  2. 如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout小于等于0表示已经超时),如果没有超时,重新计算超时间隔nanosTimeout,然后使当前线程等待nanosTimeout纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Objectblocker,long nanos)方法返回)。

  3. 如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因在于,非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。(这种情况下线程的阻塞和唤醒的耗时可能大于nanosTimeout)。

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

 

image.png

 

 

自定义同步组件-TwinsLock

这里设计一个同步工具:该工具在同一时刻,只允许至多两个线程同时访问,超过两个线程的访问将被阻塞,我们将这个同步工具命名为TwinsLock。

访问模式

TwinsLock能够在同一时刻支持多个线程的访问,这显然是共享式访问,因此,需要使用同步器提供的acquireShared(int args)方法等和Shared相关的方法,这就要求TwinsLock必须重写tryAcquireShared(int args)方法和tryReleaseShared(int args)方法,这样才能保证同步器的共享式同步状态的获取与释放方法得以执行。

资源数

TwinsLock在同一时刻允许至多两个线程的同时访问,表明同步资源数为2,这样可以设置初始状态status为2,当一个线程进行获取,status减1,该线程释放,则status加1,状态的合法范围为0、1和2,其中0表示当前已经有两个线程获取了同步资源,此时再有其他线程对同步状态进行获取,该线程只能被阻塞。在同步状态变更时,需要使用compareAndSet(int expect,int update)方法做原子性保障。

TwinsLock

通过实现Lock接口以及组合继承AQS的子类来实现这个自定义的同步组件。一般情况下,继承AQS的子类会被定义为这个同步组件类的静态内部类。

public class TwinsLock implements Lock {
    private final Sync sync = new Sync(2);

    private static final class Sync extends AbstractQueuedSynchronizer {
        Sync(int count) {
            if (count <= 0) {
                throw new IllegalArgumentException("Count must be larger than zero!");
            }
            setState(count);
        }

        @Override
        protected int tryAcquireShared(int reduceCount) {
            for (;;) {
                int current = getState();
                int newCount = current - reduceCount;
                if (newCount < 0 || compareAndSetState(current, newCount)) {
                    return newCount;
                }
            }
        }

        @Override
        protected boolean tryReleaseShared(int returnCount) {
            for (;;) {
                int current = getState();
                int newCount = current + returnCount;
                if (compareAndSetState(current, newCount)) {
                    return true;
                }
            }
        }

        Condition newCondition() {
            return new ConditionObject();
        }
    }

    @Override
    public void lock() {
        sync.acquireShared(1);
    }

    @Override
    public void unlock() {
        sync.releaseShared(1);
    }
    // 其他接口方法略
}

在上述实例中,

  1. TwinsLock实现了Lock接口,提供了面向使用者的接口,使用者调用lock()方法获取锁,随后调用unlock()方法释放锁,而同一时刻只能有两个线程同时获取到锁。
  2. TwinsLock同时包含了一个自定义同步器Sync,而该同步器面向线程访问和同步状态控制。以共享式获取同步状态为例:同步器会先计算出获取后的同步状态,然后通过CAS确保状态的正确设置,tryAcquireShared(int reduceCount)方法返回值大于等于0时,当前线程才获取同步状态,对于上层的TwinsLock而言,则表示当前线程获得了锁。

AQS作为一个桥梁,连接线程访问以及同步状态控制等底层技术与不同并发组件(比如Lock、CountDownLatch等)的接口语义

TwinsLock测试Demo

在测试用例中,定义了工作者线程Worker,该线程在执行过程中获取锁,当获取锁之后使当前线程睡眠1秒(并不释放锁),随后打印当前线程名称,最后再次睡眠1秒并释放锁,测试代码如下:

public class TwinsLockTest {
    Lock lock = new TwinsLock();

    @Test
    public void testCase01() throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Worker w = new Worker();
            Thread thread = new Thread(w, "Worker" + i);
            thread.start();
        }

        // 每隔1秒换行
        for (int i = 0; i < 10; i++) {
            Thread.sleep(1000);
            System.out.println();
        }
    }

    class Worker implements Runnable {
        @Override
        public void run() {
            while (true) {
                lock.lock();
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
                break;
            }
        }
    }
}

运行可以发现输出结果如下,同一时刻只有两个线程能够获取到锁,这表明TwinsLock可以按照预期正确工作。

Worker0
Worker1

Worker3
Worker2

Worker4
Worker5

Worker7
Worker6

Worker9
Worker8

可重入锁ReentranLock

可重入性

可重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对 资源的重复加锁。简单地说,重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。

synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。而本文之前的例子Mutex却不支持可重入性。如果某个线程获取了锁,会在下一次获取锁时出现阻塞自己的情况。

可重入性的实现有两个问题:

  1. 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  2. 锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放

ReentrantLock是通过组合自定义继承AQS的子类以及实现Lock接口来实现锁的获取与释放,以非公平性(默认的)实现为例,获取同步状态的代码如下:

        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

该方法增加了线程再次获取同步状态(即可重入)的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。

成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值,该方法的代码如下:

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }
  1. 如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了,才能返回true。
  2. 该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功。

公平获取锁 vs 非公平获取锁

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。

上面介绍的nonfairTryAcquire(int acquires)方法,对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同,公平获取锁的代码如下所示:

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                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;
        }

该方法与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

公平与非公平获取锁的区别

  1. tryAcquire中锁获取的条件:非公平锁只要CAS设置同步状态成功,公平锁需要CAS设置同步状态成功+当前节点是同步队列首节点。
  2. 性能: 公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章