【Java并发编程的艺术】【学习笔记】并发基础

本文深入探讨了Java并发编程中的AQS(AbstractQueuedSynchronizer)机制,包括同步队列、独占式和共享式同步状态的获取与释放。AQS是构建锁和其他同步组件的基础,通过模板方法模式简化了同步状态管理。同时,文章还介绍了CAS(Compare And Swap)原子操作的实现原理,讨论了其在Java中的应用和潜在问题。
摘要由CSDN通过智能技术生成

2、并发基础

2.1、AQS

​ 队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)希望他能够成为实现大部分同步需求的基础。

​ 同步器的设计是基于模板方法模式的,主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()、getState(int new State)和compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器即可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件。

​ 同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并向访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。

同步器可重写的方法:

方法名称描述
tryAcquire(int arg)独占式获取同步状态,实现该方法需要查询当前状态(getState()获取当前同步状态)并判断同步状态是否符合预期,然后在进行CAS(compareAndSetState(int expect,int update))设置同步状态
tryRelease(int arg)独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态
tryAcquireShared(int arg)共享式获取同步状态,返回大于等于0的值,表示获取成功
tryReleaseShared(int arg)共享式释放同步状态
isHeldExclusively()当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所占用

实现自定义同步组件(自定义锁)时,将会调用同步器提供的模板方法:

方法名称描述
acquire(int arg)独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)方法
acquireInterruptibly(int arg)与acquire(int arg)相同,但是该方法响应中断,当前线程未获取到同步状态而进入同步队列中,如果当前线程被中断,则该方法会抛出InterruptedExecption并返回
tryAcquireNanos(int arg, long nanos)在acquireInterruptibly(int arg)基础上增加了超时限制,如果当前线程在超时时间内没有获取到同步状态,那么将会返回false
acquireShared(int arg)共享式的获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式获取的主要区别是在同一时刻可以有多个线程获取到同步状态
acquireShareInterruptibly(int arg)与acquireShared(int arg)相同,该方法响应中断
tryAcquireShareNanos(int arg,long nanos)在acquireSharedInterruptibly(int arg)基础上增加了超时限制
release(int arg)独占式的释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒
releaseShared(int arg)共享式的释放同步状态
getQueuedThreads()获取等待在同步队列上的线程集合

总结:

AbstractQueuedSynchronizer,同步器,实现JUC核心基础组件。

解决了子类实现同步器涉及的大量细节问题,例如获取同步状态,FIFO同步队列。

采用模板方法模式,AQS实现大量通用方法,子类通过继承方式实现其抽象方法来管理同步状态。

自定义同步组件(自定义锁)将使用同步器提供的模板方法来实现自己的同步语义。

同步器提供的模板方法基本分3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。

AQS的实现分析

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

同步队列

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

​ 同步队列中的节点(Node)用来保存获取同步状态失败线程引用等待状态以及前驱后继节点,节点的属性类型名称以及描述

节点的属性类型和描述:

属性类型与名称属性类型与名称描述
int waitStatus等待状态。1)cancelled,值为1,由于同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,节点进入该状态将不会变化。2)signal,值为-1,后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行。3)condition,值为-2,节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中。4)propagate,值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去。5)initial,值为0,初始状态
Node prev前驱节点,当节点加入同步队列时被设置(尾部添加)
Node next后继节点
Node nextWaiter等待队列中的后继节点。如果当前节点是共享的,那么这个字段将是一个shared常量,也就是说节点类型(独占和共享)和等待队列中的后继节点共用同一个字段
Thread thread获取同步状态的线程

​ 节点是构成同步队列(等待队列,下面会介绍)的基础,同步器拥有首节点和尾节点,没有成功获取同步状态的线程将会成为节点加入该队列的尾部。

​ 同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAdnSetTail(Node expect,Node update),它需要传递当前线程”认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

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

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

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

同步器的acquire方法:

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

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

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

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

最后调用acquireQueued(Node node, int arg)方法,使得该节点以“死循环”的方式获取同步状态。

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

节点的构造以及加入同步队列分析:

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;
} 
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;
      }
    }
  }
}

