引言
上一节介绍了 ReentrantLock 类的 lock 锁获取流程,本节继续来说说它的其他两个知识点:
- lock 和 lockInterruptibly 的区别
- newCondition() ,条件队列的实现逻辑
lock 和 lockInterruptibly 的区别
ReentrantLock 的 lock 方法有几种获取锁方式:
- tryLock(),tryLock(long ,TimeUnit) ,可轮询的、可定时地获取锁;
- lock() ,无条件地轮询获取锁,锁等待期间,线程可被中断;
- lockInterruptibly() ,可中断的锁获取方式,锁等待期间,线程可被中断。
lock() 和 lockInterruptibly() ,这两个方法都能响应中断请求,但是区别在哪里呢?
分析源码,笔者发现 lock 方法默认处理了中断请求,一旦监测到中断状态,则中断当前线程;而 lockInterruptibly() 则直接抛出中断异常,由上层调用者区去处理中断,一起来看看源码细节。
lock 源码
lock 方法在获取锁的过程中,忽略了中断,并在成功获取锁之后,再根据中断标识处理中断,即 selfInterrupt 中断自己。 再回忆下 acquire 的源码:
/** *默认处理中断方式是selfInterrupt */public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();}
acquireQueued,在 for 循环中无条件重试获取锁,直到成功,同时返回线程中断状态。for 循正常返回时,必定是成功获取到了锁,它的源码是这样:
/** *无条件重试,直到成功返回,并且记录中断状态 */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); }}
lockInterruptibly 源码
可中断加锁,在锁获取过程中不处理中断状态,而是直接抛出中断异常,由上层调用者处理中断。具体源码如下:
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()) throw new InterruptedException();//抛出中断异常 } } finally { if (failed) cancelAcquire(node); }}
源码差别在 parkAndCheckInterrupt() 后面的那行:
- acquireQueued 是 interrupted = true,记录中断状态;
- doAcquireInterruptibly 是 throw new InterruptedException() ,抛出中断异常。
由于抛出了中断异常,所以 lockInterruptibly 方法的返回途径有两种,一种是 for 循环结束,正常获取到锁;另一种是线程被唤醒后检测到中断请求,则立即抛出中断异常,方法结束,同时该线程放弃获取锁请求。
差异分析
ReentrantLock 的中断和非中断加锁模式的区别在于:线程尝试获取锁操作失败后,在等待过程中,如果该线程被其他线程中断了,它是如何响应中断请求的。
lock 方法会忽略中断请求,继续获取锁直到成功;而 lockInterruptibly 则立即响应中断,并抛出中断异常,由上层调用者处理中断。
那么,为什么要分为这两种模式呢?这两种加锁方式分别适用于什么场合呢?
根据它们的实现语义来理解,笔者认为 lock 适用于锁获取操作不受中断影响的情况,此时可以忽略中断请求正常执行加锁操作。因为该操作仅仅记录了中断状态,执行 Thread.currentThread().interrupt()操作后,只是恢复了中断状态为 true,并没有对中断进行响应。
如果要求被中断线程不能参与锁的竞争,则应该使用 lockInterruptibly 方法,因为它一旦检测到中断请求,会立即返回,并且取消锁获取操作,不再参与锁的竞争。即 finally中 cancelAcquire 的操作。
newCondition 创建条件队列
AQS.ObjectCondition 是显式锁条件队列的抽象类,ReentrantLock 的 newCondition() 方法返回的是一个条件队列的实例。
什么是条件队列
第二节的锁部分也有提及过条件队列,这里再啰嗦一下。条件队列使得一组线程能够通过某种方式来等待特定的条件变成真,条件队列的元素是一个个正在等待某种状态的线程,这个等待过程其实就是阻塞线程。
Java 的内置锁【synchronized 语义对应的同步机制】,关联着一个内置条件队列。Object 的wait/notify/notifyAll 等方法构成了内置条件队列的 API,它们自动将内置锁与内置条件队列关联起来。 内置条件队列需要内置锁保护,即:调用对象 X 的 wait /notify 等方法,必须持有对象 X 的锁。这是因为状态处于并发环境下,“等待依赖状态的某个条件”与“维护状态的一致性”是绑定在一起的。
条件队列提供了一种挂起方式,当某个线程等待的条件非真时,挂起自己并释放锁,一旦等待条件为真,则立即醒来。这就是条件队列提供的主要功能,阻塞和唤醒。
显式条件队列
内置锁的局限在于每个内置锁只能关联一个条件队列,无法满足等待多个条件等待场景。与内置锁对应的是显式锁,显式锁关联的条件队列是显式条件队列,它可以与多个条件队列关联。
Condition 是显式条件队列的顶层抽象接口,它是 Object 的 wait/notify/notifyAll 等方法的扩展,提供了在一个锁对象上设置多个等待条件的功能,基本方法是 await 和 signal ,代表着阻塞和唤醒。
显式条件队列必须和显式锁一起使用,因为对共享状态变量的访问发生在多线程环境下,原理与内部条件队列一样,因此 Condition 一般是作为 Lock 的内部类实现的。
AQS 的 ConditionObject 是用双向链表来实现队列结构的,类图结构为:
![c35fbfc3c74f121a30067967a7816d65.png](https://i-blog.csdnimg.cn/blog_migrate/dcadd13baaffd9d61aa2e4fd58d9274f.jpeg)
ConditionObject 记录了头尾两个 Node 节点,提供了阻塞、唤醒等方法,基本与内置锁的 API 一致:
- 阻塞操作,会调用 addConditionWaiter ,将某个线程加入等待队列;
- 唤醒操作,会调用 doSignal ,将某个线程节点移除等待队列。
条件队列的节点状态
阻塞操作,向队列添加一个等待线程时,它会设置节点的 waitingStatus 为 Condition,标识当前节点正处于条件队列中。为了理解阻塞和唤醒的流程,我们来看看节点状态转换图:
![7a62d409d0b98c6c5466338c13677e73.png](https://i-blog.csdnimg.cn/blog_migrate/9d544e1153b3036d9400bdd1fded76bc.jpeg)
结合前面的源码,总结下 Node 的各个状态的作用:
1、Cancelled ,取消状态,主要是解决线程在持有锁时被外部中断的逻辑,lockInterrutibly 是基于该状态实现的。
2、Condition ,阻塞状态,需要等待某种条件时的状态。
3、Signal 唤醒状态,是 AQS 等待队列阻塞后继节点的标识,一个等待获取锁的线程,只有在其前驱节点等待 SIGNAL 时才会被阻塞,否则一直执行自旋尝试,以减少线程调度的开销。
等待和唤醒操作
条件队列上的阻塞和唤醒,本质是线程节点在 AQS 线程等待队列和条件队列之间相互转移的过程。
当线程需要等待某个条件时,它会被加入到条件队列,并释放锁;当某个线程在条件队列中被唤醒时,它会从条件队列中转移到 AQS 等待队列。一个 Condition 实例代表着一个条件队列,我们可以通过 Lock 的 newCondition 来创建多个条件队列。
为了理解节点在两个队列直接的转移过程,先看看阻塞和唤醒操作流程:
![689f09c60201bd908009ec915d94ce45.png](https://i-blog.csdnimg.cn/blog_migrate/8f6b2203b1f557f5775be0f64db5b282.jpeg)
核心是两个队列,AQS 的等待队列和 Condition 的条件队列,线程满足锁竞争条件,则进入 AQS 的等待队列;线程需要等待某个条件发生,则进入Condition 的条件队列。
条件队列引发的思考
显式条件队列弥补了内置条件队列只能关联一个条件的缺陷,同时继承了 Lock 对象的公平性。在Condition 类定义中,与 Object 的 wait/notify/notifyAll 等价的方法是 await/signal/signallAll,同时它也继承有 Object 的这三个方法,所以使用的时候需要注意调用的方法,不能混了。
使用显式锁时,必须手动释放锁,否则如果是独占锁,锁使用完成还被占者,其他线程就无法获取到该锁,标准的显式锁调用代码如下:
lock.lock();try{ // TODO }finally{ lock.unlock();}
此外,AQS 的两个队列都是链表队列,类的方法代码都相当简洁,尤其是节点移除队列操作过程中,都及时释放了所占内存。
这是我见到过的第三处及时释放 GC 的的代码了,最初是 ArrayList 的元素 remove 方法看到的,然后是 HashMap 的动态扩容数组转移操作置空无效元素,接着是最近看 AQS 的元素唤醒和锁释放操作。关注GC的确是最近开始形成的一种编程意识。
除了更深刻地理解 API ,源码中随处可见的严谨性细节,会对自己产生一些冲击。相似的处理方式,看到的次数多了,会记住并试着模仿应用,这就是笔者阅读源码额外收获……