15.AQS的今生,构建出JUC的基础

简介: 大家好,我是王有志。今天我们接着学习AQS的部分,这次我们深入Doug Lea的设计,来探究AQS是如何通过“变种”CLH构建出JUC框架基础的。

大家好,我是王有志,欢迎和我聊技术,聊漂泊在外的生活。快来加入我们的Java提桶跑路群:共同富裕的Java人

AQS的前世,从1990年的论文说起》中我们已经对AQS做了简单的介绍,并学习了先于AQS出现的3种基于排队思想的自旋锁。今天我们深入到AQS的设计中,探究Doug Lea是如何构建JUC框架基础组件的。不过在正式开始前,我们先来回顾上一篇中提到的面试题:

  • 原理相关:AQS是什么?它是怎样实现的?
  • 设计相关:如何使用AQS实现Mutex?

希望今天可以帮你解答上面的问题。

Tips

初衷与目的

《The java.util.concurrent Synchronizer Framework》中清晰的阐述了Doug Lea设计AQS的目的:

This framework provides common mechanics for atomically managing synchronization state, blocking and unblocking threads, and queuing.

(AQS)框架为同步状态的原子性管理,线程的阻塞和唤醒以及排队提供了一种通用的机制。也就是说,可以通过AQS去构建不同的同步器,如:基于AQS而诞生的ReentrantLock

基于构建通用同步机制的目的,Doug Lea分析了各种同步器,总结出它们共同的特性:

  • acquire操作:阻塞调用线程,直到同步状态允许其继续执行;
  • release操作:改变同步状态,唤醒被阻塞的线程。

除此之外,论文中也提到了对AQS的性能要求,Doug Lea认为大家在分析synchronized时提到的2个问题:

  • 如何最小化空间开销(因为任意Java对象都可以作为锁)
  • 如何最小化单核处理器的单线程环境下的时间开销

都不是AQS要考虑的,他认为AQS需要考虑的是scalability(可伸缩性),即大部分场景中,即便存在竞争,也能提供稳定的效率。原文中是这样描述的:

Among the main goals is to minimize the total amount of time during which some thread is permitted to pass a synchronization point but has not done so.

(AQS)主要目标之一是使某一线程被允许通过同步点但还没有通过的情况下耗费的总时间最少,即从一个线程释放锁开始,到另一个线程获取锁,这个过程锁消耗的时间。

设计与实现

Doug Lea先是完成了acquire操作和release操作的伪代码设计:

// acquire操作
while (synchronization state does not allow acquire) {
  enqueue current thread if not already queued;
  possibly block current thread;
}
dequeue current thread if it was queued;

// release操作
update synchronization state;
if (state may permit a blocked thread to acquire)
  unblock one or more queued threads;

为了实现上述的操作,需要以下组件的协同工作:原子管理的同步状态,线程的阻塞与唤醒,以及队列

同步状态

AQS使用volatile修饰的int类型变量state保存同步状态,并提供getStatesetStatecompareAndSetState方法。

AQS中,state不仅用作表示同步状态,也是某些同步器实现的计数器,如:Semaphore中允许通过的线程数量,ReentrantLock中可重入特性的实现,都依赖于state作为计数器的特性。

早期,Java对long类型变量的原子操作需要借助内置锁来完成,性能较差,并且除了CyclicBarrier外,其余同步器使用32位的int类型已经能够满足需求,因此在AQS诞生初期,state可以使用int类型。

Tips

  • CyclicBarrier通过锁来实现;
  • Java 1.6中提供了使用long类型的AbstractQueuedLongSynchronizer
  • 注意要区别同步状态与线程在队列中的状态。

阻塞与唤醒

早期,线程的阻塞与唤醒只能通过Thread.suspendThread.resume实现,但存在竞态问题,即一个线程先调用了Thread.resume后调用Thread.suspend,那么Thread.resume不会产生任何作用。

