通过对锁原理的分析,重点分析ReentrantLock和ReentrantReadWriteLock的源码,通过锁的实现更深入的理解AQS。
提出问题
在本文的开篇,先提出问题,我们所有的研究,都为了解决这些问题的。
- ReentrantLock的公平模式和非公平模式有什么区别?分别是如何的?
- 什么是可重入?ReentrantLock是如何实现可重入的?
- ReentrantLock与Syncronized有何不同?
- Condition的作用是什么?它的实现原理是什么?Condition和Object.wait,Object.notify有何异同?
- AQS是如何实现自旋的?AQS中的线程会一直保持自旋吗?
- ReentrantReadWriteLock与ReentrantLock的区别是什么?
- ReentrantReadWriteLock是如何实现读不阻塞的?
- AQS是如何实现独占锁和共享锁的?
锁基础知识
在分析源码之前,先讲解下锁相关的基础。
自旋锁&互斥锁
通常情况下解决多线程共享资源逻辑一致性问题有两种方式:
- 互斥锁:当发现资源被占用的时候,会阻塞自己直到资源解除占用,然后再次尝试获取。互斥锁会带来线程上下文切换,信号发送等开销,理论上性能比自旋锁要慢。
- 自旋锁:当发现资源被占用的时候,一直尝试获取锁,故而称为“自旋”。自旋锁会死循环检测锁标志位,期间是占用CPU的,并且在竞争激烈的时候,标志位会频繁的变更,会造成高频率的缓存同步。所以它适合锁保持时间比较短的情况。
对于这两种方式没有优劣之分,只有是否适合当前的场景。
但是如果竞争非常激烈的时候,使用自旋锁就会产生一些额外的问题:
- 每一个线程都在疯狂的自旋,会造成大量的CPU浪费;
- 因为自旋锁会依赖一个共享的锁标识,所以竞争激烈的时候,锁标识的同步也需要消耗大量的资源;
- 如果要用自旋锁实现公平锁(即先到先获取),此时就还需要额外的变量,也会比较麻烦;
解决这些问题其中的一种办法就是使用队列锁,简单来讲就是让这些线程排队获取,下面章节会介绍CLH锁,它是队列锁的一种优秀实现。
CLH锁
无论是简单的非公平自旋锁还是公平的基于排队的自旋锁,由于执行线程均在同一个共享变量上自旋,申请和释放锁的时候必须对该共享变量进行修改,这将导致所有参与排队自旋锁操作的处理器的缓存变得无效。如果排队自旋锁竞争比较激烈的话,频繁的缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。
所以,需要有一种办法能够让执行线程不再在同一个共享变量上自旋,避免过高频率的缓存同步操作。于是MCS和CLH锁应运而生,由于MCS和CLH很类似,Java中主要采用了CLH的思想,所以CMS在此就不再研究。
CLH锁的名称都来源于发明人的名字首字母:
Craig、Landin、Hagersten三个人发明了CLH锁。其核心思想是:通过一定手段将所有线程对某一共享变量的轮询竞争转化为一个线程队列,且队列中的线程各自轮询自己的本地变量。
公平模式和非公平模式
上文提到的自旋锁和互斥锁,是天然的非公平锁。在竞争激烈的时候,可能导致一些线程始终无法获取锁(争抢的时候必然是当前活跃线程获得锁的几率大),也就是饥饿现象。为了解决饥饿现象所以需要一种公平的方式,让每一个线程都有机会获取到锁。
公平锁(FairSync)的定义是:所有竞争资源的线程遵循排队原则,逐一获得锁,通俗一点来讲,就是“先来后到”。非公平锁(NonfairSync)允许“插队行为“。
在现实生活中,大家都对插队行为嗤之以鼻,但是大家完全遵循排队原则,效率(在计算机世界里,效率就是用最少的资源最大化利用CPU的计算能力)就真的高嘛?举一个简单的例子,有10个线程用公平排队的方式等待资源,但是由于线程A有大量的I/O操作,一直占用锁不释放,CPU空置,后面的线程为了遵循公平原则只能干瞪眼,此刻CPU资源不能最大化的利用。
那如果执行较快的线程B在到的时候,资源恰好是无竞争状态,那它可以直接“插队“进来优先被执行,但是由于线程B执行的速度很快,所以线程A也没有蒙受多大的‘’损失‘’‘。所以非公平锁可以尽量利用系统的计算资源,省去了线程的唤醒,用户态到内核态切换等等消耗资源的操作,从大局上提高系统的效率。
但是,所有的事物都要辩证的去看。举一个例子,如果线程A是一个很重要的线程,但是由于锁竞争很激烈,线程A始终拿不到锁,那么就会导致一些关键性功能受到影响,这就是锁饥饿。所以非公平锁在锁竞争很激烈的情况下,会存在锁饥饿现象。
公平和非公平各有利弊,但是大多数的锁的实现,默认实现的是非公平锁,如果没有特殊的场景,非公平锁就能满足你的大多数需求。
可重入
先说场景来理解可重入性。如下代码会最终打印100。调用test的时候,主线程获得了锁,但是在主线程还未释放锁的时候,由于递归调用又再次获取了锁。一个线程可以多次获取同一把锁的行为,叫做锁的可重入。如果不可重入,如下代码就会造成死锁。
ReentrantLock 和 synchronized都是可重入锁。
1 | public class TestClass { |
乐观锁和悲观锁
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而说一种人好于另外一种人。
Java中synchronized和ReentrantLock就是悲观锁思想实现的,数据库中的表锁和行锁,也都是悲观锁的实现。
Java中java.util.concurrent.atomic
包下面的各种原子类,就是使用了乐观锁的一种实现方式CAS实现。
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种。乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
乐观锁的实现,一般会通过版本号机制或者CAS算法实现。
版本号机制
一般在数据中,增加一个version
字段,表示字段被修改的次数,经典的实现有数据库的乐观锁设计,ElasticSearch的_version机制等。用如下伪代码的形式,解释下版本号的思想。
1 | var user = select user.age,user.id,user.version from t_user where id = 1001; |
CAS机制
具体的CAS问题可以参考此文,作者写的也是比较详细,ABA的例子也来自他的博文。
CAS即compare and swap(比较与交换),是一种有名的无锁算法。CAS算法涉及到三个操作数
- 需要读写的内存值V
- 进行比较的值A
- 拟写入的新值B
当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是原子操作),通过自旋不断地重试。
但是CAS机制会存在ABA问题,举一个生活中取钱的例子。
在多线程场景下CAS
会出现ABA
问题,关于ABA问题这里简单科普下,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下
- 线程1,期望值为A,欲更新的值为B
- 线程2,期望值为A,欲更新的值为B
线程1
抢先获得CPU时间片,而线程2
因为其他原因阻塞了,线程1
取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3
,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2
从阻塞中恢复,并且获得了CPU时间片,这时候线程2
取值与期望的值A比较,发现相等则将值更新为B,虽然线程2
也完成了操作,但是线程2
并不知道值已经经过了A->B->A
的变化过程。
ABA
问题带来的危害:
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。
解决方法: 在变量前面加上版本号,每次变量更新的时候变量的版本号都+1
,即A->B->A
就变成了1A->2B->3A
。
synchronized
synchronized是Java中的内置锁,具体的实现在JVM中。synchronized可以给修饰代码块,修饰方法等,由于它的用法过于基础,不在本文中赘述。
synchronized的特点是使用简单,不需要手动指定锁的获取和锁的释放。被大家所诟病的性能问题,在JDK1.6之后不复存在。在JDK1.6中,引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
关于重量级锁、轻量级锁、偏向锁,可以看此文,作者写的很用心。
总之在使用synchronized的时候,偏向锁、轻量级锁、重量级锁,分别对应锁只被一个线程持有,不同线程持交替持有,多线程竞争锁三种情况。当条件不满足时,锁会按偏向锁->轻量级锁->重量级锁的顺序升级。JVM的锁也是能降级的,不过条件十分苛刻。
针对问题:ReentrantLock与Syncronized有何不同?回答是:
在JDK1.6之前,由于还没有引入偏向锁和轻量级锁,JVM对synchronized的实现比较重,导致它的性能存在一定的问题,ReentrantLock的性能要完胜synchronized的。但是随着JVM对synchronized的不断优化,在jdk1.6之后,两者的差距越来越小,synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量,性能差距可以忽略不计。所以在一般情况下,性能不是两者最大的差距。真正的差距是在于两者的使用上,synchronized是很死板的,只能对一个代码块或者一个方法,提供非公平独占锁的实现。跟synchronized相比,ReentrantLock是非常灵活的,既可以公平锁也可以非公平锁,可以newCondition()
,来实现锁的阻塞队列,但是灵活性带来的就是使用的门槛较高,使用不规范就很容易造成死锁!不要小看死锁,解决死锁的方式是进程重启!但是在使用synchronized时,是不用考虑死锁问题的。
ReentrantLock
常用方法
void lock()
获取锁- 当锁没有被其他线程持有时,lock方法立即返回,设置锁计数器为1。
- 当前线程已经持有该锁时,锁持有计数器继续增加,lock方法立即返回
- 锁被其他线程持有时当前线程出于线程调度的目的而被禁用,并出于休眠状态,直到获取锁为止,此时锁持有的计数器置为1。
void lockInterruptibly()
获取锁除非线程被中断- 当锁没有被其他线程持有时,lock方法立即返回,设置锁计数器为1。
- 当前线程已经持有该锁时,锁持有计数器继续增加,lock方法立即返回
- 锁被其他线程持有时当前线程出于线程调度的目的而被禁用,并出于休眠状态,直到如下两件事情任意一件发生为止;当前线程获取到锁或者其他线程中断了当前线程,方法会抛出
InterruptedException
boolean tryLock()
仅当其他线程在此刻没有持有该锁时,才会获取该锁,立即返回并返回true
,设置标志位为1。如果当前线程已经持有该锁,立即返回,标志位递增并返回true
。如果该锁被其他线程持有,将立即返回false
。boolean tryLock(long timeout, TimeUnit unit)
具体逻辑跟tryLock相同但是多了一个等待的时间,如果在等待期间,线程被中断,还会抛出InterruptedException
void unlock()
试图释放锁,当前线程拥有该锁,则计数器减1,直到计数器为0,则释放锁。如果当前线程不持有该锁,则抛出IllegalMonitorStateException
异常Condition newCondition()
返回锁的Condition实例。Condition实例的作用与Object.wait()
、Object.notify()
、Object.notifyAll()
相同。如果lock没有被任何线程持有,调用Condition.awart()
或者Condition.signal
,会抛出IllegalMonitorStateException
。如果线程被中断,则waiting将会终止,并抛出InterruptedException
,线程的中断标识位将被清除。int getHoldCount()
,锁CLH队列的数量boolean isHeldByCurrentThread()
锁是否被当前线程持有boolean isLocked()
锁是否被线程持有boolean isFair()
是否是公平锁Thread getOwner()
返回当前持有锁的线程,如果锁没有被任何线程持有则返回null
公平模式&非公平模式实现原理
如下代码只保留了关键性代码。Sync
通过继承AbstractQueuedSynchronizer
来获得基本的同步控制能力,NofairSync
和FairSync
通过实现AQS模板方法,来指定获取锁的资格算法。
1 | abstract static class Sync extends AbstractQueuedSynchronizer { |
如何实现可重入的?
在上一章节公平锁&非公平锁中,可以看到current == getExclusiveOwnerThread()
,status会加上acquires值,来做递增并返回true,所以同一个线程进来之后,不会加入到CLH同步队列中。
Lock和unlock原理分析
在公平模式&非公平模式的章节中,我们会发现无论是公平锁还是非公平锁,他们最终都会调用acquire
方法进入到同步队列中。acquire
方法包括如下流程
tryAcquire
尝试获取锁,如果能获取锁,则直接返回。tryAcquire
是一个模板方法,由实现AQS的类实现。在ReentrantLock中,分别有公平锁和非公平锁两种实现,具体实现可以参考对应章节。- 如果发现不能获取到锁,则调用
addWaiter
方法,把currentThread
包装成Node
,并加入到同步队列中。这里有一个细节addWaiter(Node.EXCLUSIVE)
,ReentrantLock是独占锁的思想,所以需要指定模式为独占模式。 - 执行
acquireQueued
方法,采用自旋+阻塞的方式,控制同步队列。只有在waitStatus=SIGNAL状态下,才会阻塞线程,每次自旋都会检测当前节点node的前置节点,是否为head
节点,如果是head节点并且能获取到锁,则跳出自旋。
1 | public final void acquire(int arg) { |
图解ReentrantLock锁竞争原理
可能看完源码还是一头雾水,我本人是看了很多次,才明白了AQS具体的执行流程。整理了一个流程图,希望大家仔细的按照图的流程梳理一遍整个流程,才能真正的理解AQS。
如图所示:
- 定义
ReentrantLock
实例,此刻head=null,tail=null,status=0 - 线程A调用
lock.lock()
,由于线程A是最先获取锁的,status=1,根据acquire
方法的逻辑,不设置任何Node到同步队列,所以head和tail依旧为null。 - 线程B调用
lock.lock()
,由于此刻status=1,所以tryAcquire
返回false。触发了addWariter
逻辑,增加了Node和NodeB两个节点,head和tail指针也指向对应的链表头和链表尾。acquireQueued
开始自旋操作,第一次自旋由于Node.waitStatus != SIGNAL
,所以设置前置节点waitStatus=SIGNAL后,继续自旋,再次判定shouldParkAfterFailedAcquire
方法,发现Node.waitStatus == SIGNAL
,这时会阻塞ThreadB。 - 线程C调用
lock.lock()
,执行逻辑同上。最终会形成Node<->NodeB<->NodeC这种的CLH同步队列。->-> - 线程A业务逻辑执行完毕,调用
lock.unlock()
,会最终调用release
方法,把status=0,并唤醒head的后继节点NodeB,NodeB唤醒之后继续自旋,最终NodeB成为head节点,跳出自旋,执行ThreadB的逻辑。 - 线程B执行完毕,调用
lock.lock()
,最终会唤醒NodeC,最终全部线程执行完毕。
Condition原理分析
通过如下代码给当前的Lock对象创建一个Condition对象。它的作用等同于Object.wait()
和Object.notify()
,条件队列是一个FIFO队列,可以通过signalAll
解锁全部的线程,也可以通过signal
单独解锁线程。
1 | ReentrantLock lock = new ReentrantLock(); |
源码分析
首先是await
的逻辑:
- 判断中断则抛出InterruptedException
- 在FIFO条件队列(condition queue)增加节点,节点状态为CONDITION,在增加节点的同时清除掉状态为CANCELED的Node
- 把该节点从同步队列(sync queue)中去除
- 判定节点是否在同步队列中,只有不在同步队列,才阻塞线程
- 阻塞node
- 当node被signal之后,把node加入到同步队列中,准备获取锁
- 移除掉所有状态为CANCELED的节点
- 报告中断,如果在signal之前线程被中断则抛出中断异常,如果在signal之后线程被中断,则再次中断,有调用代码自行处理中断标识
然后是signal
的逻辑:
- 移除掉
firstWaiter
,如果first.nextWaiter == null 则置空lastWaiter
- 转换node,从条件队列 -> 同步队列
- node状态必须满足CONDITION
- unpark线程
1 | //等待Conditon |
Condition的中断处理
在使用Condition的时候,一定要注意对中断的处理。Condition.await()
跟Object.wait()
相比,后者只支持中断抛出InterruptedException
异常,而前者即抛出InterruptedException
异常,也会再次重置中断标识位,Condition处理中断的逻辑是如果在signal之前线程被中断则抛出中断异常,如果在signal之后线程被中断,则再次中断,有调用代码自行处理中断标识。如下示例代码所示。
定义三个线程t1
,t2
,t3
,在t2
执行signal()
之前,t3
线程执行了t1.interrupt()
,则t1线程中捕获中断异常。如果不执行t3
线程并且在t2
执行signal()
之后再调用t1.interrupt()
,则t1线程不会捕获中断异常,但是Thread.currentThread().isInterrupted() == true
。
1 | /** 在退出wait之后再次中断 */ |
总结
由于ReentrantLock是AQS的最基础实现,理解ReentrantLock十分重要。通过对ReentrantLock的源码分析,可以总结为如下结论:
- ReentrantLock有公平和非公平两种模式
- ReentrantLock是可重入锁。
- ReentrantLock是一种独占锁,核心逻辑由AQS框架来提供,简单的概括为自旋+同步队列来实现独占锁
- ReentrantLock包括同步队列(sync queue)和条件队列(condition queue)两种队列
- AQS的锁标识状态为
status
字段,同步队列节点为内部类Node
,Node的状态通过waitStatus
控制,共有SIGNAL
,CANCELED
,CONDITION
,PROPAGATE
四种状态,nextWaiter
是条件队列的单向链表。 - Condition是通过阻塞线程来实现,当条件队列全部执行完毕之后,节点才会被加入到同步队列。
- AQS的自旋次数有限,当上一个节点的waitStatus=-1就会阻塞当前节点。且每次只唤醒head节点的下一个节点参与竞争锁,避免同时唤醒多个线程造成上下文切换过于频繁。
- ReentrantLock一定要记得unlock,否则会发生死锁。
ReentrantReadWriteLock
通过学习ReentrantLock,我们掌握了ReentrantLock和AQS的关系,知道了是如何通过AQS来实现一个独占锁。但是很多情况下,独占锁也是一种很重的操作,比如缓存这种读多写少的情况,多并发读取缓存值是很频繁的事情,如果采用独占锁,会大大降低缓存的吞吐。而读操作本身就不存在共享变量同步,如果资源能够同时被多个读线程访问而且能保证同步,则缓存的吞吐能力将大大提升。
那么ReentrantReadWriteLock
的出现,就是要解决以上问题的利器。读写锁的定义为:一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
ReentrantReadWriteLock总览
ReadLock 和 WriteLock 中的方法都是通过 Sync 这个类来实现的。Sync 是 AQS 的子类,然后再派生了公平模式和不公平模式。
从它们调用的 Sync 方法,我们可以看到: ReadLock 使用了共享模式,WriteLock 使用了独占模式。
同一个 AQS 实例怎么可以同时使用共享模式和独占模式???
AQS 的精髓在于内部的属性 state:对于独占模式来说,通常就是 0 代表可获取锁,1 代表锁被别人获取了,重入例外而共享模式下,每个线程都可以对 state 进行加减操作也就是说,独占模式和共享模式对于 state 的操作完全不一样,那读写锁 ReentrantReadWriteLock 中是怎么使用 state 的呢?答案是将 state 这个 32 位的 int 值分为高 16 位和低 16位,分别用于共享模式和独占模式。
1 | public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { |
源码分析
读锁获取
在 AQS 中,如果 tryAcquireShared(arg) 方法返回值小于 0 代表没有获取到共享锁(读锁),大于 0 代表获取到。
AQS共享模式:tryAcquireShared 方法不仅仅在 acquireShared 的最开始被使用,这里是 try,也就可能会失败,如果失败的话,执行后面的 doAcquireShared,进入到阻塞队列,然后等待前驱节点唤醒。唤醒以后,还是会调用 tryAcquireShared 进行获取共享锁的。当然,唤醒以后再 try 是很容易获得锁的,因为其他节点都还出于阻塞状态,此刻参与竞争的,只有被唤醒的节点或者其他新的线程。
所以,你在看下面这段代码的时候,要想象到两种获取读锁的场景,一种是新来的,一种是排队排到它的。
1 | public final void acquireShared(int arg) { |
回顾下这段代码,这个判定条件是重点if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARD_UNIT)
,共有三个判定条件:
readerShouldBlock()
在
FairSync
中,如果同步队列中有其他元素在等待锁,你就需要乖乖的去排队。1
2
3
4
5
6
7
8
9
10
11final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}在
NonFairSync
中,如果head的后继节点是写锁线程节点,则阻塞读锁的获取,先让写锁执行。如果head.next不是写锁,非公平模式下是允许竞争的。1
2
3
4
5
6
7
8
9
10
11final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null &&
(s = h.next) != null &&
!s.isShared() &&
s.thread != null;
}
r < MAX_COUNT
,static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
,MAX_COUNT一般情况下是不会达到的,最大值是65535。这个条件可以忽略不计。compareAndSetState(c, c + SHARD_UNIT)
,CAS竞争失败,可以另一个读锁竞争,也可能是写锁操作竞争。
1 | /** |
读锁释放
读锁释放的过程还是比较简单的,主要就是将 hold count 减 1,如果减到 0 的话,还要将 ThreadLocal 中的 remove 掉。
然后是在 for 循环中将 state 的高 16 位减 1,如果发现读锁和写锁都释放光了,那么唤醒后继的获取写锁的线程。
1 | //共享模式release |
写锁获取
先说重点
1、写锁是独占锁
2、如果读锁被占用,写锁获取时是要进入阻塞队列中等待的
1 | protected final boolean tryAcquire(int acquires) { |
写锁释放
写锁释放就很简单了,独占锁的释放是线程安全的,不需要CAS,就是把state - 1。
1 | protected final boolean tryRelease(int releases) { |
锁降级
Doug Lea 没有说写锁更高级,如果有线程持有读锁,那么写锁获取也需要等待。
不过从源码中也可以看出,确实会给写锁一些特殊照顾。如非公平模式下,为了提高吞吐量,write.lock的时候不需要管同步队列中是否存在等待节点,直接CAS去竞争。read.lock的时候,如果发现 head.next 是获取写锁的线程,就会建议阻塞读锁。
Doug Lea 将持有写锁的线程,去获取读锁的过程称为锁降级(Lock downgrading)。这样,此线程就既持有写锁又持有读锁。
但是,锁升级是不可以的。线程持有读锁的话,在没释放的情况下不能去获取写锁,因为会发生死锁。
注意:读写锁经常在这种地方造成死锁,一定要真正的理解这些思想,才能在使用的时候游刃有余
回去看下写锁获取的源码:
1 | //锁不能升级的实现 |
如果线程 a 先获取了读锁,然后获取写锁,那么线程 a 就到阻塞队列休眠了,自己把自己弄休眠了,而且可能之后就没人去唤醒它,造成死锁。
如果线程 a 先获取了写锁,然后获取读锁,那么线程 a 就不会被阻塞,继续有机会获取读锁。
死锁代码
读写锁虽然适合读多写少的场景,提高了吞吐能力。但是其使用难度比ReentrantLock
更高,必须要细心谨慎的操作锁,一旦出现死锁情况,只能靠重启进程来解决。设想一下刚重启完进程2分钟又进入死锁,线程池被占满又要重启的恐怖场景!所以,使用锁是一把双刃剑,要仔细检查,反复测试。
以下列出一些可能造成死锁的代码:
Case1: 锁升级造成死锁。一个线程要获取读锁之后,想要获取写锁前一定要先unlock读锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public static void main(String[] args) {
try {
System.out.println("获取读锁");
lock.readLock().lock();
System.out.println("获取写锁");
lock.writeLock().lock();
lock.writeLock().unlock();
System.out.println("释放写锁");
lock.readLock().unlock();
} catch (Exception e) {
e.printStackTrace();
} finally {
}
}
Case2: lock两次, unlock一次,由于粗心大意。一定要多次检查lock和unlock是不是成对出现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public static void main(String[] args) {
try {
Random random = new Random();
lock.writeLock().lock();
lock.writeLock().lock();
System.out.println("获取写锁");
lock.readLock().lock();
System.out.println("获取读锁");
lock.readLock().unlock();
System.out.println("释放读锁");
lock.writeLock().unlock();
new Thread(() -> {
lock.readLock().lock();
System.out.println("另外线程获取读锁");
lock.readLock().unlock();
}).start();
System.out.println("释放写锁" + lock.writeLock().isHeldByCurrentThread());
} catch (Exception e) {
e.printStackTrace();
} finally {
}
}
总结
1、读写锁分为了读锁和写锁,可以多个线程共享读锁,但是只有一个线程能获取写锁。在获取写锁之后,读锁都会在同步队列中等待写锁释放。这样做最大化的提高了吞吐性能的同时,保证了数据一致性。
2、持有写锁的线程获取读锁的过程叫做锁降级,持有读锁的线程获取写锁的过程叫做锁升级。读写锁只能锁降级,锁升级会造成死锁。
3、读锁通过AQS共享模式实现,写锁通过AQS独占模式实现。AQS的status
, 分为高 16 位和低 16位,分别用于共享模式和独占模式。
4、读锁和写锁最大是65535,超过这个值会抛出Error
。当然一般是不会超过的。
AbstractQueuedSynchronizer
相信大家在仔细研读可重入锁和读写锁的实现之后,对AQS已经不再陌生了。
AQS作为JUC中最基础的框架,光说它就可以写一篇论文了。本文不打算AQS长篇阔论,而是把上文中讲述的AQS知识再总结提炼一下。能看懂ReentrantLock和ReentrantReadWriteLock,就对AQS的精髓有一定掌握了。
1 | /** |
总结
JUC的出现,大大降低了并发编程的难度,但是如果只是停留在会用的层面是不够的。希望本文能够给读者带来启发,也希望大家能够踊跃帮我挑出文章中不对的地方,大家一起进步。最后,写本篇的时候,也阅读了大量的其他博主写的文章,有些博主写的很好,我就直接摘抄了过来,方便自己日后review。感谢他们!
引用
The java.util.concurrent Synchronizer Framewor论文