从ReentrantLock详解AQS原理源码解析

Java中的大部分同步类(ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier等)都是基于AbstractQueuedSynchronizer(简称为AQS)实现的。AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及FIFO队列模型的简单框架。

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

数据结构


在java.util.concurrent.locks.AbstractQueuedSynchronizer类中存在如下数据结构。

// 链表结点
static final class Node {}

// head指向的是一个虚拟结点,刷多了算法就知道这样做的目的是方便对链表操作,真正的头为head.next
private transient volatile Node head;

// 尾结点
private transient volatile Node tail;

// 这个锁(共享资源)对象的状态。
// volatile保证可见性和屏蔽指令重排
private volatile int state;

// 继承至AbstractOwnableSynchronizer类
// 独占模式下当前锁的拥有者
private transient Thread exclusiveOwnerThread;

// 自旋锁的自旋纳秒数,用于提高应用的响应能力
static final long spinForTimeoutThreshold = 1000L;

// unsafe类
private static final Unsafe unsafe = Unsafe.getUnsafe();

// 以下字段对应上面字段的在对象中的偏移值,在静态代码块中初始化,其值是相对于在这个类对象中的偏移量
private static final long stateOffset;
private static final long headOffset;
private static final long tailOffset;
private static final long waitStatusOffset;
private static final long nextOffset;

在AQS类中的内部类Node包含如下数据结构

static final class Node {

    // 共享锁
    static final Node SHARED = new Node();

    // 独占锁
    static final Node EXCLUSIVE = null;
       
    // 0	               当一个Node被初始化的时候的默认值
    // CANCELLED	为  1,表示线程获取锁的请求已经取消了
    // CONDITION	为 -2,表示节点在等待队列中,节点线程等待唤醒
    // PROPAGATE	为 -3,当前线程处在SHARED情况下,该字段才会使用
    // SIGNAL	        为 -1,表示线程已经准备好了,就等资源释放了
    volatile int waitStatus;
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;
    
    // 前驱指针
    volatile Node prev;
    
    // 后继指针
    volatile Node next;
	
    // 该节点代表的线程对象
    volatile Thread thread;

    Node nextWaiter;
}

从其数据结构可以猜测出

  • AQS类中主要的存储结构是一个双向链表,称为CLH变体的虚拟双向队列(FIFO)。
  • state字段对应了这个锁(共享资源)对象的状态。
  • 线程申请锁(共享资源)时会将其包装成一个节点。Node保存了获取锁的线程信息。
  • Node.waitStatus字段保存这个线程申请锁(共享资源)的状态。
  • head指向的是一个虚拟结点,真正有效的头为head.next。
  • 请求共享资源的线程包装节点node包含两种模式,Node.SHARED表示以共享的模式等待锁、Node.EXCLUSIVE表示正在以独占的方式等待锁。

在前文锁阻塞和唤醒是用CLH队列锁实现的,CLH:Craig、Landin and Hagersten队列,是单向链表。通过分析上面的数据结构可知,在AQS中其实现本质上是一个双向链表,AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

Node.waitStatus包含5个状态,对应如下

状态 含义
0 当一个Node被初始化的时候的默认值
CANCELLED 为 1,表示线程获取锁的请求已经取消了
CONDITION 为 -2,表示节点在等待队列中,节点线程等待唤醒
PROPAGATE -3,当前线程处在SHARED情况下,该字段才会使用
SIGNAL 为 -1,表示线程已经准备好了,就等资源释放了

源码分析


我们从AQS的实现类ReentrantLock#lock开始分析其具体的流程。

ReentrantLock#lock

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

直接调用了Sync类的lock()方法,Sync类在ReentrantLock中有两个实现类分别是FairSync和NonfairSync,分别对应了公平锁和非公平锁。

  • 公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO;
  • 非公平锁:线程获取锁的顺序和调用lock的顺序无关,全凭运气。

由于ReentrantLock默认是非公平锁,我们从NonfairSync类分析。

ReentrantLock.NonfairSync#lock