AQS使用LockSupport.parkLockSupport.unpark实现阻塞与唤醒,特点是如果LockSupport.unpark发生在LockSupport.park前,则此次的LockSupport.park无效。

Tips:无论提前调用多少次LockSupport.unpark,都只会使后一次LockSupport.park无效。

CLH队列

队列的设计是构建AQS的关键,Doug Lea在论文中使用“The heart of”来形容:

The heart of the framework is maintenance of queues of blocked threads, which are restricted here to FIFO queues.

Doug Lea参考了CLH的设计, 保留了基本的设计,由前驱节点做阻塞与唤醒的控制,但是在队列的选择上做出了改变,AQS选择双向链表来实现队列,节点中添加了prevnext指针。

添加prev指针主要是为了实现取消功能,而next指针的加入可以方便的实现唤醒后继节点。

AQS源码分析

再次强调,本文基于Java 11完成,与Java 8的源码存在差异,如,操作同步状态state时,Java 8借助了UnSafe,而Java 11中使用了VarHandle。另外,本文只讨论AQS的独占(EXCLUSIVE)模式因此会跳过共享(SHARED)模式

队列的结构

有了《AQS的前世,从1990年的论文说起》的铺垫,再结合Doug Lea论文中的描述,我们可以很容易想象到AQS中队列节点的结构:线程状态,前驱节点指针,后继节点指针以及用于保存线程的变量。事实也和我们的猜想十分接近:

static final class Node {
  volatile int waitStatus;
  volatile Node prev;
  volatile Node next;
  volatile Thread thread;
  Node nextWaiter;
}

注意,NodewaitStatus表示线程在队列中的状态,AQS的state表示同步器的状态。Node中定义了waitStatus的5种状态:

  • CANCELLED:1,线程获取锁的请求已经取消;
  • SIGNAL :-1,节点释放后,需要唤醒后继节点
  • CONDITION:-2,节点处于条件队列中;
  • PROPAGATE:-3,共享(SHARED)模式下使用;
  • 0,初始化Node时的默认值。

AQS的实现中,并不是后继节点“监听”前驱节点的状态,来决定自身是否持有锁,而是通过前驱节点释放锁,并主动唤醒后继节点来实现排队的

AQS的结构

AQS的结构就更加简单了:

private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;

static final long SPIN_FOR_TIMEOUT_THRESHOLD = 1000L;

总共4个成员变量,除了我们意料之中的,队列的头尾节点和AQS的同步状态外,还有SPIN_FOR_TIMEOUT_THRESHOLD。看名字会有些误解,以为是自旋的阈值,实际上并不是,AQS提供了带有超时时间的方法,例如doAcquireNanos方法:

private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException {
  final long deadline = System.nanoTime() + nanosTimeout;
  final Node node = addWaiter(Node.EXCLUSIVE);
  for (;;) {
    final Node p = node.predecessor();
    nanosTimeout = deadline - System.nanoTime();
    if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > SPIN_FOR_TIMEOUT_THRESHOLD) {
      LockSupport.parkNanos(this, nanosTimeout);
    }
  }
}

可以看到只有在剩余的nanosTimeout大于SPIN_FOR_TIMEOUT_THRESHOLD时,才会调用LockSupport.parkNanos(this, nanosTimeout)

Tips

  • Java 11中,无论是AQS还是Node中都使用了VarHandle,定义了大量的成员变量,我们跳过这部分;
  • 删除了doAcquireNanos方法中大部分内容,重点展示nanosTimeoutSPIN_FOR_TIMEOUT_THRESHOLD的关系。

获取锁

如果是你,你会如何设计AQS的加锁过程?我可能会“按部就班”的构建队列,并将等待线程逐个的加入的队列中。那Doug Lea是如何设计AQS加锁过程的呢?

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

acquire方法中,Doug Lea设计了4步操作,如果仅从名字来看,首先tryAcquire尝试获取锁,如果获取失败,则通过addWaiter加入等待,然后调用acquireQueued方法进入排队状态,最后是通过调用selfInterrupt方法使当前线程中断。先来看AQS中的tryAcquire方法。

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

