面试官:AQS队列同步器和Java中常用的锁讲解下

884 阅读27分钟

队列同步器的实现分析

同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取 同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其 加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再 次尝试获取同步状态。

同步队列中的节点(Node)

用来保存获取同步状态失败的线程引用、等待状态以及前驱和 后继节点,节点的属性类型与名称以及描述。

同步队列基本结构

节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会被构造成节点加入该队列的尾部。

同步队列的基本结构如下图

节点加入到同步队列

加入队列的过程必须要保证线程安全,因此 同步器提供了一个基于CAS的设置尾节点法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式 与之前的尾节点建立关联。

同步器将节点加入到同步队列的过程如下:

首节点设置

同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态 时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点

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

同步状态获取

同步器的acquire方法

通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是 由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同 步队列中移出.

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

上述代码主要完成了以下工作:

  1. 同步状态的获取
  • 调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态
  1. 节点的构造
  • 如果同步状态获取失败,则构造同步节点
  • 独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态
  1. 加入同步队列
  • 通过addWaiter(Node node)方法将该节点加入到同步队列的尾部
  1. 在同步队列中自旋
  • 调用acquireQueued(Node node,int arg)方法,使得该 节点以“死循环”的方式获取同步状态

  • 如果获取不到则阻塞节点中的线程,而被阻塞线程的 唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现

下面分析一下相关工作:

同步器的addWaiter和enq方法

构造节点并通过CAS加入同步队列

/**
* 创建一个节点,并将该节点 插入队列
*/

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)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

/**
* enter queue 进入队列,顾名思义将节点插入队列
*
* 同步器通过“死循环”来保证节点的正确添加,在“死循 环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从方 * 法返回,否则,当前线 程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过CAS变  * 得“串行化”了
*/

private Node enq(final Node node) 
    for (;;) {
        Node t = tail;
        if (t == null) { // 如果当前队列的tail为null,则意味着队列为空,则需要初始化队列
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) { // 队列不为空时,将节点插入队尾
                t.next = node;
                return t;
            }
        }
    }
}

同步器的acquireQueued方法

节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自 省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这 个自旋过程中(并会阻塞节点的线程)

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {// 通过自旋(死循环)不断自身
            final Node p = node.predecessor();
              // 当且仅当此节点的前驱节点是首节点(head)时,才会去获取同步状态
            if (p == head && tryAcquire(arg)) { 
                setHead(node);// 更新 首节点(head)
                  // 手动将更新前的首节点的后置节点置为null,即脱离队列, 有助于垃圾回收
                p.next = null// help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状

态,而只有前驱节点是头节点(head)时,才回去尝试获取同步状态(tryAcquire),这是为什么?原因有两个,如下:

  1. 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点
  2. 维护同步队列的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(h); // 唤醒头节点的后继节点线程
        return true;
    }
    return false;
}
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus; // 当前节点的等待状态
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
      // 如果后继节点为空或状态为CANCELED(1)
    if (s == null || s.waitStatus > 0) {
        s = null;
          // 从同步队列的尾部开始向头部遍历
        for (Node t = tail; t != null && t != node; t = t.prev)
              // 找到离头部最近的非CANCELED状态的节点作为实际后继节点
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
          // 唤醒处于阻塞状态的线程thread
        LockSupport.unpark(s.thread);
}
/**
* 当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作
* Park有停车的意思,假设线程为车辆,那么park方法代表着停 车,而unpark方法则是指车辆启动离开
*/

public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

总结

在获取同步状态时,同步器维 护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列 (或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用·tryRelease(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) {
                  // tryAcquireShared(int arg)方法返回值为int类型,
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                      // 当返回值大于等于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);
    }
}

同步状态的释放

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

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

重入锁

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对 资源的重复加锁

ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方 法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

重入性演示

class T09_reentrant {
  private static Lock lock = new ReentrantLock();

  public static void main(String[] args) {
    m1();
  }

