JUC并发编程与源码分析
一、AQS(AbstractQueuedSynchronizer) 抽象队列同步器
前置知识:
- 公平锁和非公平锁
- 可重入锁
- 自旋锁
- LockSupport
- 数据结构之链表
- 设计模式之模板设计模式
1.1 是什么?
- 抽象的队列同步器
- 是用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态
1.2 AQS为什么是JUC内容中最重要的基石
1.2.1 和AQS有关的
- ReetrantLock
- CountDownLatch
- ReentrantReadWriteLock
- Semaphore
- 。。。
1.2.2 进一步理解锁和同步器的关系
锁,面向锁的使用者
定义了程序员和锁交互的使用层API,隐藏了实现细节,调用即可。
同步器,面向锁的实现者
Java并发大神DougLee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等。
1.3 能干嘛?
- 加锁会导致阻塞
- 有阻塞就需要排队,实现排队必然需要队列
- 解释说明
抢到资源的线程直接使用处理业务,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。
既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。
1.4 AQS初步
1.4.1 AQS初识
官网解释
有阻塞就需要排队,实现排队必然需要队列
AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改。
1.4.2 AQS内部体系架构
1.4.2.1 AQS的int变量
- AQS的同步状态State成员变量
- 银行办理业务的受理窗口状态
- 零就是没人,自由状态可以办理
- 大于等于1,有人占用窗口,等着去
1.4.2.2 AQS的CLH队列
- CLH队列(三个大牛的名字组成),为一个双向队列
1.4.2.3 小总结
- 有阻塞就需要排队,实现排队必然需要队列
- state变量+CLH双端队列
1.4.2.4 内部类Node(Node类在AQS类内部)
Node的int变量
- Node的等待状态waitState成员变量
- 等候区其它顾客(其它线程)的等待状态
- 队列中每个排队的个体就是一个
内部结构
属性说明
1.4.3 AQS同步队列的基本结构
1.5 从ReetrantLock开始解读AQS
- Lock接口的实现类,基本都是通过【聚合】了一个【队列同步器】的子类完成线程访问控制的
1.5.1 ReetranLock的原理
1.5.2 从最简单的lock方法开始看看公平和非公平
1.5.3 非公平锁,方法lock()
对比公平锁和非公平锁的 tryAcquire()方法的实现代码,其实差别就在于非公平锁获取锁时比公平锁中少了一个判断 !hasQueuedPredecessors()
hasQueuedPredecessors() 中判断了是否需要排队,导致公平锁和非公平锁的差异如下:
公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中;
非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)
lock()
acquire()
tryAcquire(arg)
- 本次走非公平锁
- return false
继续推进条件,走下一个方法 - return true
抢锁成功
addWaiter(Node.EXCLUSIVE)
-
addWaiter(Node mode)
-
enq(node);
-
双向链表中,第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是从第二个节点开始的。
-
假如3号ThreadC线程进来
- prev
- compareAndSetTail
- next
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
- acquireQueued
- 假如再抢抢失败就会进入
- shouldParkAfterFailedAcquire 和 parkAndCheckInterrupt 方法中
- 如果前驱节点的 waitStatus 是 SIGNAL状态,即 shouldParkAfterFailedAcquire 方法会返回 true 程序会继续向下执行 parkAndCheckInterrupt 方法,用于将当前线程挂起
- shouldParkAfterFailedAcquire 和 parkAndCheckInterrupt 方法中
1.5.4 unlock()
-
sync.release(1);
-
tryRelease(arg)
-
unparkSuccessor
总结
流程图
https://www.processon.com/view/5e29b0e8e4b04579e40c15a7
二、ReentrantLock、ReentrantReadWriteLock、StampedLock
2.1 是什么?
读写锁定义为
一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
演变
读写锁 意义
『读写锁ReentrantReadWriteLock』并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是“读/读”线程间并不存在互斥关系,只有"读/写"线程或"写/写"线程间的操作需要互斥的。因此引入ReentrantReadWriteLock。
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁。也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。
2.2 特点
- 可重入
- 读写分离
无锁无序->加锁-读写锁case
class MyResource {
Map<String,String> map = new HashMap<>();
//=====ReentrantLock 等价于 =====synchronized
Lock lock = new ReentrantLock();
//=====ReentrantReadWriteLock 一体两面,读写互斥,读读共享
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public void write(String key,String value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"\t"+"---正在写入");
map.put(key,value);
try { TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t"+"---完成写入");
}finally {
rwLock.writeLock().unlock();
}
}
public void read(String key) {
rwLock.readLock().lock();
try
{
System.out.println(Thread.currentThread().getName()+"\t"+"---正在读取");
String result = map.get(key);
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t"+"---完成读取result: "+result);
}finally {
rwLock.readLock().unlock();
}
}
}
public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
MyResource myResource = new MyResource();
for (int i = 1; i <=10; i++) {
int finalI = i;
new Thread(() -> {
myResource.write(finalI +"", finalI +"");
},String.valueOf(i)).start();
}
for (int i = 1; i <=10; i++) {
int finalI = i;
new Thread(() -> {
myResource.read(finalI +"");
},String.valueOf(i)).start();
}
for (int i = 1; i <=3; i++) {
int finalI = i;
new Thread(() -> {
myResource.write(finalI +"", finalI +"");
},"马羽成"+ i).start();
}
}
}
从写锁->读锁,ReetrantReadWriteLock可以降级
- 锁降级:将写入锁降级为读锁(类似Linux文件读写权限理解,就像写权限要高于读权限一样)
《Java 并发编程的艺术》中关于锁降级的说明:
- 锁的严苛程度变强叫做升级,反之叫做降级
读写锁降级演示
锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。
如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。
锁降级是为了让当前线程感知到数据的变化,目的是保证数据可见性
Java8 官网说明
重入还允许通过获取写入锁定,然后读取锁然后释放写锁从写锁到读取所
code
/**
* 锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。
*
* 如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。
*/
public class LockDownGradingDemo {
public static void main(String[] args) {
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//先获取写锁可以再获取读锁
/*writeLock.lock();
readLock.lock();
System.out.println("写锁 -> 读锁");
writeLock.unlock();
readLock.unlock();*/
//先获取读锁,只有释放读锁才能获取写锁
readLock.lock();
System.out.println("获取读锁");
writeLock.lock();
System.out.println("写锁 -> 读锁");
writeLock.unlock();
readLock.unlock();
}
}
结论
如果有线程在读,那么写线程是无法获取写锁的,是悲观锁的策略
线程获取读锁是不能直接升级为写入锁的。
在ReentrantReadWriteLock中,当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。
所以,需要释放所有读锁,才可获取写锁,
写锁和读锁是互斥的
写锁和读锁是互斥的(这里的互斥是指线程间的互斥,
当前线程可以获取到写锁又获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性。
因为,如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。
因此,
分析读写锁ReentrantReadWriteLock,会发现它有个潜在的问题:
读锁全完,写锁有望;写锁独占,读写全堵;
如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,见前面Case《code演LockDownGradingDemo》
即ReadWriteLock读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁,也就是写入必须等待,这是一种悲观的读锁,o(╥﹏╥)o,人家还在读着那,你先别去写,省的数据乱。
分析StampedLock会发现它改进之处在于:
读的过程中也允许获取写锁介入(相当牛B,读和写两个操作也让你“共享”(注意引号)),这样会导致我们读的数据就可能不一致!
所以,需要额外的方法来判断读的过程中是否有写入,这是一种乐观的读锁,显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
Oracle公司ReentrantWriteReadLock源码总结
锁降级 下面的示例代码摘自ReentrantWriteReadLock源码中:
ReentrantWriteReadLock支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。
解读在最下面:
2.3 邮戳锁StampedLock
- 无锁→独占锁→读写锁→邮戳锁
2.3.1 是什么?
- StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化。
- 邮戳锁,也叫票据锁
- stamp(戳记,long类型)
- 代表了锁的状态。当stamp返回零时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的stamp值。
2.3.2 锁饥饿问题
锁饥饿问题:
ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,
假如当前1000个线程,999个读,1个写,有可能999个读取线程长时间抢到了锁,那1个写线程就悲剧了
因为当前有可能会一直存在读锁,而无法获得写锁,根本没机会写,o(╥﹏╥)o
如何解决锁饥饿问题?
- 使用“公平”策略可以一定程度上缓解这个问题
- new ReentrantReadWriteLock(true);
- 但是“公平”策略是以牺牲系统吞吐量为代价的
2.3.3 StampedLock类的乐观读锁闪亮登场
ReentrantReadWriteLock
允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,
读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的synchronized速度要快很多,
原因就是在于ReentrantReadWriteLock支持读并发
StampedLock横空出世
ReentrantReadWriteLock的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。
但是,StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验。
2.3.4 StampedLock的特点
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零表示获取失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
- StampedLock是不可重入的,危险(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
StampedLock的三种访问模式
- ①Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
- ②Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
- ③Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
乐观读code演示
- 读的过程中也允许获取写锁介入
public class StampedLockDemo
{
static int number = 37;
static StampedLock stampedLock = new StampedLock();
public void write()
{
long stamp = stampedLock.writeLock();
System.out.println(Thread.currentThread().getName()+"\t"+"=====写线程准备修改");
try
{
number = number + 13;
}catch (Exception e){
e.printStackTrace();
}finally {
stampedLock.unlockWrite(stamp);
}
System.out.println(Thread.currentThread().getName()+"\t"+"=====写线程结束修改");
}
//悲观读
public void read()
{
long stamp = stampedLock.readLock();
System.out.println(Thread.currentThread().getName()+"\t come in readlock block,4 seconds continue...");
//暂停4秒钟线程
for (int i = 0; i <4 ; i++) {
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t 正在读取中......");
}
try
{
int result = number;
System.out.println(Thread.currentThread().getName()+"\t"+" 获得成员变量值result:" + result);
System.out.println("写线程没有修改值,因为 stampedLock.readLock()读的时候,不可以写,读写互斥");
}catch (Exception e){
e.printStackTrace();
}finally {
stampedLock.unlockRead(stamp);
}
}
//乐观读
public void tryOptimisticRead()
{
long stamp = stampedLock.tryOptimisticRead();
//先把数据取得一次
int result = number;
//间隔4秒钟,我们很乐观的认为没有其他线程修改过number值,愿望美好,实际情况靠判断。
System.out.println("4秒前stampedLock.validate值(true无修改,false有修改)"+"\t"+stampedLock.validate(stamp));
for (int i = 1; i <=4 ; i++) {
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t 正在读取中......"+i+
"秒后stampedLock.validate值(true无修改,false有修改)"+"\t"
+stampedLock.validate(stamp));
}
if(!stampedLock.validate(stamp)) {
System.out.println("有人动过--------存在写操作!");
//有人动过了,需要从乐观读切换到普通读的模式。
stamp = stampedLock.readLock();
try {
System.out.println("从乐观读 升级为 悲观读并重新获取数据");
//重新获取数据
result = number;
System.out.println("重新悲观读锁通过获取到的成员变量值result:" + result);
}catch (Exception e){
e.printStackTrace();
}finally {
stampedLock.unlockRead(stamp);
}
}
System.out.println(Thread.currentThread().getName()+"\t finally value: "+result);
}
public static void main(String[] args)
{
StampedLockDemo resource = new StampedLockDemo();
//1 悲观读,和ReentrantReadWriteLock一样
/*new Thread(() -> {
//悲观读
resource.read();
},"readThread").start();*/
//2 乐观读,成功
/*new Thread(() -> {
//乐观读
resource.tryOptimisticRead();
},"readThread").start();
//6秒钟乐观读取resource.tryOptimisticRead()成功
try { TimeUnit.SECONDS.sleep(6); } catch (InterruptedException e) { e.printStackTrace(); }*/
//3 乐观读,失败,重新转为悲观读,重读数据一次
new Thread(() -> {
//乐观读
resource.tryOptimisticRead();
},"readThread").start();
//2秒钟乐观读取resource.tryOptimisticRead()失败
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(() -> {
resource.write();
},"writeThread").start();
}
}
2.3.5 StampedLock的缺点
- StampedLock 不支持重入,没有Re开头
- StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意。
- 使用 StampedLock一定不要调用中断操作,即不要调用interrupt() 方法