AQS中并未给出任何实现,它要求子类必须重写tryAcquire方法,否则抛出异常。

addWaiter方法

接着是addWaiter方法:

private Node addWaiter(Node mode) {
  // 注释1:创建节点,通过acquire进入时mode = Node.EXCLUSIVE
  Node node = new Node(mode);
  for (;;) {
    // 注释2:获取尾节点
    Node oldTail = tail;
    if (oldTail != null) {
      // 注释5:添加新的尾节点
      node.setPrevRelaxed(oldTail);
      if (compareAndSetTail(oldTail, node)) {
        oldTail.next = node;
        return node;
      }
    } else {
      // 注释3:尾节点为空则初始化队列
      initializeSyncQueue();
    }
  }
}

static final class Node {
  Node(Node nextWaiter) {
    this.nextWaiter = nextWaiter;
    // 可以看做是:this.thread = Thread.currentThread()
    THREAD.set(this, Thread.currentThread());
  }
}

private final void initializeSyncQueue() {
  Node h;
  // 注释4:创建空节点,作为尾节点
  if (HEAD.compareAndSet(this, null, (h = new Node())))
    tail = h;
}

addWaiter的逻辑并不复杂:

  • 注释1:创建节点node
  • 注释2:获取AQS的尾节点oldTail,并判断是否存在尾节点;
  • 注释3:初始化队列;
  • 注释4:创建空节点h,作为AQS的头尾节点;
  • 注释5:更新AQS的尾节点为node,并与oldTail关联。

我们知道,只有tryAcquire失败后,才会调用addWaiter方法,也就是说,如果实现了tryAcquire获取锁的逻辑,那么在没有竞争的场景下,AQS就不会构建等待队列

回过头来看addWaiter做了什么?它的核心功能是初始化的等待队列,并返回当前队列的尾节点

acquireQueued方法

addWaiter创建了等待队列并返回尾节点后,就进入了acquireQueued方法中:

final boolean acquireQueued(final Node node, int arg) {
  // 是否中断的标记
  boolean interrupted = false;
  try {
    for (;;) {
      // 注释1:获取node的前驱节点,node更名为currentNode更方便我理解
      final Node p = node.predecessor();
      if (p == head && tryAcquire(arg)) {
        setHead(node);
        p.next = null;
        return interrupted;
      }

      // 注释2:判断是否需要park当前线程
      if (shouldParkAfterFailedAcquire(p, node))
        interrupted |= parkAndCheckInterrupt();
    }
  } catch (Throwable t) {
    cancelAcquire(node);
    if (interrupted)
      selfInterrupt();
    throw t;
  }
}

注释1的部分,获取到node的前驱节点p,如果p为头节点,则当前线程直接通过tryAcquire尝试获取锁。如果p不是头节点的话可以直接调用tryAcquire吗?

答案是不可以,如果p不是头节点,也就证明当前线程不在获取锁的第二顺位上,前面可能还有若干节点在等待锁,如果任意节点都直接调用tryAcquire,那就失去了acquireQueued方法的意义。

注释2的部分,p不是头节点的情况,也就是当前线程非第二顺位获取锁。那么node就要根据前驱节点的状态来判断是否中断执行了:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
  // 获取前驱节点的状态,waitStatus初始化的状态为0
  int ws = pred.waitStatus;
  if (ws == Node.SIGNAL)
    // 注释2:前驱节点处于Node.SIGNAL状态
    return true;
  if (ws > 0) {
    do {
      node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;
  } else {
    // 注释1:更新前驱节点的状态为Node.SIGNAL
    pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
  }
  return false;
}

private final boolean parkAndCheckInterrupt() {
  // 暂停线程
  LockSupport.park(this);
  return Thread.interrupted();
}

