概述
ReentrantLock 是 Java 并发中十分常用的一个类,具备类似 synchronized 锁的作用。但是相比 synchronized, 它具备更强的能力,同时支持公平锁和非公平锁。
公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。
那 ReentrantLock 中具体是怎么实现公平和非公锁的呢?它们之间又有什么优缺点呢?本文就带大家一探究竟。
RenentrantLock 原理概述
上面是 RenentrantLock 的类结构图。
RenentrantLock 实现了 Lock 接口,Lock 接口提供了锁的通用 api,比如加锁 lock,解锁 unlock 等操作。
RenentrantLock 底层加锁是通过 AQS 实现的,两个内部类 FairSync 服务于公平锁,NofaireSync 服务于非公平锁的实现,他们统一继承自 AQS。
ReentrantLock 类 API:
如果锁没有被另一个线程占用,则将锁定计数设置为 1
如果当前线程已经保持锁定,则保持计数增加 1
如果锁被另一个线程保持,则当前线程被禁用线程调度,并且在锁定已被获取之前处于休眠状态
如果当前线程是该锁的持有者,则保持计数递减
如果保持计数现在为零,则锁定被释放
如果当前线程不是该锁的持有者,则抛出异常
关于 AQS 的原理, 强烈大家阅读深入浅出理解Java并发AQS的独占锁模式
非公平锁实现
演示
@Testpublic void testUnfairLock() throws InterruptedException { // 无参构造函数,默认创建非公平锁模式 ReentrantLock reentrantLock = new ReentrantLock();
for (int i = 0; i < 10; i++) { final int threadNum = i; new Thread(() -> { reentrantLock.lock(); try { System.out.println("线程" + threadNum + "获取锁"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { // finally中解锁 reentrantLock.unlock(); System.out.println("线程" + threadNum +"释放锁"); } }).start(); Thread.sleep(999); }
Thread.sleep(100000);}
复制代码
运行结果:
线程0获取锁线程0释放锁线程1获取锁线程1释放锁线程3获取锁线程3释放锁线程2获取锁线程2释放锁线程5获取锁线程5释放锁线程4获取锁线程4释放锁....
复制代码
加锁原理
构造函数创建锁对象
public ReentrantLock() { sync = new NonfairSync();}
复制代码
第一个线程加锁时,不存在竞争,如下图:
// ReentrantLock.NonfairSync#lockfinal void lock() { // 用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示【获得了独占锁】 if (compareAndSetState(0, 1)) // 设置当前线程为独占线程 setExclusiveOwnerThread(Thread.currentThread()); else acquire(1);//失败进入}
复制代码
cas 修改 state 从 0 到 1,获取锁
设置锁对象的线程为当前线程
第二个线程申请加锁时,出现锁竞争,如下图:
// AbstractQueuedSynchronizer#acquirepublic final void acquire(int arg) { // tryAcquire 尝试获取锁失败时, 会调用 addWaiter 将当前线程封装成node入队,acquireQueued 阻塞当前线程, // acquireQueued 返回 true 表示挂起过程中线程被中断唤醒过,false 表示未被中断过 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 如果线程被中断了逻辑来到这,完成一次真正的打断效果 selfInterrupt();}
复制代码
// ReentrantLock.NonfairSync#tryAcquireprotected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires);}// 抢占成功返回 true,抢占失败返回 falsefinal boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); // state 值 int c = getState(); // 条件成立说明当前处于【无锁状态】 if (c == 0) { //如果还没有获得锁,尝试用cas获得,这里体现非公平性: 不去检查 AQS 队列是否有阻塞线程直接获取锁 if (compareAndSetState(0, acquires)) { // 获取锁成功设置当前线程为独占锁线程。 setExclusiveOwnerThread(current); return true; } } // 这部分是重入锁的原理 // 如果已经有线程获得了锁, 独占锁线程还是当前线程, 表示【发生了锁重入】 else if (current == getExclusiveOwnerThread()) { // 更新锁重入的值 int nextc = c + acquires; // 越界判断,当重入的深度很深时,会导致 nextc < 0,int值达到最大之后再 + 1 变负数 if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); // 更新 state 的值,这里不使用 cas 是因为当前线程正在持有锁,所以这里的操作相当于在一个管程内 setState(nextc); return true; } // 获取失败 return false;}
复制代码
第二个线程 tryAcquire 申请锁失败,通过执行 addWaiter 方法加入到队列中。
// AbstractQueuedSynchronizer#addWaiter,返回当前线程的 node 节点private Node addWaiter(Node mode) { // 将当前线程关联到一个 Node 对象上, 模式为独占模式 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; // 快速入队,如果 tail 不为 null,说明存在阻塞队列 if (pred != null) { // 将当前节点的前驱节点指向 尾节点 node.prev = pred; // 通过 cas 将 Node 对象加入 AQS 队列,成为尾节点,【尾插法】 if (compareAndSetTail(pred, node)) { pred.next = node;// 双向链表 return node; } } // 初始时队列为空,或者 CAS 失败进入这里 enq(node); return node;}
复制代码
// AbstractQueuedSynchronizer#enqprivate Node enq(final Node node) { // 自旋入队,必须入队成功才结束循环 for (;;) { Node t = tail; // 说明当前锁被占用,且当前线程可能是【第一个获取锁失败】的线程,【还没有建立队列】 if (t == null) { // 设置一个【哑元节点】,头尾指针都指向该节点 if (compareAndSetHead(new Node())) tail = head; } else { // 自旋到这,普通入队方式,首先赋值尾节点的前驱节点【尾插法】 node.prev = t; // 【在设置完尾节点后,才更新的原始尾节点的后继节点,所以此时从前往后遍历会丢失尾节点】 if (compareAndSetTail(t, node)) { //【此时 t.next = null,并且这里已经 CAS 结束,线程并不是安全的】 t.next = node; return t; // 返回当前 node 的前驱节点 } } }}
复制代码
第二个线程加入队列后,现在要做的是想办法阻塞线程,不让它执行,就看 acquireQueued 的了。
inal boolean acquireQueued(final Node node, int arg) { // true 表示当前线程抢占锁失败,false 表示成功 boolean failed = true; try { // 中断标记,表示当前线程是否被中断 boolean interrupted = false; for (;;) { // 获得当前线程节点的前驱节点 final Node p = node.predecessor(); // 前驱节点是 head, FIFO 队列的特性表示轮到当前线程可以去获取锁 if (p == head && tryAcquire(arg)) { // 获取成功, 设置当前线程自己的 node 为 head setHead(node); p.next = null; // help GC // 表示抢占锁成功 failed = false; // 返回当前线程是否被中断 return interrupted; } // 判断是否应当 park,返回 false 后需要新一轮的循环,返回 true 进入条件二阻塞线程 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) // 条件二返回结果是当前线程是否被打断,没有被打断返回 false 不进入这里的逻辑 // 【就算被打断了,也会继续循环,并不会返回】 interrupted = true; } } finally { // 【可打断模式下才会进入该逻辑】 if (failed) cancelAcquire(node); }}
复制代码
acquireQueued 会在一个自旋中不断尝试获得锁,失败后进入 park 阻塞
如果当前线程是在 head 节点后,也就是第一个节点,又会直接多一次机会 tryAcquire 尝试获取锁,如果还是被占用,会返回失败。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 表示前置节点是个可以唤醒当前节点的节点,返回 true if (ws == Node.SIGNAL) return true; // 前置节点的状态处于取消状态,需要【删除前面所有取消的节点】, 返回到外层循环重试 if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); // 获取到非取消的节点,连接上当前节点 pred.next = node; // 默认情况下 node 的 waitStatus 是 0,进入这里的逻辑 } else { // 【设置上一个节点状态为 Node.SIGNAL】,返回外层循环重试 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } // 返回不应该 park,再次尝试一次 return false;}
复制代码
shouldParkAfterFailedAcquire 发现前驱节点等待状态是-1, 返回 true,表示需要阻塞。
shouldParkAfterFailedAcquire 发现前驱节点等待状态大于 0,说明是无效节点,会进行清理。
shouldParkAfterFailedAcquire 发现前驱节点等待状态等于 0,将前驱 node 的 waitStatus 改为 -1,返回 false。
private final boolean parkAndCheckInterrupt() { // 阻塞当前线程,如果打断标记已经是 true, 则 park 会失效 LockSupport.park(this); // 判断当前线程是否被打断,清除打断标记 return Thread.interrupted();}
复制代码
多个线程尝试获取锁,竞争失败后,最终形成下面的图形。
释放锁原理
第一个线程通过调用 unlock 方法释放锁。
public void unlock() { sync.release(1);}
复制代码
// AbstractQueuedSynchronizer#releasepublic final boolean release(int arg) { // 尝试释放锁,tryRelease 返回 true 表示当前线程已经【完全释放锁,重入的释放了】 if (tryRelease(arg)) { // 队列头节点 Node h = head; // 头节点什么时候是空?没有发生锁竞争,没有竞争线程创建哑元节点 // 条件成立说明阻塞队列有等待线程,需要唤醒 head 节点后面的线程 if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false;}
复制代码
进入 tryRelease,设置 exclusiveOwnerThread 为 null,state = 0
当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor, 唤醒阻塞的线程
线程一通过调用 tryRelease 方法释放锁,该类的实现是在子类中
// ReentrantLock.Sync#tryReleaseprotected final boolean tryRelease(int releases) { // 减去释放的值,可能重入 int c = getState() - releases; // 如果当前线程不是持有锁的线程直接报错 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); // 是否已经完全释放锁 boolean free = false; // 支持锁重入, 只有 state 减为 0, 才完全释放锁成功 if (c == 0) { free = true; setExclusiveOwnerThread(null); } // 当前线程就是持有锁线程,所以可以直接更新锁,不需要使用 CAS setState(c); return free;}
复制代码
唤醒队列中第一个线程 Thread1
private void unparkSuccessor(Node node) { // 当前节点的状态 int ws = node.waitStatus; if (ws < 0) // 【尝试重置状态为 0】,因为当前节点要完成对后续节点的唤醒任务了,不需要 -1 了 compareAndSetWaitStatus(node, ws, 0); // 找到需要 unpark 的节点,当前节点的下一个 Node s = node.next; // 已取消的节点不能唤醒,需要找到距离头节点最近的非取消的节点 if (s == null || s.waitStatus > 0) { s = null; // AQS 队列【从后至前】找需要 unpark 的节点,直到 t == 当前的 node 为止,找不到就不唤醒了 for (Node t = tail; t != null && t != node; t = t.prev) // 说明当前线程状态需要被唤醒 if (t.waitStatus <= 0) // 置换引用 s = t; } // 【找到合适的可以被唤醒的 node,则唤醒线程】 if (s != null) LockSupport.unpark(s.thread);}
复制代码
为什么这里查找唤醒的节点是从后往前,而不是从前往后呢?
从后向前的唤醒的原因:enq 方法中,节点是尾插法,首先赋值的是尾节点的前驱节点,此时前驱节点的 next 并没有指向尾节点,从前遍历会丢失尾节点。
Thread1 恢复执行流程
另一种可能,突然来了 Thread-4 来竞争,体现非公平锁
如果这时有其它线程来竞争锁,例如这时有 Thread-4 来了并抢占了锁,很有可能抢占成功。
公平锁实现
演示
@Testpublic void testfairLock() throws InterruptedException { // 有参构造函数,true表示公平锁,false表示非公平锁 ReentrantLock reentrantLock = new ReentrantLock(true);
for (int i = 0; i < 10; i++) { final int threadNum = i; new Thread(() -> { reentrantLock.lock(); try { System.out.println("线程" + threadNum + "获取锁"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { // finally中解锁 reentrantLock.unlock(); System.out.println("线程" + threadNum +"释放锁"); } }).start(); Thread.sleep(10); }
Thread.sleep(100000);}
复制代码
运行结果:
线程0获取锁线程0释放锁线程1获取锁线程1释放锁线程2获取锁线程2释放锁线程3获取锁线程3释放锁线程4获取锁线程4释放锁线程5获取锁线程5释放锁线程6获取锁线程6释放锁线程7获取锁线程7释放锁线程8获取锁线程8释放锁线程9获取锁线程9释放锁
复制代码
原理实现
公平锁和非公锁的整体流程基本是一致的,唯一不同的是尝试获取锁 tryAcquire 的实现。
static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; final void lock() { acquire(1); }
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 先检查 AQS 队列中是否有前驱节点, 没有(false)才去竞争 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 锁重入 return false; }}
复制代码
public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; // 头尾指向一个节点,链表为空,返回false return h != t && // 头尾之间有节点,判断头节点的下一个是不是空 // 不是空进入最后的判断,第二个节点的线程是否是本线程,不是返回 true,表示当前节点有前驱节点 ((s = h.next) == null || s.thread != Thread.currentThread());}
复制代码
与非公平锁最大的区别是:公平锁获取锁的时候先检查 AQS 队列中是否有非当前线程的等待节点,没有才去 CAS 竞争,有的话,就老老实实排队去吧。而非公平锁会尝试抢一次锁,如果抢不到的话,老老实实排队去吧。
总结
非公平锁和公平锁的两处不同:
非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。
公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。
评论