深入剖析 ReentrantLock
Java 中的 synchronized 关键字在 JVM 层面来保证线程安全,而在 JUC 包下也有可以保证线程安全的类。
在JDK 1.6之前还没有偏向锁和轻量级锁等优化的时候,使用 synchronized 保证线程安全是一个非常重的操作,原因有以下两点:
- 第一点:因为 synchronized 的原子性是通过操作系统的Mutex Lock互斥量实现的,所以每次申请互斥量都需要从用户态转换到内核态。
- 第二点:JVM 线程是和内核线程 1:1 实现的,所以对线程的阻塞和唤醒也是需要从用户态转换到内核态。
为了减少加锁造成对线程频繁阻塞唤醒带来的开销,Java并发编程之父 Doug Lea 提供了一系列的 Lock 通过 CAS + 少量自旋的方式的去提升锁的性能。Lock 接口下有多重种实现,我们来通过加锁解锁 API 的角度来重点分析下可重入锁 ReentrantLock。
一. AQS
ReentrantLock 对线程的阻塞和唤醒都是通过 AbstractQueuedSynchronizer 来实现的。
AbstractQueuedSynchronizer 简称 AQS,又叫做 队列同步器,一般以模板类的方式存在于同步工具类的内部类中。AQS 里面维护了一个双向链表来阻塞和唤醒线程,一个 volatile 修饰的 state 变量维护锁的状态:
public abstract class AbstractQueuedSynchronizer { // 头节点,懒加载 private transient volatile Node head; // 尾节点,懒加载 private transient volatile Node tail; // 锁状态和锁重入的数量,0 代表无线程占用 private volatile int state; }
双向链表的节点的结构:
static final class Node { // 节点状态 volatile int waitStatus; // 前继节点 volatile Node prev; // 后继节点 volatile Node next; // 阻塞的线程 volatile Thread thread; // 下个等待节点,单链表,作用类似于Object.wait()的等待队列 Node nextWaiter; // Used by addWaiter Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; } // Used by Condition Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; } }
waitStatus 状态分为以下几种:
- **CANCELLED(1):**取消状态,标志该线程不再参与锁的争抢
- **SIGNAL(-1):**标记后继节点的线程需要被阻塞
- **CONDITION(-2):**同步阻塞状态,类似调用了Object.wait()
- **PROPAGATE(-3):**共享模式,同步状态可传播状态
- **0:**初始状态
二. ReentrantLock
ReentrantLock 有非公平锁和公平锁两种,可以通过构造方法的参数进行初始化,默认是非公平锁:
// 默认初始化非公平锁 public ReentrantLock() { sync = new NonfairSync(); } // 可以通过参数指定公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
非公平锁顾名思义,就是获取锁不是公平的,我们来看下非公平锁的加锁逻辑:
lock() 加锁
lock() 加锁方法:
// lock()方法源码 final void lock() { // 先通过CAS的操作将state值改为1,期待值为0; if (compareAndSetState(0, 1)) // 如果CAS成功,将独占线程设置为当前线程 setExclusiveOwnerThread(Thread.currentThread()); else // 失败代表有竞争 acquire(1); }
非公平锁的加锁很粗暴,上来就抢锁;竞争成功将独占线程设置为当前线程,这个独占线程的作用有两个:一个是重入锁的判断,另一个使是为了解锁时进行判断防止非占用锁的线程进行误解锁操作;竞争失败就进行进入 acquire() 方法。
点进 acquire() 方法:
// acquire()源码 public final void acquire(int arg) { // 再次尝试获取锁,如果失败将当前线程加入等待队列进行阻塞 // acquireQueued()也会有尝试获取锁的逻辑 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
acquire() 里通过 tryAcquire(arg) 方法再次尝试获取一次锁,如果线程没有抓住这次机会,就会被加到队列中。
再看下 tryAcquire(arg) 方法:
// 非公平锁的实现 protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } // 非公平锁尝试获取锁 final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); // 获取 state 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"); // 不需要CAS操作,因为进入到这里的线程已经获取到了锁自然没有竞争,直接设置值即可 setState(nextc); return true; } // 抢占失败或者非重入的返回失败 return false; }
tryAcquire(arg) 里会先去判断 state 的值:
- 如果 state 值为 0 代表没有线程占用,可以进行一次 CAS 操作去修改 state 的值,返回 CAS 结果;
- 如果 state 不为 0 进行重入锁的判断,判断独占线程是否是当前线程,如果是进行锁的重入,这里不需要对 state 进行 CAS 操作,因为进入到这里的线程已经获取到了锁自然没有竞争,直接设置 state 值即可。
在 acquire() 方法内如果 tryAcquire() 尝试获取锁的操作失败后会进行线程阻塞的操作,我们接着来看下 addWaiter() 方法:
// addWaiter源码 private Node addWaiter(Node mode) { // 将当前线程存储到 Node 节点内 Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; // 如果尾节点不为空 if (pred != null) { // 将当前线程节点的pre指向尾节点:tail <- node node.prev = pred; // CAS 操作将尾节点改为当前线程节点node if (compareAndSetTail(pred, node)) { // 如果修改成功 // 将之前 pre(之前的尾节点)的 next 指向 node(现在的尾节点) pred.next = node; // 完成等待队列的添加,直接返回 return node; } } // 尾节点为空或 CAS 失败,会走到这里 enq(node); return node; } // 自旋直到将node加入到等待队列里 private Node enq(final Node node) { for (;;) { Node t = tail; // 走到这里并且尾节点为空代表第一次进行竞争抢占锁 if (t == null) { // Must initialize // CAS 设置等待队列的head节点 if (compareAndSetHead(new Node())) // CAS 操作成功设置尾节 tail = head; // 进入下一轮循环 } else { // 尾节点不为空就能将node的prev指向尾节点 node.prev = t; // CAS 修改尾节点为 node(这块是循环的出口,只有加入等待队列成功才会跳出循环) if (compareAndSetTail(t, node)) { // CAS 成功就next连上node并返回 t.next = node; return t; } // CAS 失败代表有竞争,进入下一轮循环 } } }
addWaiter() 方法主要做的事情是将当前线程加到等待队列的尾部。
加入到等待队列后就需要对线程进行阻塞操作,acquireQueued() 方法:
// acquireQueued() 源码 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; // 自旋 for (;;) { // 获取node节点的前继节点 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() 是线程被唤醒争抢锁和阻塞的核心方法,先判断前继节点是否是 head 头节点:
- 如果是,尝试获取锁,失败就尝试阻塞线程;
- 如果不是,直接尝试阻塞线程。
shouldParkAfterFailedAcquire() 方法判断当前线程节点是否应该阻塞:
// 判断当前d节点是否应该阻塞 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 如果前继节点的waitStatus为SIGNA就返回true进行阻塞操作 if (ws == Node.SIGNAL) return true; if (ws > 0) { // 大于 0 代表取消争抢锁,从后往前遍历,直到 waitStatus 不大于零 do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // 初始化状态将前继节点的 waitStatus 值改为 SIGNAL 状态 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } // 返回false后会再次进入下一轮循环 return false; }
直到判断可以进行阻塞后进行线程阻塞:
private final boolean parkAndCheckInterrupt() { // 线程阻塞方法,调用本地方法阻塞当前线程 LockSupport.park(this); return Thread.interrupted(); }
线程阻塞在LockSupport.park()方法内,保存线程上下文到寄存器内,被唤醒后会继续执行代码,走到 acquireQueued() 方法的循环内尝试获取锁。
lock() 方法的加锁方式和 synchronized 逻辑基本一样,都是维护一个队列来阻塞没抢到锁的线程;但是有些场景下需要没抢到锁快速返回失败而不是阻塞,synchronized 正常是无法做到的,Lock 接口中提供了tryLock() / tryLock(long timeout, TimeUnit unit) 的方法,如果获取不到锁会直接返回失败或自旋一段时间后返回失败。
tryLock() 加锁
tryLock() 方法不区分公平锁和非公平锁,本身就是一种非公平的体现,直接去尝试获取锁,获取成功返回 true,获取失败返回 false,底层就是用的上文说的 nonfairTryAcquire() 方法。
tryLock(long timeout, TimeUnit unit) 加锁
而 tryLock(long timeout, TimeUnit unit) 的逻辑会有些不一样,会等到设置的超时时间到后才返回:
public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 先通过tryAcquire尝试获取一次锁,如果失败调用 doAcquireNanos(arg, nanosTimeout) return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout); } // 自旋超时时间阈值 static final long spinForTimeoutThreshold = 1000L; private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { // 检查设置的超时时间 if (nanosTimeout <= 0L) return false; // 计算方法的返回的时间戳 deadline final long deadline = System.nanoTime() + nanosTimeout; // 先将当前线程加到等待队列尾部 final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (;;) { // 获取当前线程节点的前继节点 final Node p = node.predecessor(); // 如果前面继节点是 head 就去尝试获取锁 if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return true; } // 获取剩余超时时间 nanosTimeout nanosTimeout = deadline - System.nanoTime(); // nanosTimeout 小于 0 代表超时时间到,返回 false if (nanosTimeout <= 0L) return false; // 检查是否应该阻塞线程 // 如果应该阻塞,判断 nanosTimeout 是否大于 spinForTimeoutThreshold(1000纳妙) if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) // 将线程阻塞 nanosTimeout 的时间,等时间到后线程自动唤醒,进行下一次循环判断时间后返回false LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
这里用到了一个优化,如果 nanosTimeout <= 1000 纳妙,就自旋判断前继节点且尝试获取锁而不是阻塞线程,减少了频繁阻塞和唤醒线程的开销。
unlock() 解锁
再看下 unlock() 解锁的流程:
public void unlock() { sync.release(1); } public final boolean release(int arg) { // 先尝试解锁,如果成功再唤醒head的后继节点 if (tryRelease(arg)) { Node h = head; // head 不等于 null 并且不等于 0 就唤醒后继节点 if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
tryRelease() 尝试解锁的逻辑:
protected final boolean tryRelease(int releases) { // 计算解锁后的state值 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; }
解锁的时候需要判断当前线程是否是独占线程,防止未持有锁的线程误解锁。
当解锁成功后去唤醒 head 的后继节点:
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; // 从尾节点tail从后往前遍历,找到最靠前的不为null且状态不为取消的 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) // 阻塞线程 LockSupport.unpark(s.thread); }
一般情况都是唤醒 head 的后继节点,但是如果后继节点为空或者状态为已取消,就需要从后往前遍历,这也是为什么 AQS 的等待队列要设计成双向链表的原因之一。
lockInterruptibly() 可中断加锁
ReentrantLock 提供了 lockInterruptibly() 方法来提供获取锁并阻塞时,可以响应中断的功能:
public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); } public final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 先尝试获取锁 if (!tryAcquire(arg)) // 获取锁失败进入 doAcquireInterruptibly doAcquireInterruptibly(arg); }
doAcquireInterruptibly(arg) 的逻辑:
private void doAcquireInterruptibly(int arg) throws InterruptedException { final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) // 重点就在这里,当调用线程的interrupt()中断方法时,阻塞的线程会被唤醒,并抛出异常 throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
调用 lockInterruptibly() 进行加锁的线程,在没抢到锁阻塞的时候如果当调用线程的 interrupt() 中断方法时,阻塞的线程会被唤醒,并抛出异常。
公平锁
ReentrantLock 的公平锁是遵循一个先来后到的原则:公平锁加锁的时候会将线程加到等待队列的尾部,按照队列的顺序分配锁资源。应用于一些要求顺序执行或同步执行时间长(同步过程中耗时较长,公平锁会产生无用自旋)的场景。因为需要保证顺序性,少去很多自旋的加锁尝试,在同步时间短和并发高的场景下性能自然会比非公平锁差些。
ReentrantLock 的加锁解锁逻辑整体大概如下:
三. ReentrantLock 如何保证线程安全?
ReentrantLock 为什么可以保证线程安全三要素?
想要保证线程安全,就必须保证原子性、有序性、可见性。原子性和有序性肯定可以保证,因为整个同步过程是原子的,同步过程中只有一个线程能执行可以保证有序性,那可见性呢?
Lock lock = new ReentrantLock(); int i = 0; // 伪代码... try { lock.lock(); i ++; } finally { lock.unlock(); }
synchronized 关键字是JVM底层支持的解锁的时候会将同步代码块中的数据刷回主内存,那在上述代码中,还需要对 i 加上volatile关键字才能保证可见性最终保证线程安全?
其实是不需要的,AQS 中的 state 是 volatile 修饰的,加锁和解锁的过程都会操作 state,对 volatile 修饰的关键字进行操作时会将本地缓存中的数据都刷新回主内存,所有中间操作的数据也都会刷新回主内存,可见性自然也可以保证。
四. 比较和总结
相比于 synchronized,ReentrantLock 提供了更多功能的API,比如快速失败加锁:tryLock(),超时加锁:tryLock(long timeout, TimeUnit unit),可中断加锁:lockInterruptibly(),公平锁等;但是由于 synchronized 后期加入了许多优化,二者的性能相差无几,ReentrantLock 除了需要显示的加锁和解锁,且需要在finally里解锁(发生异常不会主动解锁)还会有更多的功能。
#java后端##程序员#