addWaiter的流程中可以看到,处理node的过程中并没有处理node.waitStatus,此时waitStatus == 0,那么对于node的前驱节点pred也是一样的,因此第一次执行shouldParkAfterFailedAcquire方法时,会进入注释1的部分,并返回false

再次进入acquireQueued的循环后,shouldParkAfterFailedAcquire返回true,执行parkAndCheckInterrupt方法,需要注意LockSupport.park(this)会让线程暂停在此处,也就是说如果没有线程唤醒,线程会一直停留在此处

至此,AQS的加锁过程已经结束了,我们画张图来回顾下这个过程:

图1:AQS加锁流程.png

释放锁

接着来看解锁的过程:

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

按照AQS的风格tryRelease必然是要交给子类实现的:

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

果不其然。假设tryRelease执行成功,接下来会发生什么?

  • 获取头节点h;
  • 判断头节点的状态h.waitStatus != 0
  • 执行unparkSuccessor

来看unparkSuccessor的代码:

private void unparkSuccessor(Node node) {
  // node是当前线程的前驱节点,也是head节点
  int ws = node.waitStatus;
  if (ws < 0)
    // 处理node的waitStatus
    node.compareAndSetWaitStatus(ws, 0);
  Node s = node.next;
  // 注释2:从后向前遍历待唤醒的节点
  if (s == null || s.waitStatus > 0) {
    s = null;
    for (Node p = tail; p != node && p != null; p = p.prev)
      if (p.waitStatus <= 0)
        s = p;
  }

  // 注释1:唤醒后继节点
  if (s != null)
    LockSupport.unpark(s.thread);
}

如果一切顺利,那么unparkSuccessor时会跳过注释2的部分,直接执行注释1的LockSupport.unpark

不过别忘了,待唤醒的线程此时还在acquireQueued方法中阻塞着,唤醒的线程会继续执行acquireQueued中的内容,调用tryAcquire获取锁,并更新AQS的头节点

我们设想一个场景:

图2:unparkSuccessor的时机.png

addWaiter执行到compareAndSetTail(oldTail, node)时调用了unparkSuccessor,可能会出现一种情况:

图3:unparkSuccessor的特殊情况.png

即T1已经与HEAD建立了联系,但HEAD却没有与T1建立联系。因此注释2中,判断HEAD节点没有后继节点时,会通过TAIL节点,从后向前遍历等待队列,查找待唤醒的线程。

好了,AQS的核心源码分析到这里就结束了,至于条件队列,共享模式等就留给大家自行探索了。

构建互斥锁

学习完AQS的核心原理后,我们来实践一下,借助AQS来构建构建互斥锁:

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

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

  private final Sync sync = new Sync();

  static class Sync extends AbstractQueuedSynchronizer {

    @Override
    protected boolean tryAcquire(int arg) {
      Thread currentThread = Thread.currentThread();
      if(compareAndSetState(0, arg)) {
        setExclusiveOwnerThread(currentThread);
        return true;
      }else {
        return false;
      }
    }

    @Override
    protected boolean tryRelease(int arg) {
      if(getState() != 1) {
        return false;
      }
      setState(arg);
      return true;
    }
  }
}

通过AQS实现只有基础功能的互斥锁还是非常简单的,甚至在重写tryAcquire方法时可以不设置独占线程(虽然在现在也没起到作用),只是简单的使用CAS替换掉AQS的state即可:

@Override
protected boolean tryAcquire(int arg) {
  return compareAndSetState(0, arg);
}

当然了,这只是一把“玩具锁”,还存在许多问题,比如,非上锁线程依旧可以解锁。其次除了阻塞还排队外,也不支持诸如可重入等高级特性。

结语

好了,关于AQS的部分就到这里了。如果你有看过《AQS的前世,从1990年的论文说起》中基于排队思想自旋锁的演进过程,并理解了MCS锁和CLH锁的实现,那么理解AQS对你来说是非常容易的,虽然它们看起来是不同的东西,但核心原理是相同的,只是在技术实现上有些许差别。