​ 上述代码通过似乎用compareAndSetTail(Node expect, Node update)方法来确保节点能够被线程安全添加。

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

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

同步器的acquireQueued方法:

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);
  }
}

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

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

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

​ 通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新重试获取同步状态)。

public final boolean release(int arg) {
  if (tryRelease(arg)) {
    Node h = head;
    if (h != null && h.waitStatus != 0)
      unparkSuccessor(h); //使用LockSupport来唤醒
    return true;
  }
  return false;
}

总结:

在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中,并在队列中进行自旋;

移出队列(或者停止自旋)的条件是前驱节点为头节点,且成功获取了同步状态。

在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

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

​ 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞。

​ 通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态。

同步器的acquireShared和doAcquireShared方法:

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);
  }
}

​ 在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int类型,当返回值大于等于0时,表示能够获取到同步状态。因此,在共享式获取的自选过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功并从自旋过程中退出。

共享式释放同步状态:

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

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

线程阻塞和唤醒

​ 当有线程获取锁了,其他再次获取时需要阻塞,当线程释放锁后,AQS负责唤醒线程。

private void unparkSuccessor(Node node) {

  int ws = node.waitStatus;
  if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);

  Node s = node.next;  //当期节点的后继节点
  if (s == null || s.waitStatus > 0) {
    s = null;
    for (Node t = tail; t != null && t != node; t = t.prev)//从尾节点开始遍历
      if (t.waitStatus <= 0) //如果小于等于0,则赋值给s,能得到当前同步队列中的头节点
        s = t;
  }
  if (s != null)
    LockSupport.unpark(s.thread); //唤醒头节点
}
LockSupport

​ LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。

​ 当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成构建同步组件的基础工具。

​ 每个使用LockSupport的线程都会与一个许可(Condition)关联,如果该许可可用,并且可在进程中使用,则调用park()将会立即返回,否则可能阻塞。如果许可尚不可用,则可以调用unpark使其可用。

​ 从线程的dump结果中,有阻塞对象的parkNanos方法能够传递给开发人员功多现场信息,Java5推出的Lock等并发工具时,遗漏了这点。

2.2、CAS

​ Compare And Swap(比较交换),整个JUC体系最核心、最基础理论。

​ 内存值V、旧的预期值A、要更新的值B,当前仅当内存值V的值等于旧的预期值A时才会将内存值V的值修改为B,否则什么都不干。

​ native方法。

原子操作的实现原理

​ 原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作意为“不可被中断的一个或一些列操作”。

CPU术语定义:

术语名称英文解释
缓存行Cache line缓存的最小操作单位
比较并交换Compare and SwapCAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
CPU流水线CPU pipelineCPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5-6个不同功能电路单元组成一条指令处理流水线,然后将一条x86指令分成5-6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提供CPU的运算速度。
内存顺序冲突Memory order violation内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。
处理器如何实现原子操作

​ 32位IA-32处理器使用基于对缓存加锁总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不同访问这个字节的内存地址。但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

​ 所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

​ 在同一时刻,我们只需要保证对某个内存地址的操作是原子性即可,但是总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

​ 所谓缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在LOCK操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不再总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

​ 下面两种情况处理器不会使用缓存锁定:

第一:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,则处理器会调用总线锁定。

第二:有些处理器不支持缓存锁定。

Java如何实现原子操作

​ 在Java中可以通过锁和循环CAS的方式来实现原子操作。

​ 1)、使用循环CAS实现原子操作,JVM中CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功。

CAS实现原子操作有三大问题:

  1. ABA问题。

    CAS操作时候,检查值有么有发生变化,如果值从A->B->A,CAS检查不出来。

    解决办法一:在变量前面追加版本号,每次变量更新的时候把版本号加1,那么A->B->A,就变成1A->2B->3A。

    解决方法二:使用AtomicStampeReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志。

  2. 循环时间长开销大。

    自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

  3. 只能保证一个共享变量的原子操作。

    对多个共享变量操作时,无法保证操作的原子性,只能用锁。

​ 2)、使用锁实现原子操作

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值