  static void m1() {
    lock.lock();
    try {
      for(int i = 0; i < 3; i++) {
        TimeUnit.SECONDS.sleep(1);
        if(i == 2) m2();
        System.out.println(i);
      }
    } catch(InterruptedException e) {
      e.printStackTrace();
    } finally {
      lock.unlock();
    }
  }

  static void m2() {
    lock.lock();
    System.out.println("m2 invoked ");
    lock.unlock();
  }
}
0
1
m2 invoked 
2

重入性的实现

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实 现需要解决以下两个问题:

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

ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认的)实现为例

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

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
      // 如果getState()返回0表示, 同步状态未被占用
    if (c == 0) { 
          // CAS更新 AQS的 state 值
        if (compareAndSetState(0, acquires)) { 
              // 设置获取同步状态的线程, 后续是否可重复进入的判断依据
            setExclusiveOwnerThread(current); 
            return true;
        }
    }
      // 当前线程是不是获取同步状态成功的线程
    else if (current == getExclusiveOwnerThread()) { 
          // 如果是 同步状态值加上acquires(一般为1)
        int nextc = c + acquires; 
        if (nextc < 0// overflow
            throw new Error("Maximum lock count exceeded");
          // 更新AQS的同步状态 state
        setState(nextc);
        return true;
    }
    return false;
}

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

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

公平锁与非公平锁

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

fair vs unfair 获取锁的区别

对于非公平锁,只要CAS设置 同步状态成功,则表示当前线程获取了锁,而公平锁则不同

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

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        /** 公平锁获取同步状态时多了一步: {@link #hasQueuedPredecessors()} ()}*/
        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;
}
/**
* 查看队列中是否有其它节点包裹的线程等待时间超过当前线程
*/

