Java并发系列五:JUC.ReentrantLock
ReentrantLock作为开发中最常用的组件,也作为面试中被问到的最高频的锁之一,我们有必要来聊聊它的作用以及内部构造。
首先尝试用一句话对ReentrantLock基于AQS,它实现了公平锁和非公平锁,在开发中可以用它对共享资源进行同步。此外,和syhchronized一样,ReentrantLock支持可重入,但ReentrantLock在调度上更灵活,支持更丰富的功能。
这段话中,包含了一些关键词,我将其标注出来,并且形成一张思维导图,这张图也就是这期视频要讲解的脉络。
若想要较为系统地去理解这些特性,我觉得最好的方式就是通过源码,在一览源码之后自己再动手实践一遍,这样就能够做到知其然并知其所以然。
首先来看ReentrantLock的继承关系,ReentrantLock实现了Lock接口。在面对对象的概念中,既然ReentrantLock是Lock的一种实现,那么它必然拥有Lock的抽象意义,继续来看一下Lock是如何被定义的。
最开头的一段注释介绍了Lock的意义在于提供了区别于synchronized的另一种具有更多广泛操作的同步方式,它能支持更多灵活的结构,并且可以关联多个Condition对象。Condition是Java提供的有一个用于线程通信的接口,下文将会介绍
Lock.Java源码文件虽然有350多行,但只对六个方法进行了定义,其余都是注释。注释篇幅比较长,我这里就来简单概括一下,感兴趣的同学可以自己去通读一下注释。
void lock(),顾名思义就是用于获取锁,假如当前锁被其他线程占用,那么将会等待直到获取为止。
void lockInterruptibly(),和lock()类似,也是用于获取锁,但区别在于,假如当前线程在等待锁的过程中被中断,那么将会退出等待,并抛出中断异常。
boolean tryLock(),尝试获取锁,无论是否获取都立即返回,返回值代表是否获取锁。
boolean tryLock(long time,TimeUnit unit),尝试获取锁并设定了等待超时时间,返回值代表是否获取锁。
void unlock(),顾名思义释放锁。
Condition newCondition(),新建一个绑定在当前Lock对象上的Condition对象。
Tips:Condition对象是什么?简单来说,它表示一个条件,不同线程可以通过该条件来进行通信。比如某线程可以通过await方法注册在condition对象上进行等待,然后通过condition对象的signal方法将线程唤醒。这有点类似Object锁的wait和notify方法。但不同的是,一个Lock对象可以关联多个Condition对象,多个线程可以被绑定在不同的Condition对象上,这样就可以分组等待唤醒。此外,Condition对象还提供了和限时,中断相关的功能,丰富了线程的调度策略。
至此为止,Lock接口差不多介绍完了。可以说他只是定义了一些方法的定义,规定了它的实现类需要满足这些语义。
接下来我们就要看看ReentrantLock是如何按照这些抽象约定来进行实现的。
说个题外话,Reentrant的翻译为“可重入”,但是ReentrantLock除了实现可重入还实现了其他特点,以Reentrant命名似乎有点狭隘,但也可能是该锁本身最大的特点。
言归正传,如果你已经理解了上期讲解的AQS,那么理解ReentrantLock源码会很轻松,如果你对AQS还不是很熟悉,建议先阅读上一篇讲解AQS的内容。
浏览ReentrantLock类的源码,我们重点关注以下三个方面。
属性:sync
内部类:Sync NonfairSync FairSync
方法:
继承/实现方法:实现Lock的方法
私有方法:暂不关注
属性
ReentrantLock只有一个属性:Sync类型的变量sync。sync被final修饰,意味着一旦初始化,就不可修改引用了。那么它的初始化时机是什么时候?
在ReentrantLock构造器中:
默认无参构造器中,sync将被初始化为非公平锁对象,而在含参构造器中,可以通过参数指定其被初始化为公平锁还是非公平锁对象。
这里的NonfairSync和FairSync两个类,看名字能够知其分别为实现了非公平性和公平性的锁,下文将会详细讲解。
内部类
Sync
首先看Sync的继承关系,Sync继承了AQS,那么说明AQS中所有预设的机制,这边就都可以借用了,这就和我们上期讲的AQS相关知识贯穿了起来。
Sync被abstract修饰,说明它提供了一些公共逻辑,但需要通过子类来进行实例化。NonfairSync FairSync是它唯二的两个子类。
Sync没有属性,那么来看看它提供的公共方法
方法:
Sync中除了void lock() 和void readObject(java.io.ObjectInputStream s)两个方法外,其余方法都被final修饰,意味着不可被子类修改,我认为这些方法是对AQS内部方法的封装和拓展,本身实现已经完整可靠,不希望被外部破坏。
非final方法。
void lock() 是获取锁的操作,这里是空实现,说明需要子类根据自己的特征来进行实现。
FairSync和NonFairSync因为涉及公平性的差别,所以获取锁的操作肯定是不一样的,需要自己实现。
readObject(java.io.ObjectInputStream s),用于反序列化不是很常用,可以忽略。
final方法:
final boolean nonfairTryAcquire(int acquires)
这里就有点奇怪,刚才不是说要公平性获取锁和非公平性获取锁的逻辑都应该分别在FairSync和NonFairSync中单独实现,那么在Sync这个基类中,为什么会出现nonfairTryAcquire这种方法?
我猜测应该是FairSync和NonFairSync中都需要用到该方法,那为什么FairSync中会用到nonfairTryAcquire这种非公平性的方法?我们暂时存疑,下文在看。
该方法逻辑比较简单:
1,获取state,该值由AQS维护。
2,当state为0,那么代表锁状态为空闲,便可以进行一次CAS来原子地更改state,如果state更改成功,则代表获取了锁,将当前线程置为独占线程,并返回true,否则返回false。
3.当state不为0,说明锁被占用,判断当前线程是否已经是独占线程,既然锁都已经被占用了,为什么还要判断当前线程是否是独占线程?这里是对“可重入行”的实现。
“可重入性”的定义是:单个线程执行时重新进入同一个子程序仍然是线程安全的。
可以这么理解:假如A线程在某上下文中获取了某锁,当A线程想要再次获取该锁时,不会因为锁已经被自己占用,而需要先等待锁的释放。假如A线程既获得了锁,又在等待自己释放锁,那么就会造成死锁。
“可重入性”简单来说就是:一个线程可以不用释放儿重复获取一个锁n次,只是在释放的时候也需要相应的释放n次。
回到代码,当锁已经被占用。如果占用锁的线程不是当前线程,那么代表获取锁失败,返回false。如果正是自己,满足可重入性的条件,使用nextc这个值来记录重入的次数,因为释放锁的时候也要释放相应次数。
这里有个细节(第十二行),判断state是否小于0,有的人可能会很奇怪,state代表锁被获取的次数,总不能是负数吧?这是因为int为16位,所能表示最大的有符号数为2147483647,一旦超出便会溢出变为负数,所以我们可以这样理解,ReentrantLock允许重入的最大次数其实就是2147483647
不难理解,释放锁是一个通用操作,所以写在Sync中供子类调用很正常。
逻辑也不复杂,但是也可以看到一些细节。这个方法的返回值的boolean,注意,这里并非返回true,代表释放成功,false代表释放失败。事实上,这里的返回值代表的是否完全释放(因为可能存在重入,所以需要释放多次)
下面几个方法都比较简单,都是提供对一些状态的查询,看一看就好
protected final boolean isHeldExclusively(),判断当前线程是否为获得锁的独占线程
final ConditionObject newCondition(),基于当前Lock对象新建一个Condition对象。
final Thread getOwner(),获取正在占用锁的那个线程对象。
final int getHoldCount(),获取state的数值。
final boolean isLocked(),判断锁是否空闲。
关于Sync,内容不多,它是一个抽象类,它的两个子类NonfairSync FairSync 分别实现了公平锁和非公平锁。
那么,什么是公平性锁和非公平性锁?
公平锁就是锁的分配会按照请求获取锁的顺序,比如AQS中介绍的FIFO队列,实现的就是公平锁。非公平锁就是锁的分配不用按照请求锁的顺序,比如是抢占式的。
公平锁就保证了,只要你排队了,那么久一定能轮到你拿锁。而非公平锁是抢占式的,很可能某个线程一直抢不到锁,而又不断有新的线程加入进来抢锁,所以可能该线程一直处于阻塞状态,这种状态称为饥饿。
既然这样,那为什么还要设计非公平锁?在很多情况下,非公平锁的效率更高。为什么更高?现实生活中不是排队比争抢效率更高吗?因为非公平锁意味着后请求锁的线程可能在前面的休眠线程恢复前拿到锁,这样就有可能提高并发的性能。当唤醒挂起的线程时,线程状态之间会产生短暂延时。非公平锁就可以利用这段时间完成操作。这是非公平锁某些时候比公平锁性能要好的原因之一。
NonfairSync中只重写了Sync中的lock和AQS中的tryAcquire两个方法。我们依次来看。
final void lock()
首先尝试一次对锁的获取,如果CAS成功,那么当前线程成功获得锁。如果一次尝试失败,则调用AQS提供的acquire方法。
这里有两个问题值得讨论:
1.可重入性
当程序调用acquire的时候不要忘记,acquire内部将会首先调用tryAcquire来尝试获取锁,而nonfairTryAcquire内部已经实现了可重入性,所以满足。
2,非公平性
当程序调用lock的时候,会先进行一次CAS的尝试,当尝试获取锁失败时,调用acquire,在acquire内部,首先会调用一次tryAcquire,而nonfairTryAcquire会直接尝试获取锁,如果锁被占用且不可重入,那么就会继续执行AQS中后续的排队流程。虽然只有那么两次尝试抢占,但也体现了非公平性。
FairSync中也重写了Sync中的lock和AQS中的tryAcquire两个方法。我们依次来看。
final void lock()
首先尝试一次对锁的获取,如果CAS成功,那么当前线程成功获得锁。如果一次尝试失败,则调用AQS提供的acquire方法。
这里有两个问题值得讨论:
1.可重入性
再重复一遍,当程序调用acquire的时候,会首先调用一次tryAcquire,这是AQS相关的知识。在tryAcquire方法内部,我们看到当锁已经被占用的时候(22-28行),将会进行可重入判断,这段类似的逻辑在nonfairTryAcquire方法中解读过。
2,公平性
在tryAcquire方法中,首先判断锁是否空闲,如果空闲,此时并不是直接通过CAS获取锁,而是需要判断是否存在前置等待节点。如果不存在,那说明,在队列中确实已经轮到当前线程尝试获取锁,否则tryAcquire返回false,当前线程将会执行AQS中的后续等待逻辑。这里就体现出了公平性。
方法
讲完了三个内部类的源码,ReentrantLock在我们面前几乎可以说是一丝不挂,暴露的彻彻底底。此外,ReentrantLock作为锁实现Lock接口,而Lock定义的方法将会被上层调用,那么接下来就对方法的实现进行探究,可以大胆猜测,NonfairSync,FairSync已经做了比较完整的封装,
ReentrantLock共有方法的实现,可能只是对sync对象的简单调用。
果然,只是对sync对象的lock方法进行调用,该方法在NonfairSync FairSync已经进行了完整的实现,这里也能体现出多态。
lockInterruptibly与lock方法的区别在于,当线程在等待锁的期间,是否立即响应中断。lock方法中,线程会在等待获取锁之后再响应中断,这点在讲解AQS那期中,讲到了中断信号延迟传递的机制。lockInterruptibly方法中,若线程在等待获取锁期间被调用了中断,那么将会立即抛出中断异常。在这里,lockInterruptibly方法直接调用sync对象的acquireInterruptibly方法,该方法的实现存在于AQS内部。
TIPS:下面简单介绍一下Java的线程中断机制
1,线程在RUNNABLE状态下
假如现在有一条线程,它的状态是RUNNABLE.若此时你调用它的interrupt中断方法,它将继续运行,并不会抛出中断异常,而是只修改Thread对象中的一个标志中断状态的boolean值,true代表调用中断,false代表未被调用中断。
那么这就有个问题,开发者怎么知道该线程究竟有没有被调用中断?
这里JDK提供了两个API,一个名为isInterrupted,返回这个Thread对象内的中断状态值;另一个名为interrupted,返回这个Thread对象内的中断状态并且将其置为false
若你需要关注某线程在RUNNABLE状态下的中断状态,那么可以轮询isInterrupted方法。
2.线程在BLOCKED/WAITING状态下
假如现在有一条线程,它的状态是BLOCKED或者WAITING,若此时你调用它的interrupt中断方法,如果该线程是通过调用sleep,wait等方法进入BLOCKED或WAITING状态的,那么该线程将将修改中断状态值并直接抛出中断异常。
另一方面,如果该线程是通过LockSupport.park方法进入BLOCKED状态的,那么不会抛出中断异常,而是将状态值置为true.
回到话题,
直接调用了sync对象的nonfairTryAcquire方法,这里有一个注意点,无论ReentrantLock被指定为公平还是非公平,tryLock操作都是非公平的,这样设计是合理的。也是我们之前提到的,为什么nonfairTryAcquire逻辑写在了Sync中而不是NonfairSync中的原因。
下面几个方法相信不用我解读也已经是一目了然
到此为止,我们再来总结一下,首先介绍了Lock接口,确认了它的抽象概念以及方法的定义。然后主要介绍了ReentrantLock中三个主要的内部类Sync,NonfairSync和FairSync,其中Sync是后两者的抽象父类。在介绍Sync时,讲解了它如何实现可重入特性,在介绍NonfairSync和FairSync时,讲解了公平锁与非公平锁的概念以及它们各自的实现方式。最后,在介绍ReenTrantLock对Lock的实现时,介绍了Java的中断机制。并且与上期的AQS内容结合,将知识连贯了起来,关于Condietion,它是JUC中一个独立的接口,不同线程可以利用该对象来进行通信。此时,相信你对ReenTrantLock的里里外外,应该已经了然于胸了。