final void lock() {
	// cas操作尝试将state字段值修改为1
    if (compareAndSetState(0, 1))
    	// 成功的话就代表已经获取到锁,修改独占模式下当前锁的拥有者为当前线程
        setExclusiveOwnerThread(Thread.currentThread());
    else
    	// 获取锁失败之后的操作
        acquire(1);
}

从这可以确定我们之前的猜测

  • state字段对应了这个锁对象的状态,值为0的时候代表锁没有被线程占用,修改为1之后代表锁被占用。

现在分析未获取到锁之后的流程

AbstractQueuedSynchronizer#acquire

public final void acquire(int arg) {
	
    if (
    		// 当前线程尝试获取锁
    		!tryAcquire(arg) &&
    		// acquireQueued会把传入的结点在队列中不断去获取锁,直到获取成功或者不再需要获取(中断)。
        	acquireQueued(
        		// 在双向链表的尾部创建一个结点,值为当前线程和传入的模式
	        	addWaiter(Node.EXCLUSIVE), 
	        	arg
        	)
        )
        // TODO
        selfInterrupt();
}

看不懂,先查找资料了解这几个方法的作用,注释在代码中。

ReentrantLock.NonfairSync#tryAcquire

// 当前线程尝试获取锁
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

ReentrantLock.Sync#nonfairTryAcquire

// 当前线程尝试获取锁-非公平
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 获得当前锁对象的状态
    int c = getState();
    // state为0代表当前没有被线程占用
    if (c == 0) {
    	// cas操作尝试将state字段值修改为请求的数量
        if (compareAndSetState(0, acquires)) {
        	// 直接修改当前独占模式下锁的拥有者为为当前线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 如果锁的占有者就是当前线程
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // state值增加相应的请求数。
        setState(nextc);
        return true;
    }
    return false;
}

ReentrantLock字面意思是可重入锁

  • 可重入锁:一个线程在获取一个锁之后,在没有释放之前仍然可以继续申请锁而不会造成阻塞,但是解锁的时候也需要相应次数的解锁操作。

结合nonfairTryAcquire方法逻辑,可以推断出state字段在独占锁模式下还代表了锁的重入次数。

AbstractQueuedSynchronizer#addWaiter

// 在链表尾部创建一个结点,值为当前线程和传入的模式
private Node addWaiter(Node mode) {
	// 创建一个结点,值为当前线程和传入的模式
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    // 快速路径,是为了方便JIT优化。jvm检测到热点代码,会将其编译成本地机器码并以各种手段进行代码优化。
    Node pred = tail;
    if (pred != null) {
    	// 将新创建的node的前驱指针指向tail。
        node.prev = pred;
        // 将结点修改为队列的tail时可能会发生数据冲突,用cas操作保证线程安全。
        if (compareAndSetTail(pred, node)) {
        	// compareAndSetTail比较的地址,如果相等则将新的地址赋给该字段(而不是在源地址上替换,为什么我会这么想???)
        	// 所以此处pred引用指向的仍然是源tail的内存地址。将其后继指针指向新的tail
            pred.next = node;
            return node;
        }
    }
    // 队列为空或者cas失败(说明被别的线程已经修改)
    enq(node);
    return node;
}

这个方法主要作用是在链表尾部创建一个结点,返回新创建的结点,其主要流程为

  • 通过当前的线程和锁模式创建一个节点。
  • 节点入尾操作
    • 新节点的前驱指针指向tail
    • 使用cas操作修改新节点为tail
    • 原tail的后继指针指向新节点

当队列为空或者cas失败(说明被别的线程已经修改)会执行enq方法兜底。

AbstractQueuedSynchronizer#enq

