- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。
内容大纲
- AQS、同步等待队列、条件等待队列、条件接口
- ReentrantLock可重入锁
- 闭锁、栅栏、信号量
- ReentrantReadWriteLock读写锁
1.AQS基础 - 抽象队列同步器
AQS是JUC包下面的一个抽象类,AbstractQueuedSynchronizer抽象队列同步器,AQS是一个抽象同步框架,它的底层已经实现了并发同步相关的操作,例如阻塞、唤醒等机制。我们一般可以基础AQS或者用内部类的方式来使用它。AQS的特性具有:
- 阻塞等待队列
- 共享模式、独占模式
- 公平锁、非公平锁(非公平效率更高,减少了线程的内核态陷入)
- 可重入
- 可中断
AQS有一个核心变量volatil int state,这个state表示资源的可用状态,可以获取它,也可以CAS方式设置它。
AQS有两种队列:
- 同步等待队列:所有竞争锁失败的线程入队。
- 条件等待队列:调用await()释放锁资源的线程入队,调用signal()唤醒线程,线程再进入同步等待队列来再次尝试获取锁。
AQS定义了5种队列中Node节点的状态:
1. 值为0,代表节点处于初始化状态,处于同步队列中,等待获取锁。
1. cancelled = 1,表示当前线程被取消。
1. signal = -1,表示当前节点的后继节点线程需要唤醒unpark。
1. condition = -2,表示当前节点在等待condition条件,处于条件队列中。
1. propagate = -3,表示当前状况下,后续调用acuireShared能够执行。
AQS提供的核心抽象方法有:
- tryAcquire():独占,尝试获取锁,成功返回true。
- tryRelease():独占,尝试释放锁,成功返回true。
- tryAcquireShared():共享,尝试获取资源,成功返回>0的正数。
- tryReleaseShared():共享,尝试释放资源,如果允许唤醒后续等待节点,返回true。
1.1 同步等待队列
AQS和synchronized类似,当发生线程竞争时,获取不到锁的线程会进入一个队列。AQS的等待队列结构类似于CLH(Craig、Landin、Hagersten三位大佬发明)一种基于双向链表结构的队列FIFO模式。
- 当线程竞争锁失败时,AQS将这些线程封装成Node节点,放到同步队列中FIFO,并park阻塞线程。
- 当当前节点释放锁后,会唤醒队列的头结点工作,至于是唤醒执行还是抢锁,取决于是否公平。
- 其它线程可以通过调用node节点的signal()、signalAll()可以将条件队列中的节点转移到同步队列中等待唤醒。
1.2 条件等待队列
条件队列基于Condition接口实现,条件接口提供了await()、signal()、signalAll等方法来基于条件的同步唤醒机制,相比于synchronized只有1个条件队列来说,Condition可以支持多个条件,那么就有多个条件队列。
2.ReentrantLock可重入锁
ReentrantLock核心功能基于AQS完成,具有:可中断、可设置超时事件、支持公平/非公平锁、支持多个条件变量、支持重入、加解锁更加灵活。
2.1 和synchronized比较
- 内置锁基于JVM原语,ReentrantLock基于JDK代码层实现。
- 内置锁状态对程序不可见,ReentrantLock锁状态程序可获取、可见。
- 内置锁是非公平,ReentrantLock支持公平和非公平。
- 内置锁不可被中断指令中断,ReentrantLock支持中断#lockInterruptibly()。
- 内置锁发生异常时,自动释放锁,ReentrantLock发生异常不会主动释放,需要自行处理。
- 内置锁的竞争队列是栈FILO结构,ReentrantLock是FIFO。
2.2 #Lock()上锁流程
ReentrantLock内部有一个
Sync内部抽象类其继承了AQS,NonfairSync和FairSync两个内部类继承了Sync,提供了公平和非公平的加解锁模式。公平锁在加锁的时候体现在是否立即抢锁,它会判断阻塞队列的存在。非公平就十分的暴力和急迫,在各个阶段都尝试拿锁,是在拿不到,迫不得已入队阻塞。
3.AQS同步等待队列工作原理
3.1 AQS在没有任何线程加锁时
3.2 当T1线程加锁,通过CAS修改state成功
3.3 当T1未释放,T2来加锁,加锁失败、初始化队列、入队、阻塞
3.4 当T1未释放,T3来加锁,加锁失败、入队、阻塞
3.5 T1释放锁,唤醒T2
4.信号量 Semaphore
信号量是操作系统中PV操作的原语在Java语言层面的一种实现,底层基于AQS实现。信号量用一个基数来表示资源的可用数量,每个线程可以获取一个或多个资源,资源为0时,请求的线程将阻塞。信号量常常用在限流的业务场景中。Semaphore s = new Semaphore(5);
4.1 信号量常用方法
- void acquire() 阻塞模式获取资源。
- boolean tryAcquire() 尝试获取资源,没有资源返回false。
- void release() 释放资源。
- int availablePermits() 返回信号量中当前可用的资源数量。
- int getQueueLength() 返回正在等待获取资源的线程数量。
- void reducePermit(int n) 减少n个资源。
- Collection getQueuedThreads() 返回所有等待获取资源的线程集合。
4.2 信号量的AQS实现
1. 线程尝试获取资源,检查资源是否充足,通过CAS机制进行修改state资源数量。
1. 当线程遇到扣减失败,也就是资源不足时会进行入队park()。
1. 入队前是否尝试再次获取,取决于是否公平,可以通过构造设置。
1. 当有线程释放资源时,会尝试唤醒后继节点。
1. 如果后继节点类型是SHARED(共享型)则唤醒的动作会向后传播,这是和lock不一样的地方。
1. 共享型节点向后传播唤醒的原因是提高并发量,因为共享资源的释放有可能会释放多个,一次性唤醒多个有助于提高并发量,因为只唤醒一个的话,有可能被唤醒的这个消耗不了这么多共享资源,造成共享资源没人去抢。
5.闭锁(发令枪) CountDownLatch
闭锁常常用来实现模拟线程的并发,它可以让一组线程等待,然后一起并发执行;也可以让主线程等待子线程执行到某一个点的场景。类似的场景就像是百米赛跑的发令枪。闭锁持有一个计数器count(state),当有线程countDown一下,计数器就会减1,当计数器减为0时所有的线程都会被释放。闭锁的计数器不会清零和重置,这一点和栅栏CyclicBarrier最大的不同。CountDownLatch c = new CountDownLatch(5);
5.1 闭锁的AQS实现
闭锁底层是靠AQS实现,但是闭锁没有提供公平和非公平的实现策略。闭锁的count计数器就是AQS的state标识字段,每次线程调用countDown()计数器就减1。await()会阻塞当前线程,直至计数器为0时有线程unpark他们。
5.2 闭锁和Thread.join()的区别
- 闭锁和join()都能实现线程的等待,但是一个闭锁可以对应多个线程的等待,join是针对单个。
- join()底层原理是不断检查线程的存活状态来实现,而线程的存活可能存在假死等。
5.3 闭锁和栅栏 CyclicBarrier的区别
- 闭锁和栅栏都能实现单个or多个线程的等待,不过他们的目标初衷不同。
- 闭锁的计数器只能使用一次,栅栏的计数器可以重置复位使用多次基数。
- 闭锁能阻塞主线程,栅栏只会阻塞子线程。
- 闭锁的目的是让多个线程执行完任务后再一起执行,栅栏是让一组线程进入到某个等待状态然后一起执行。
- 闭锁是通过AQS共享锁实现,栅栏是通过ReentrantLock和Conditon组合完成。
6.栅栏 CyclicBarrier
栅栏可以实现让一组线程等待至某个状态后,再一起执行;当所有线程释放执行后,栅栏又可以循环利用,这是和闭锁最大的区别,底层也不单单是AQS实现,而是采用ReentrantLock和Condition实现。栅栏有个别称叫循环屏障。CyclicBarrier c = new CyclicBarrier(5);
CyclicBarrier c = new CyclicBarrier(5, Runnable barrierAction);
6.1 栅栏的重要方法
- public CyclicBarrier(int parties, Runnable barrierAction)和CyclicBarrier(int parites),parites代表线程数量,线程通过await()告诉栅栏它到达了屏障点。barrierAction标识线程到达屏障前优先执行barrierAction。
- public int await();parties个线程调用了await()后,栅栏将释放,所有线程继续执行。不过这个方法有BrokenBarrierException异常,肯能是因为某个线程await()被中断或超时。
- public int await(long timeout, TimeUnit unit);
- public void reset();重置栅栏。
6.2 栅栏的实现
- 通过parites拷贝AQS的state变量副本来完成栅栏的循环使用重置,state不会改变,改变的是parites副本。
- 线程调用await()会进行lock.lock(),parites减1,然后进入到条件队列。
- 线程释放lock锁后,会进行条件队列的park()。
- 条件队列是一个单向链表,先来的线程在头,尾插法。
- 当线程调用await(),parites-1,parites==0的时候调用条件队列的signalAll(),会将条件队列 按头至尾依次放入AQS的同步等待队列中,头结点就是当前活跃线程,然后unpark()所有节点。(条件队列的singal/singalAll是将条件队列的节点放入同步队列,等待同步队列的unpark()才会被从头唤醒)
7.读写锁的使用和结构
在多线程访问同一个资源的情况下要保证线程安全性问题还要保证性能可以采用JDK提供的读写锁,读写锁更加适合在读远多于写的场景。读写锁的特点是:读读并发、读写互斥、写读互斥、写写互斥。在读大于写的并发场景,读写锁比ReentrantLock性能高很多。读写锁内部维护了2个锁,一个负责读锁,一个负责写锁。读锁在同一时刻可以由多个读线程持有。读锁是共享的,写锁是独占的。
8.读写锁设计
8.1 锁状态的记录
在AQS中通过state值来标识锁的状态,读写锁因为有两把锁,所以需要将state一个变量来表示两个锁的状态。通过int的高低位来表示,高16位表示读锁,每多一个线程持有高位就+1。低16位表示写锁,线程每重入一次,低16位+1。读锁的重入次数是通过ThreadLocal来记录的。
8.2 HoldCounter计数器
读锁的重入是依靠ThreadLocalHoldCounter类来和持有读锁的线程绑定,通过ThreadLocal来记录线程持有读锁的重入次数。
8.3 写锁加锁
8.4 读锁加锁
9.锁的降级
读写锁的锁降级指的是写锁降级为读锁,如果当前线程持有写锁,然后再获取读锁,然后释放写锁的过程。锁降级的目的是为了保证数据的可见性问题。如果读锁被多个线程持有,其中任意一个线程获取了写锁并更新了值那么这个更新后的值对其他读锁线程是不可见的。写锁 ->读锁 -> 释放写锁。
10.锁的饥饿
在读远大于写、高并发的场景下,读锁由于是共享的,大量线程的读请求是并发执行,但是无法有另外线程加写锁,写锁一直处于互斥状态,容易造成写锁的饥饿。
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。