(4)AQS底层原理分析

1.J.U.C 简介

Java.util.concurrent 是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。并发包的作者是大名鼎鼎的 Doug Lea。我们在接下来的课程中,回去剖析一些经典的比较常用的组件的设计思想

2.Lock

Lock 在 J.U.C 中是最核心的组件,前面我们讲 synchronized 的时候说过,锁最重要的特性就是解决并发安全问题。为什么要以 Lock 作为切入点呢?如果有同学看过 J.U.C 包中的所有组件,一定会发现绝大部分的组件都有用到了 Lock。所以通过 Lock 作为切入点使得在后续的学习过程中会更加轻松。

Lock 简介

在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于
synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,
也就是它并不适合于所有的并发场景。但是在 Java5 以后,Lock 的出现可以解决
synchronized 在某些场景中的短板,它比 synchronized 更加灵活。

Lock 的实现

Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意
味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现 Lock 接口的
类有很多,以下为几个常见的锁实现
ReentrantLock:表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是
线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入
次数
ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个
类中维护了两个锁,一个是 ReadLock,一个是 WriteLock,他们都分别实现了 Lock
接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock: stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。
stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程

Lock 的类关系图

Lock 有很多的锁的实现,但是直观的实现是 ReentrantLock 重入锁

image.png

下面是lock的方法介绍

void lock() // 如果锁可用就 获得锁,如果锁不可用就阻塞 直到锁释放
void lockInterruptibly() // 和 lock()方法相似, 但阻塞的线程可中断, 抛出java.lang.InterruptedExcepti on异常
boolean tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回true
boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁方法
void unlock() // 释放锁

3.ReentrantLock 重入锁

重入锁,表示支持重新进入的锁,也就是说,如果当前线程t1通过调用lock方 法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数 就行了。synchronized和ReentrantLock都是可重入锁。不理解为什么锁会存在重入的特性,那是因为对于同步锁的理解程度还不够,比如在下面这类 的场景中,存在多个加锁的方法的相互调用,其实就是一种重入特性的场景。

重入锁的设计目的

比如调用demo方法获得了当前的对象锁,然后在这个方法中再去调用 demo2,demo2中的存在同一个实例锁,这个时候当前线程会因为无法获得 demo2的对象锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁。

public class App {

    public synchronized void demo(){ //main获得对象锁
        System.out.println("demo");
        demo2();
    }
    public void demo2(){
        synchronized (this) { //增加重入次数就行
            System.out.println("demo2");
        }//减少重入次数
    }

    public static void main(String[] args) {
        App app=new App();
        app.demo();
    }
}
ReentrantLock 的使用案例
/**
 * @Project: ThreadExample
 * @description: ReentrantLock的使用
 * @author: sunkang
 * @create: 2020-06-27 22:39
 * @ModificationHistory who      when       What
 **/
public class ReentrantLockDemo {
    private int count = 0;
    Lock lock = new ReentrantLock();

    public void increment(){
        lock.lock();
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
        lock.unlock();
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLockDemo reentrantLock = new ReentrantLockDemo();
        for (int i=0 ;i<1000;i++){ //使用1000个线程进行计数器累加
            new Thread(()->reentrantLock.increment()).start();
        }
        Thread.sleep(3000);
        System.out.println(reentrantLock.count);
    }
}
ReentrantReadWriteLock

我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进 行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有 的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁; 一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读 多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量.

public class RWLock {

    static ReentrantReadWriteLock wrl=new ReentrantReadWriteLock();

    static Map<String,Object> cacheMap=new HashMap<>();

    static Lock read=wrl.readLock();
    static Lock write=wrl.writeLock();

    //线程B/C/D
    public static final Object get(String key){
        System.out.println("begin read data:"+key);
        read.lock(); //获得读锁-> 阻塞
        try {
            return cacheMap.get(key);
        }finally {
            read.unlock();
        }
    }
    //线程A
    public static final Object put(String key,Object val){
        write.lock();//获得了写锁
        try{
            return cacheMap.put(key,val);
        }finally {
            write.unlock();
        }
    }
}

在这个案例中,通过hashmap来模拟了一个内存缓存,然后使用读写所来保证这 个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时 候,读锁不会被阻塞,因为读操作不会影响执行结果。
在执行写操作是,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线 程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升 读操作的并发性,也保证每次写操作对所有的读写操作的可见性