// 在队列尾部创建一个结点,值为当前线程和传入的模式,当队列为空的时候初始化。
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
        	// 创建一个空结点设置为头,真正的头为hdead.next
            if (compareAndSetHead(new Node()))
            	// 尾等于头
                tail = head;
        } else {
        	// 这段逻辑跟addWaiter()中快速路径的逻辑一样。
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

addWaiter是对enq方法的一层封装,addWaiter首先尝试一个快速路径的在链表尾部创建一个结点,失败的时候回转入enq方法兜底,循环在链表尾部创建一个节点,直到成功为止。

这里有个疑问,为什么要在addWaiter方法中尝试一次在enq方法中能完成的在链表尾部创建一个节点的操作呢?其实是为了方便JIT优化。jvm检测到热点代码,会将其编译成本地机器码并以各种手段进行代码优化。了解更多1了解更多2

在链表尾插入需要

AbstractQueuedSynchronizer#acquireQueued

// acquireQueued会把传入的结点在队列中不断去获取锁,直到获取成功或者不再需要获取(中断)。
final boolean acquireQueued(final Node node, int arg) {
	// 标记是否成功拿到锁
    boolean failed = true;
    try {
    	// 标记获取锁的过程中是否中断过
        boolean interrupted = false;
        // 开始自旋,要么获取锁,要么中断
        for (;;) {
        	// 获得其前驱节点
            final Node p = node.predecessor();
            // 如果前驱节点为head代表现在节点node在队列有效数据的第一位,就尝试获取锁
            if (p == head && tryAcquire(arg)) {
            	// 获取锁成功,把当前节点置为虚节点
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果存在以下情况就要判断当前node是否要被阻塞
            // 1. p为头节点且获取锁失败 2. p不为头结点
            if (shouldParkAfterFailedAcquire(p, node) &&
            	// 阻塞进程
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
        	// 取消申请锁
            cancelAcquire(node);
    }
}

AbstractQueuedSynchronizer#shouldParkAfterFailedAcquire

// 依赖前驱节点判断当前线程是否应该被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
	// 入参请求锁的node的前驱节点的状态
    int ws = pred.waitStatus;
    // 如果前驱节点的状态为"表示线程已经准备好了,就等资源释放了"
    // 说明前驱节点处于激活状态,入参node节点需要被阻塞
    if (ws == Node.SIGNAL)
        return true;
    // 只有CANCELLED状态对应大于0
    if (ws > 0) {
        do {
        	// 循环向前查找取消状态节点,把取消节点从队列中剔除
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
    	// 设置状态非取消的前驱节点等待状态为SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

ReentrantLock#lock总结

到现在我们可以总结一下ReentrantLock#lock非公平锁方法的流程

未获取到锁的情况下函数调用流程

  • ReentrantLock#lock
  • ReentrantLock.Sync#lock
  • ReentrantLock.NonfairSync#lock
  • AbstractQueuedSynchronizer#acquire
  • ReentrantLock.NonfairSync#tryAcquire
  • ReentrantLock.Sync#nonfairTryAcquire
  • AbstractQueuedSynchronizer#addWaiter
  • AbstractQueuedSynchronizer#acquireQueued

描述

  • 执行ReentrantLock的Lock方法。
  • 会调用到内部类Sync的Lock方法,由于Sync#lock是抽象方法,根据ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,cas修改state值获取锁,失败执行父类的Acquire方法。
  • 父类的Acquire方法会执行子类实现的tryAcquire方法,因为tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。
  • tryAcquire是获取锁逻辑,获取失败后,会执行框架AQS的后续逻辑,跟ReentrantLock自定义同步器无关。


// 公平锁加锁时判断等待队列中是否存在有效节点的方法。
// 返回False,当前线程可以争取共享资源;
// 返回True,队列中存在有效节点,当前线程必须加入到等待队列中。
public final boolean hasQueuedPredecessors() {
	Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    // 头不等于尾代表队列中存在结点返回true
    // 但是还有一种特例,就是如果现在正在执行enq方法进行队列初始化,tail = head;语句运行之后
    // 此时h == t,返回false,但是队列中
    return h != t &&
    	// 从这可以看出真正的头结点是head.next,即说明head是一个无实际数据的结点,为了方便链表操作
        ((s = h.next) == null 
        // 有效头结点与当前线程不同,返回true必须加入到等待队列
        || s.thread != Thread.currentThread());
}

即时编译器


Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
这里所说的热点代码主要包括两类

  • 被多次调用的方法
  • 被多次执行的循环体

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体

未完待续

posted @ 2020-08-19 00:51  NeverTh  阅读(396)  评论(0编辑  收藏  举报