Java并发学习(四)显式锁Lock与AQS队列同步器

    在Java5之前,在协调对共享对象的访问时可以使用的机制只有synchronizedvolatile,Java5之后增加了新的机制显式锁Lock,所以我们可以使用锁(显式锁)而不是隐式锁(同步)来线程安全地编程;和隐式锁相比,显示锁更加的灵活,但这不代表隐式锁是来替代synchronized隐式内置锁的,而是当内置锁不适用时,作为一种高级功能。

Lock接口

前面的学习中我们已经了解到synchronized同步机制,synchronized同步机制在经历了偏向锁、自旋锁、轻量级锁、重量级锁、锁消除以及锁粗化等一系列的优化之后,其性能已经远远超过以前了,现在和ReentrantLock性能几乎一样。如此看来同步机制已经相当好了,那么为什么Java开发人员花了这么多时间来开发java.util.concurrent.locks显式锁框架呢?

答案很简单:同步是不错,但它并不完美。它有一些功能性的限制,下面是Lock提供的synchronized不具备的特性:

特性描述
尝试非阻塞地获取锁当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
能被中断地获取锁与synchronized不同,获取到锁的线程能被响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
超时获取锁在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回

Lock锁的使用很简单,它需要手动的获取和释放锁

Lock lock = new ReentrantLock();
lock.lock();
try { 
  // update object state
}
finally {
  lock.unlock(); 
}

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

Lock接口的实现基本都是通过聚合一个同步器的子类来完成线程访问控制的,所以在学习Lock实现类之前,有必要了解同步器类

队列同步器

队列同步器(AQS,AbstractQueuedSynchronizer),位于java.util.concurrent.locks包下,是用来构建锁或者其他同步组件的基础框架,它是Java并发包的基础工具抽象类,是实现 ReentrantLock、CountDownLatch、Semaphore、FutureTask 等锁或同步组件的基础。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步器类中状态,它管理了一个整数状态信息,可以通过以下protected方法对同步状态进行更改,且保证状态的改变是安全的。

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

整个整数状态信息可以表示任何的状态,例如,重入锁ReentrantLock用来表示所有者线程已经重复获取该锁的次数;信号量Semaphore用来表示剩余的许可数量;FutureTask用来表示任务的运行状态(尚未运行、正在运行、已完成已经已取消);随后会对以上等同步工具类详细的介绍。

AQS本身并没有实现任何同步接口,它仅仅是定义了若干同步状态的获取和释放的方法供自定义同步器使用;可以这么理解,AQS队列同步器就像一个大的展柜,里面摆放着各种用于获取/释放同步状态的方法(展品),不同类型的同步组件都可以从这里获取所需要的方法来实现自己的特定功能,但AQS并不参与方法的实际实现。

AQS是实现锁(或其他同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。

  • 锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了锁的实现细节。
  • AQS是面向锁的实现者的,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待/唤醒等底层操作,相当于锁工厂。

锁和同步器很好的隔离了使用者和实现者所需要关注的领域。

1、队列同步器的设计

队列同步器的设计是基于模板方法模式的,使用者需要继承同步器并重写其指定的方法,随后将同步器组合在在自定义的同步组件的实现中,并调用同步器提供的模板方法,而由于多态的动态绑定,这些模板方法最终会调用使用者重写的方法。

通俗的说就是,当需要设计一个锁时,由于AQS是抽象类,就需要创建一个静态的内部类去继承AQS类,重写它的模板方法,从而使得锁的使用者在锁的实例方法中能调用此方法。

除了上述提到的同步状态的操作方法外,AQS主要还提供了以下方法:

  • 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):共享式释放同步状态;

这些方法主要可分为三大类:独占式获取与释放同步状态共享式获取与释放同步状态查询同步队列中的线程等待情况

下面通过一个独占锁的例子深入了解一下AQS的工作原理:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