最后,希望通过AQS的前世和今生,能够帮助你重新认识AQS,理解Doug Lea设计这样一个同步器基础组件的意义。


好了,今天就到这里了,Bye~~

目录
相关文章
|
Cloud Native Devops 持续交付
云原生与 DevOps
云原生与 DevOps
843 0
|
6月前
|
人工智能 供应链 安全
MCP Server的五种主流架构与Nacos的选择
本文深入探讨了Model Context Protocol (MCP) 在企业级环境中的部署与管理挑战,详细解析了五种主流MCP架构模式(直连远程、代理连接远程、直连本地、本地代理连接本地、混合模式)的优缺点及适用场景,并结合Nacos服务治理框架,提供了实用的企业级MCP部署指南。通过Nacos MCP Router,实现MCP服务的统一管理和智能路由,助力金融、互联网、制造等行业根据数据安全、性能需求和扩展性要求选择合适架构。文章还展望了MCP在企业落地的关键方向,包括中心化注册、软件供应链控制和安全访问等完整解决方案。
3025 158
MCP Server的五种主流架构与Nacos的选择
|
6月前
|
JSON 安全 Serverless
MCP Server 之旅第 2 站: 从 0 到 1 - MCP Server 市场构建与存量 OpenAPI 转 MCP Server
本文聚焦MCP协议在企业应用中的两大核心痛点:如何将社区主流STDIO MCP Server一键转为可插拔Remote MCP Server,以及如何实现存量OpenAPI向MCP Server的智能化转型。文章通过具体示例,展示了基于函数计算和协议转译Adapter的解决方案,支持npm/pip生态,实现零改造一键迁移,大幅降低成本。
|
4月前
|
人工智能 Java API
后端开发必看:零代码实现存量服务改造成MCP服务
本文介绍如何通过 **Nacos** 和 **Higress** 实现存量 Spring Boot 服务的零代码改造,使其支持 MCP 协议,供 AI Agent 调用。全程无需修改业务代码,仅通过配置完成服务注册、协议转换与工具映射,显著降低改造成本,提升服务的可集成性与智能化能力。
1212 1
|
存储 安全 Java
String、StringBuffer 和 StringBuilder 的区别
【10月更文挑战第21天】String、StringBuffer 和 StringBuilder 都有各自的特点和适用场景。了解它们之间的区别,可以帮助我们在编程中更合理地选择和使用这些类,从而提高程序的性能和质量。还可以结合具体的代码示例和实际应用场景,进一步深入分析它们的性能差异和使用技巧,使对它们的理解更加全面和深入。
531 57
|
存储 缓存 Java
大厂面试高频:Volatile 的实现原理 ( 图文详解 )
本文详解Volatile的实现原理(大厂面试高频,建议收藏),涵盖Java内存模型、可见性和有序性,以及Volatile的工作机制和源码案例。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:Volatile 的实现原理 ( 图文详解 )
|
安全 搜索推荐 Android开发
安卓与iOS:两大操作系统的比较
本文将深入探讨安卓和iOS两大操作系统的差异,包括它们的设计理念、用户界面、应用生态以及安全性等方面。通过对比分析,我们可以更好地理解这两个系统各自的优势和不足,从而为用户在选择手机时提供一些参考。
|
人工智能 大数据 区块链
|
存储 缓存 Kubernetes
在K8S中,Pod优雅终止过程是什么?
在K8S中,Pod优雅终止过程是什么?
|
网络协议 网络性能优化 数据安全/隐私保护
IPV4与IPV6之间的区别
IPv4(32位,42.9亿地址)面临枯竭,促成了IPv6(128位,近乎无限地址)的诞生。IPv6增强安全性,提供身份验证,使用灵活的ICMPv6和SLAAC配置地址,其十六进制表示法区别于IPv4的点分十进制。IPv6还优化了数据包处理,包含Flow Label以提升服务质量,使用AAAA记录进行DNS映射。随着需求增长,IPv6正逐步成为标准。