读锁与读锁可以共享
读锁与写锁不可以共享(排他)
写锁与写锁不可以共享(排他)

4.ReentrantLock 的实现原理

我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串 行执行,从而达到线程安全性的目的。在 synchronized 中,我们分析了偏向锁、 轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销, 同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。 那么在ReentrantLock中,也一定会存在这样的需要去解决的问题。就是在多线程 竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?

AQS 是什么

在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它 是一个同步工具也是Lock用来实现线程同步的核心组件。如果你搞懂了AQS,那 么J.U.C中绝大部分的工具都能轻松掌握。

AQS 的两种功能

从使用层面来说,AQS的功能分为两种:独占和共享

独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是 以独占方式实现的互斥锁
共享锁,允许多个线程同时获取锁,并发访问共享资源,比如 ReentrantReadWriteLock

AQS 的内部实现

AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任 意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线 程争抢锁失败后会封装成Node加入到ASQ队列中去;当获取锁的线程释放锁以 后,会从队列中唤醒一个阻塞的节点(线程)。

image.png

Node类的组成如下

tatic final class Node {
        static final Node SHARED = new Node();
        static final Node EXCLUSIVE = null;
        static final int CANCELLED =  1;
        static final int SIGNAL    = -1;
        static final int CONDITION = -2;
        static final int PROPAGATE = -3;
        volatile int waitStatus;
        volatile Node prev; //前驱节点
        volatile Node next; //后继节点
        volatile Thread thread;//当前线程
        Node nextWaiter; //存储在condition队列中的后继节点
        //是否为共享锁
        final boolean isShared() { 
            return nextWaiter == SHARED;
        }

        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    // Used to establish initial head or SHARED marker
        }
        //将线程构造成一个Node,添加到等待队列
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }
        //这个方法会在Condition队列使用,后续单独写一篇文章分析condition
        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }
释放锁以及添加线程对于队列的变化

添加节点
当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景。


image.png

这里会涉及到两个变化

新的线程封装成Node节点追加到同步队列中,设置prev节点以及修改当前节点的前置节点的next节点指向自己
通过CAS讲tail重新指向新的尾部节点

head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点, 如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下


image.png

这个过程也是涉及到两个变化

  1. 修改head节点指向下一个获得锁的节点
  2. 新的获得锁的节点,将prev的指针指向null

设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成 的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并且断开原head节点的next引用即可

5.ReentrantLock 的源码分析

清楚了AQS的基本架构以后,我们来分析一下AQS的源码,仍然以ReentrantLock为模型。

5.1 ReentrantLock的时序图

调用ReentrantLock中的lock()方法,源码的调用过程我使用了时序图来展现


image.png

从图上可以看出来,当锁获取失败时,会调用addWaiter()方法将当前线程封装成Node节点加入到AQS队列,基于这个思路,我们来分析AQS的源码实现

5.2ReentrantLock.lock()
public void lock() {
    sync.lock();
}

这个是获取锁的入口,调用sync这个类里面的方法,sync是什么呢?

abstract static class Sync extends AbstractQueuedSynchronizer

sync是一个静态内部类,它继承了AQS这个抽象类,前面说过AQS是一个同步工具,主要用来实现同步控制。我们在利用这个工具的时候,会继承它来实现同步控制功能。
通过进一步分析,发现Sync这个类有两个具体的实现,分别是NofairSync(非公平锁),FailSync(公平锁).

公平锁 表示所有线程严格按照FIFO来获取锁
非公平锁 表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁

公平锁和非公平锁的实现上的差异,我会在文章后面做一个解释,接下来的分析仍然以非公平锁作为主要分析逻辑。

5.3NonfairSync.lock

以非公平锁为例,来看看lock中的实现

  1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是,不管有 没有线程排队,我先上来cas去抢占一下
  2. CAS成功,就表示成功获得了锁
  3. CAS失败,调用acquire(1)走锁竞争逻辑
final void lock() {
    if (compareAndSetState(0, 1)) //通过cas操作来修改state状态,表示争抢锁的操作
      setExclusiveOwnerThread(Thread.currentThread());//设置当前获得锁状态的线程
    else
      acquire(1); //尝试去获取锁
}
CAS的实现原理
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

通过 cas 乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的 state的值和预期值expect相等,则替换为update。更新成功返回true,否则返 回false.