public class Mutex implements Lock {
    //静态内部类,继承AQS,自定义同步器,未重写的就调用AQS中的方法
    private static class Sync extends AbstractQueuedSynchronizer {
        //获取当前同步状态,为1代表占用状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
        //同步状态为0时获取锁,并将状态置为1
        public boolean tryAcquire(int acquires) {
            if (compareAndSetState(0,1)) {
                //设置当前拥有独占访问的线程。null 参数表示没有线程拥有访问。
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        //释放锁,并将状态置为0
        protected boolean tryRelease(int arg) {
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }
        //返回一个Condition,每个Condition都包含一个condition队列
        Condition newCondition() {return newCondition();}
    }
    //仅需要将操作代理在Sync上
    private final Sync sync = new Sync();
    @Override
    public void lock() {
        sync.acquire(1);//传参并调用tryAcquire方法
    }

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

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

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

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

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

上述代码中,独占锁Mutex是一个自定义同步组件,它在同一个时刻只允许一个线程占有锁。Mutex定义了一个静态内部类,该内部类继承了AQS并实现了独占锁获取和释放同步状态。在上述tryAcquire(int acquires)方法中,如果经过CAS设置成功(同步状态设置为1),则代表获取了同步状态,而在tryRelease(int arg)方法中只是将同步状态设置为0。

锁的使用者不会直接和内部同步器打交道,而是直接调用Mutex提供的方法。

2、队列同步器的实现

主要包括:同步队列、独占式同步状态的获取与释放、共享式同步状态获取与释放以及超时获取同步状态等队列同步器核心数据结构和模板方法。

同步队列

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

一个节点表示一个线程,它保存着线程的引用(thread)、等待状态(waitStatus)、前驱节点(prev)、后继节点(next)

// 头结点,即代表 当前持有锁的线程 
private transient volatile Node head;

// 阻塞的尾节点,每个新的节点进来,都插入到最后,也就形成了一个链表
private transient volatile Node tail;

// 这个是最重要的,代表当前锁的状态,0代表没有被占用,大于 0 代表有线程持有当前锁
// 这个值可以大于 1,是因为锁可以重入,每次重入都加上 1
private volatile int state;

// 代表当前持有独占锁的线程,举个最重要的使用例子,因为锁可以重入
// reentrantLock.lock()可以嵌套调用多次,所以每次用这个来判断当前线程是否已经拥有了锁
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer

同步队列的基本结构图:

AQS队列同步器中包含了两个节点类型的引用(只是引用,并不创建真的节点),如上图所示,阻塞队列中是不包含head头结点的,因为头结点代表真正执行的获得锁的线程,而尾结点tail是包括的;

入列:

在竞争锁的过程中,当一个线程获取到锁(或同步状态),其他线程将无法获取同步状态,转而被构造成为节点并加入到同步队列中,而这个加入到同步队列的尾结点的过程必须是保持线程安全的,因此AQS提供了一个基于CAS设置尾结点的方法:compareAndSetTail(Node except,Node update),该方法可以确保节点是线程安全添加的。

但只有设置成功后,当前节点才会和之前的尾结点建立关联,即当前节点成为新的尾结点tail

出列:

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

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

通过调用AQS的acquire(int arg)模板方法,该方法为独占式获取同步状态,但是该方法对中断不敏感,也就是说由于线程获取同步状态失败加入到CLH同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移除。

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

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

  • tryAcquire:去尝试获取锁,获取成功则设置锁状态并返回true,且&&的短路性质导致无须在进行后续操作,否则返回false。该方法自定义同步组件自己实现,该方法必须要保证线程安全的获取同步状态。
  • addWaiter:如果tryAcquire返回FALSE(获取同步状态失败),则调用该方法将当前线程加入到CLH同步队列尾部。
  • acquireQueued:当前线程会根据公平性原则来进行阻塞等待(自旋),直到获取锁为止;并且返回当前线程在等待过程中有没有中断过。acquireQueued方法为一个自旋的过程,也就是说当前线程(Node)进入同步队列后,就会进入一个自旋的过程,每个节点都会自省地观察,当条件满足,获取到同步状态后,就可以从这个自旋过程中退出,否则会一直执行下去。
  • selfInterrupt:产生一个中断。
private Node addWaiter(Node mode) {
        //将竞争锁失败的线程构造成一个节点
        Node node = new Node(Thread.currentThread(), mode);
        Node pred = tail;
        //判断同步队列当前线程认为的“尾结点”是否为空
        if (pred != null) {
            node.prev = pred;//不为空,将当前尾结点设置为此节点的前驱节点
            if (compareAndSetTail(pred, node)) {//CAS设置此节点为tail尾结点
                pred.next = node;//成功后将之前的尾结点的下一节点设置为此节点
                return node;
            }
        }
        enq(node);//如果当前的尾结点为空或设置尾结点失败,调用end方法
        return node;
}

private Node enq(final Node node) {
        for (;;) {//自旋保证结点的正确添加
            Node t = tail;
            if (t == null) { //CAS操作实现初始化
                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; //断开原头结点,以便GC
                    failed = false;
                    return interrupted;
                }
                //获取失败,线程等待--具体后面介绍
                if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

在上述代码中,只有前驱节点是头节点才能够获取同步状态,这是因为:

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

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

独占式同步状态获取流程,也是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();//唤醒后继节点
            return true;
        }
        return false;
 }

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

 整个独占式同步状态的获取与释放过程概括来说就是:在获取同步状态时,队列同步器AQS维护一个FIFO同步队列,获取状态失败的线程都会加入到队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态是,同步器调用tryRelease(int arg)释放同步状态,然后唤醒头节点的后继节点。

独占式超时获取同步状态

除了以上独占式获取同步状态的基本过程,AQS还为我们提供了功能更强大的方法;通过调用同步器的tryAcquireNanos(int arg,long nanos)方法可以超时获取同步状态,即在指定的时间获取同步状态则返回true,否则返回false;

 public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        return tryAcquire(arg) ||
            doAcquireNanos(arg, nanosTimeout);
}

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

而超时获取同步状态过程,不仅包括了acquireInterruptibly方法响应中断的功能,还提供了超时获取的特性,可看做是acquireInterruptibly方法的增强版。其中超时获取实现方法doAcquireNanos(int arg, long nanosTimeout)如下:

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        //nanosTimeout <= 0
        if (nanosTimeout <= 0L)
            return false;
        //超时时间
        final long deadline = System.nanoTime() + nanosTimeout;
        //新增Node节点
        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();
                //已经超时,返回false
                if (nanosTimeout <= 0L)
                    return false;
                //如果没有超时,则等待nanosTimeout纳秒
                //注:该线程会直接从LockSupport.parkNanos中返回,
                //LockSupport为JUC提供的一个阻塞和唤醒的工具类
                if (shouldParkAfterFailedAcquire(p, node) &&
                        nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                //线程是否已经中断了
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

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

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

     共享式获取与独占式获取的最主要区别在于同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

通过调用AQS提供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; 
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                        parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

从上面程序可以看出,方法首先是调用tryAcquireShared(int arg)方法尝试获取同步状态,如果获取失败则调用doAcquireShared(int arg)自旋方式获取同步状态,共享式获取同步状态成功并退出自旋的标志是tryAcquireShared方法的返回值大于等于0

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

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

该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。它和独占式的主要区别是:由于可能会存在多个线程同时进行释放同步状态资源,所以需要确保同步状态(或者资源)安全地成功释放,一般都是通过CAS和循环来完成的。

总结

经过上面的学习我们已经了解到,队列同步器AbstractQueuedSynchronizer基本的实现过程,一个自定义的同步组件(一个自定义的锁)的实现大概可分为以下步骤:

  1. 确定同步工具的访问模式,共享式或是独占式
  2. 定义资源数,即定义一个整型数的合法取值,一般为线程获取锁加1,释放锁减1
  3. 组合自定义同步器,使用静态内部类Sync继承AQS重写我们设计的锁所需要的对应方法

其实Java中的重入锁ReentrantLock和读写锁ReentrantReadWriteLock等都是在此基础上实现的,之后的学习会详细对源码进行剖析。

最后,为了更好的理解队列同步器的强大,可以自己设计一个锁,并利用AQS实现它的功能。

 

 

参考文章:

《Java并发编程的艺术》

《Java并发编程实践》

《Java核心技术 卷1》

  https://javadoop.com/2017/06/16/AbstractQueuedSynchronizer/

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值