public final boolean hasQueuedPredecessors() {
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

公平锁与非公平锁 测试

  private static ReentrantLock fairLock = new ReentrantLock(true);
  private static ReentrantLock unFairLock = new ReentrantLock(false);

  public static void main(String[] args) {
//    testLock(fairLock);
    testLock(unFairLock);
  }

  private static void testLock(ReentrantLock lock) {
    for(int i = 0; i < 5; i++) {
      new Thread(() -> {
        for(int j = 0; j < 2; j++) {
          try {
            lock.lock();
            printLockAndWaiting(lock);
            TimeUnit.SECONDS.sleep(1);
          } catch(InterruptedException e) {
            e.printStackTrace();
          } finally {
            lock.unlock();
          }
        }
      }, " " + i).start();
    }
  }

  private static void printLockAndWaiting(ReentrantLock lock) {
    try {
      Field sync = lock.getClass().getDeclaredField("sync");
      sync.setAccessible(true);
      AbstractQueuedSynchronizer aqs = (AbstractQueuedSynchronizer) sync.get(lock);
      Collection<Thread> queuedThreads = aqs.getQueuedThreads();
      System.out.print("Lock by:" + Thread.currentThread().getName());
      System.out.print(", Waiting by: [");
      Collections.reverse((List<?>) queuedThreads);
      queuedThreads.stream().forEach(thread -> {
        System.out.print(thread.getName() + ",");
      });
      System.out.print("]");
      System.out.println("\r");
    } catch(NoSuchFieldException | IllegalAccessException e) {
      e.printStackTrace();
    }
  }
Fair(公平锁) UnFari(非公平锁)
Lock by: 0, Waiting by: [ 1, 2, 3, 4,] Lock by: 0, Waiting by: [ 1, 2, 3, 4,]
Lock by: 1, Waiting by: [ 2, 3, 4, 0,] Lock by: 1, Waiting by: [ 2, 3, 4, 0,]
Lock by: 2, Waiting by: [ 3, 4, 0, 1,] Lock by: 2, Waiting by: [ 3, 4, 0, 1,]
Lock by: 3, Waiting by: [ 4, 0, 1, 2,] Lock by: 3, Waiting by: [ 4, 0, 1, 2,]
Lock by: 4, Waiting by: [ 0, 1, 2, 3,] Lock by: 3, Waiting by: [ 4, 0, 1, 2,]
Lock by: 0, Waiting by: [ 1, 2, 3, 4,] Lock by: 4, Waiting by: [ 0, 1, 2,]
Lock by: 1, Waiting by: [ 2, 3, 4,] Lock by: 4, Waiting by: [ 0, 1, 2,]
Lock by: 2, Waiting by: [ 3, 4,] Lock by: 0, Waiting by: [ 1, 2,]
Lock by: 3, Waiting by: [ 4,] Lock by: 1, Waiting by: [ 2,]
Lock by: 4, Waiting by: [] Lock by: 2, Waiting by: []

观察上表所示的结果(其中每个数字代表一个线程),公平性锁每次都是从同步队列中的
第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。

为什么会出现线程连续获取锁的情况呢?回顾nonfairTryAcquire(int acquires)方法,当一
个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获
取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。

读写锁

之前提到锁(如ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线
程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读
线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写
锁,使得并发性相比一般的排他锁有了很大提升。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写
的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是
ReentrantReadWriteLock

读写锁的使用

public class ReadWriteLockTest {
  private static int value = 0;
  private static ReentrantLock lock = new ReentrantLock();

  private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
  private static Lock writeLock = readWriteLock.writeLock();
  private static Lock readLock = readWriteLock.readLock();

  static void read(Lock lock) {
    lock.lock();
    try {
      TimeUnit.SECONDS.sleep(1);
      System.out.println("readOver");
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      lock.unlock();
    }
  }

  static void write(Lock lock, int newValue) {
    lock.lock();
    try {
      TimeUnit.SECONDS.sleep(1);
      System.out.println("write over");
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      lock.unlock();
    }
  }

  public static void main(String[] args) {
    Runnable writeR = () -> write(lock, new Random().nextInt(100));
    Runnable readR = () -> read(lock);

//    Runnable writeR = () -> write(writeLock, new Random().nextInt(100));
//    Runnable readR = () -> read(readLock);

    for (int i = 0; i < 18; i++) new Thread(readR).start();
    for (int i = 0; i < 2; i++) new Thread(writeR).start();
  }
}

Condition接口

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()
wait(long timeout)notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以
实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等
待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。

Condition接口示例

public class ConditionTest {
  static Lock lock = new ReentrantLock();
  static Condition c1 = lock.newCondition();

  public static void main(String[] args) {
    new Thread(() -> {
      lock.lock();
      try {
        System.out.println("thread-1 start");
        c1.await();
        System.out.println("thread-1 end");
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }).start();

    new Thread(() -> {
      lock.lock();
      try {
        System.out.println("thread-2 start");
        c1.signal();
        System.out.println("thread-2 end");
      } finally {
        lock.unlock();
      }
    }).start();
  }
}
/** 
* 输出结果:
* thread-1 start
* thread-2 start
* thread-2 end
* thread-1 end
*/

如示例所示,一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会
释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程
才从await()方法返回,并且在返回前已经获取了锁。

Condition接口实现分析

等待队列

每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是
Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会
释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点
的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类
AbstractQueuedSynchronizer.Node

一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点
lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部
加入等待队列, 而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可

同步队列 vs 等待队列

Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列。


Condition的实现是同步器的内部类,因此每个Condition实例都能够访问同步器
提供的方法,相当于每个Condition都拥有所属同步器的引用

等待

调用Conditionawait()方法(或者以await开头的方法),会使当前线程进入等待队列并释
放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition
关联的锁

如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同
步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。

  1. 将当前线程构造成节点并加入等待队列中
    • 调用该方法的线程成功获取了锁的线程,也就是同步队列中的首节点
  2. 释放同步状态
  3. 唤醒同步队列中的后继节点
  4. 当前线程会进入等待状态

通知

调用Conditionsignal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点firstWaiter),在唤醒节点之前,会将节点移到同步队列中。