这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作, 以及涉及到state这个属性的意义。
state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入 锁的实现来说,表示一个同步状态。它有两个含义的表示

  1. 当state=0时,表示无锁状态
  2. 当 state>0 时,表示已经有线程获得了锁,也就是 state=1,但是因为ReentrantLock允许重入,所以同一个线程多次获得同步锁的时候,state会递增, 比如重入5次,那么state=5。 而在释放锁的时候,同样需要释放5次直到state=0 其他线程才有资格获得锁
Unsafe类

Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包 括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、 Hadoop、Kafka等;

Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、 线程的挂起和恢复、CAS、线程同步、内存屏障

而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象, 第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第 四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值var4相等,则 更新为新的期望值 var5,如果更新成功,则返回true,否则返回false;

stateOffset

一个Java对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存 里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节 偏移。用于在后面的compareAndSwapInt中,去根据偏移量找到对象在内存中的 具体位置

所以stateOffset表示state这个字段在AQS类的内存中相对于该类首地址的偏移 量

compareAndSwapInt

在 unsafe.cpp 文件中,可以找到 compareAndSwarpInt 的实现

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj); //将 Java 对象解析成 JVM 的 oop(普通对象指针),
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); //根据对象 p和地址偏移量找到地址
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
 //基于 cas 比较并替换, x 表示需要更新的值,addr 表示 state 在内存中的地址,e 表示预期值
UNSAFE_END

5.4AQS.accquire

acquire 是 AQS 中的方法,如果 CAS 操作未能成功,说明 state 已经不为 0,此 时继续acquire(1)操作
大家思考一下,acquire方法中的1的参数是用来做什么呢?
这个方法的主要逻辑是

  1. 通过tryAcquire尝试获取独占锁,如果成功返回true,失败返回false
  2. 如果tryAcquire失败,则会通过addWaiter方法将当前线程封装成Node添加 到AQS队列尾部
  3. acquireQueued,将Node作为参数,通过自旋去尝试获取锁。
   public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
NonfairSync.tryAcquire

这个方法的作用是尝试获取锁,如果成功返回true,不成功返回false 它是重写 AQS 类中的 tryAcquire 方法,并且大家仔细看一下 AQS 中 tryAcquire 方法的定义,并没有实现,而是抛出异常。

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
ReentrantLock.nofairTryAcquire
  1. 获取当前线程,判断当前的锁的状态
  2. 如果state=0表示当前是无锁状态,通过cas更新state状态的值
  3. 当前线程是属于重入,则增加重入次数
final boolean nonfairTryAcquire(int acquires) {
  final Thread current = Thread.currentThread();//获取当前执行的线程
  int c = getState();//获得 state 的值
  if (c == 0) {//表示无锁状态
    if (compareAndSetState(0, acquires)) {//cas 替换 state 的值,cas 成功表示获取锁成功
      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;
}
5.5AQS.addWaiter

当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成 Node.

入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状 态。意味着重入锁用到了AQS的独占锁功能

  1. 将当前线程封装成Node
  2. 当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的node添加到AQS队列
  3. 如果为空或者cas失败,调用enq将节点添加到AQS队列
private Node addWaiter(Node mode) {
  Node node = new Node(Thread.currentThread(), mode);//把当前线程封装为 Node
  Node pred = tail; //tail 是 AQS 中表示同比队列队尾的属性,默认是 null
  if (pred != null) {//tail 不为空的情况下,说明队列中存在节点
    node.prev = pred;//把当前线程的 Node 的 prev 指向 tail
    if (compareAndSetTail(pred, node)) {//通过 cas 把 node加入到 AQS 队列,也就是设置为 tail
      pred.next = node;//设置成功以后,把原 tail 节点的 next指向当前 node
      return node;
    }
  }
  enq(node);//tail=null或者compareAndSetTail(pred, node)=false,把 node 添加到同步队列  
  return node;
}
enq

enq就是通过自旋操作把当前节点加入到队列中

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;
      }
    }
  }
}
图解分析

假设3个线程来争抢锁,那么截止到enq方法运行结束之后,或者调用addwaiter 方法结束后,AQS中的链表结构图

image.png
5.6AQS.acquireQueued

