文章目录
ReenTrantLock特点
- 独占锁
- 可重入锁,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
- 需要手动释放锁,否则可能造成死锁
- 支持公平和非公平,默认非公平锁,因为维护公平锁开销太大了
- 锁的过程是可中断的
- 可设置超时时间
- 支持多个条件变量Condition,即支持多个不同的等待队列,而synchronized只支持一个。
锁的竞争实现原理:
ReentrantLock是通过互斥变量,使用CAS机制来实现的。
没有竞争到锁的线程,使用了AbstractQueuedSynchronizer这样一个队列同步器
来存储,底层是通过双向链表
来实现的。当锁被释放之后,会从AQS队列里面的头部唤醒下一个等待锁的线程。
公平 vs 非公平:
ReentrantLock默认采用了非公平锁的策略来实现锁的竞争逻辑。
其次,ReentrantLock内部使用了AQS来实现锁资源的竞争,没有竞争到锁资源的线程,会加入到AQS的同步队列里面,这个队列是一个FIFO的双向链表。
在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS同步队列里面有没有等待的线程。如果有,就加入到队列的尾部等待。
而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到AQS同步队列等待。
ReentrantLock和Synchronized默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。
因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,这里会涉及到内核态的切换,对性能的影响比较大。
如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,
虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。
对AQS的理解
AQS是AbstractQueuedSynchronizer
的简称,即抽象队列同步器,从字面意思上理解:
- 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
- 队列:FIFO同步队列,本质是双向链表
- 同步:为实现同步而设计
AQS是队列同步器基类,提供模板方法给子类重写实现不同的锁。用volatile变量表示锁状态,clh队列保存阻塞的线程,ReentrantLock、ReentrantReadWriteLock等等皆是基于AQS构建出来的。
AQS内部由两个核心部分组成:
-
一个volatile修饰的state变量,作为一个竞态条件
-
用双向链表结构维护的FIFO线程等待队列
它的具体工作原理是:多个线程通过对这个state共享变量进行修改来实现竞态条件,竞争失败的线程加入到FIFO队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照FIFO顺序实现有序唤醒。
FIFO同步队列
AQS中最重要的就是FIFO队列,其实现原理是阻塞+唤醒
AQS通过内置的FIFO同步队列
来完成资源获取线程
的排队工作,如果当前线程获取同步状态失败,AQS则会将当前线程以及等待状态等信息构造成一个节点Node
并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
Node节点
在同步队列中, 节点的类型为Node,一个节点表示一个线程,用来存放线程和线程状态等信息 。
node节点中的线程有以下五种状态:
AQS获取和释放资源流程分析
AQS获取/释放资源(同步)的一般情况如下:
每一个基于AQS实现的同步器都会包含两种类型的操作:
1、至少一个acquire操作。
这个操作阻塞调用线程,直到AQS的状态允许这个线程继续执行。
比如Condition的acquire操作为await()。
2、至少一个release操作。
这个操作改变AQS的状态,改变后的状态可允许一个或多个阻塞线程被解除阻塞。
FIFO同步队列 vs条件等待队列
一个 FIFO同步队列 对应多个条件变量, 每个条件变量有自己的一个条件队列。所以FIFO同步队列:条件等待队列=1:n
二者配合在一起完成线程的调度和执行,具体过程如下:
当条件队列里的线程被唤醒时。会进入 AQS的同步队列
里排队
然后当线程执行完成再次进入等待状态时就会回到条件队列等待。
就这样,线程在同步队列和条件队列中来回交替。
FIFO同步队列为什么是双向链表?
因为双向链表可以支持 常量O(1) 时间复杂度的情况下找到前驱结点,基于这样的特点。双向链表在插入和删除操作的时候,要比单向链表简单、高效。
具体高效在三个场景下:
1、第一个方面,没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,
这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。
所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从head节点开始遍历,性能非常低。
2、第二个方面,在Lock接口里面有一个,lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。
也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。
这个时候,被中断的线程的状态会修改成CANCELLED。
被标记为CANCELLED状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。
意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。
在这种情况下,如果是单向链表,就需要从Head节点开始往下逐个遍历,找到并移除异常状态的节点。
同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
3、第三个方面,为了避免线程阻塞和唤醒的开销,所以刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。
但是实际上按照公平锁的设计,只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。
否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。
所以为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。
所以这里会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。
ReadWriteLock
ReentrantLock 是独占锁
, 同时只有一个线程可以获取该锁, 写多
的场景是适合的
而实际中会有大量写少读多的场景, 显然如果使用 ReentrantLock 则读操作效率过低, 所以 ReadWriteLock 应运而生。
ReadWriteLock 采用读写分离
的策略, 保证写操作数据安全的同时,增加了读操作的并发量
加读锁,则其他线程可以读不能写
加写锁,则其他线程不能读写
所以:读读不阻塞 读写和写写阻塞