通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给acquireQueued方法,去竞争锁

  1. 获取当前节点的prev节点
  2. 如果prev节点为head节点,那么它就有资格去争抢锁,调用tryAcquire抢占 锁
  3. 抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head 节点
  4. 如果获得锁失败,则根据waitStatus决定是否需要挂起线程
  5. 最后,通过cancelAcquire取消获得锁的操作
final boolean acquireQueued(final Node node, int arg) {
  boolean failed = true;
  try {
    boolean interrupted = false;
    for (;;) {
      final Node p = node.predecessor();//获取当前节点的 prev 节点
      if (p == head && tryAcquire(arg)) {//如果是 head 节点,说明有资格去争抢锁
        setHead(node);//获取锁成功,也就是ThreadA 已经释放了锁,然后设置 head 为 ThreadB 获得执行权限
        p.next = null; //把原 head 节点从链表中移除
        failed = false;
        return interrupted;
      }
      //ThreadA 可能还没释放锁,使得 ThreadB 在执行 tryAcquire 时会返回 false
      if (shouldParkAfterFailedAcquire(p,node) && parkAndCheckInterrupt())
        interrupted = true; //并且返回当前线程在等待过程中有没有中断过。
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}
NofairSync.tryAcquire

这个方法在前面分析过,就是通过state的状态来判断是否处于无锁状态,然后在 通过cas进行竞争锁操作。成功表示获得锁,失败表示获得锁失败

shouldParkAfterFailedAcquire

如果ThreadA的锁还没有释放的情况下,ThreadB和ThreadC来争抢锁肯定是会 失败,那么失败以后会调用shouldParkAfterFailedAcquire方法

Node 有 5 中状态,分别是:CANCELLED(1),SIGNAL(-1) CONDITION(2)、PROPAGATE(-3)、默认状态(0)

CANCELLED: 在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点, 其结点的waitStatus为CANCELLED,即结束状态,进入该状 态后的结点将不会再变化
SIGNAL: 只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程
CONDITION: 和Condition有关系
PROPAGATE:共享模式下,PROPAGATE状态的线程处于可运行状态
0.初始状态

这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是否应该被挂起。

  1. 如果ThreadA的pred节点状态为SIGNAL,那就表示可以放心挂起当前线程
  2. 通过循环扫描链表把CANCELLED状态的节点移除
  3. 修改pred节点的状态为SIGNAL,返回false.

返回false时,也就是不需要挂起,返回true,则需要调用parkAndCheckInterrupt 挂起当前线程

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
  int ws = pred.waitStatus;//前置节点的waitStatus
  if (ws == Node.SIGNAL)//如果前置节点为 SIGNAL,意味着只需要等待其他前置节点的线程被释放,
    return true;//返回 true,意味着可以直接放心的挂起了
  if (ws > 0) {//ws 大于 0,意味着 prev 节点取消了排队,直接移除这个节点就行
  do {
    node.prev = pred = pred.prev;//相当于: pred=pred.prev;
    node.prev=pred;
  } while (pred.waitStatus > 0); //这里采用循环,从双向列表中移除 CANCELLED 的节点
    pred.next = node;
  } else {//利用 cas 设置 prev 节点的状态为 SIGNAL(-1)
    compareAndSetWaitStatus(pred, ws,Node.SIGNAL);
  }
  return false;
}
parkAndCheckInterrupt

使用LockSupport.park挂起当前线程编程WATING状态
Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是thread.interrupt();
如果有触发过中断请求,那么这个方法会返回当前的中断标识 true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回true,意味 着在acquire方法中会执行selfInterrupt()。

private final boolean parkAndCheckInterrupt() {    
     LockSupport.park(this);  
   return Thread.interrupted();
 }

selfInterrupt: 标识如果当前线程在acquireQueued中被中断过,则需要产生一 个中断请求,原因是线程在调用acquireQueued方法的时候是不会响应中断请求 的

static void selfInterrupt() { 
    Thread.currentThread().interrupt(); 
} 
图解分析

通过acquireQueued方法来竞争锁,如果ThreadA还在执行中没有释放锁的话, 意味着ThreadB和ThreadC只能挂起了。


image.png
LockSupport

LockSupport类是Java6引入的一个类,提供了基本的线程同步原语。LockSupport 实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数

   public native void unpark(Object var1);

   public native void park(boolean var1, long var2);

unpark函数为线程提供“许可(permit)”,线程调用park函数则等待“许可”。这个有 点像信号量,但是这个“许可”是不能叠加的,“许可”是一次性的。

permit 相当于 0/1 的开关,默认是 0,调用一次unpark 就加 1变成了 1.调用一次park会消费permit,又会变成0。 如果再调用一次park会阻塞,因为permit已 经是0了。直到permit变成1.这时调用unpark会把permit设置为1.每个线程都 有一个相关的permit,permit最多只有一个,重复调用unpark不会累积

6.锁的释放流程

如果这个时候ThreadA释放锁了,那么我们来看锁被释放后会产生什么效果

ReentrantLock.unlock

在unlock中,会调用release方法来释放锁

public final boolean release(int arg) {
  if (tryRelease(arg)) { //释放锁成功
    Node h = head; //得到 aqs 中 head 节点
    if (h != null && h.waitStatus != 0)//如果 head 节点不为空并且状态!=0.调用 unparkSuccessor(h)唤醒后续节点
       unparkSuccessor(h);
    return true;
  }
  return false;
}
ReentrantLock.tryRelease

这个方法可以认为是一个设置锁状态的操作,通过将state状态减掉传入的参数值 (参数是1),如果结果状态为0,就将排它锁的Owner设置为null,以使得其它的线程有机会进行执行。

在排它锁中,加锁的时候状态会增加 1(当然可以自己修改这个值),在解锁的时 候减掉1,同一个锁,在可以重入后,可能会被叠加为2、 3、 4这些值,只有unlock() 的次数与 lock()的次数对应才会将 Owner 线程设置为空,而且也只有这种情况下 才会返回true。

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;
}
unparkSuccessor
private void unparkSuccessor(Node node) {
  int ws = node.waitStatus;//获得 head 节点的状态
  if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);// 设置 head 节点状态为 0
  Node s = node.next;//得到 head 节点的下一个节点
  if (s == null || s.waitStatus > 0) {
    //如果下一个节点为 null 或者 status>0 表示 cancelled 状态.
    //通过从尾部节点开始扫描,找到距离 head 最近的一个waitStatus<=0 的节点
    s = null;
    for (Node t = tail; t != null && t != node; t =t.prev) 
      if (t.waitStatus <= 0)
        s = t;
  }
   if (s != null) //next 节点不为空,直接唤醒这个线程即可
      LockSupport.unpark(s.thread);
}
为什么在释放锁的时候是从 tail 进行扫描

我觉得有必要单独拧出来说一下,我们再回到 enq 那个方法、。在标注为红色部分的代码来看一个新的节点是如何加入到链表中的

  1. 将新的节点的prev指向tail
  2. 通过cas将tail设置为新的节点,因为cas是原子操作所以能够保证线程安全性
  3. t.next=node;设置原tail的next节点指向新的节点
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;
      }
    }
  }
}
image.png

在cas操作之后, t.next=node操作之前。 存在其他线程调用unlock方法从head 开始往后遍历,由于 t.next=node 还没执行意味着链表的关系还没有建立完整。 就会导致遍历到 t 节点的时候被中断。所以从后往前遍历,一定不会存在这个问 题。

图解分析

通过锁的释放,原本的结构就发生了一些变化。head节点的waitStatus变成了0, ThreadB被唤醒

image.png

7.原本挂起的线程继续执行

通过ReentrantLock.unlock,原本挂起的线程被唤醒以后继续执行,应该从哪里执 行大家还有印象吧。 原来被挂起的线程是在 acquireQueued 方法中,所以被唤 醒以后继续从这个方法开始执行

AQS.acquireQueued

这个方法前面已经完整分析过了,我们只关注一下 ThreadB 被唤醒以后的执行流 程。

由于ThreadB的prev节点指向的是head,并且ThreadA已经释放了锁。所以这 个时候调用tryAcquire方法时,可以顺利获取到锁

  1. 把ThreadB节点当成head
  2. 把原head节点的next节点指向为null
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. 设置新head节点的prev=null
  2. 设置原head节点的next节点为null


    image.png

8.公平锁和非公平锁的区别

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

在上面分析的例子来说,只要 CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不一样,差异点 有两个

FairSync.tryAcquire
final void lock() { 
    acquire(1); 
} 

非公平锁在获取锁的时候,会先通过CAS进行抢占,而公平锁则不会

FairSync.tryAcquire
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,则表示有线程比当前线程更早地请求获取锁, 因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270