13. AbstractQueuedSynchronized之AQS
13.1 前置知识
- 公平锁和非公平锁
- 可重入锁
- 自旋思想
- LockSupport
- 数据结构之双向链表
- 设计模式之模板设计模式
13.2 AQS入门级别理论知识
- AQS是什么?
- 字面意思:抽象的队列同步器,实现了通知唤醒的机制
- 源代码
- 技术解释
- 是用来实现锁或者其它同步器组件的公共基础部分的抽象实现,是重量级基础框架及整个JUC体系的基石,主要用于解决锁分配给"谁"的问题
- 整体就是一个抽象的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态
- 为实现阻塞锁和相关的同步器提供一个框架,它是依赖于先进先出的一个等待。依靠单个原子int值来表示状态,通过占用和释放方法,改变状态值
- AQS是JUC基石
- 进一步理解锁和同步器的关系
- 锁,面向锁的使用者:定义了程序员和锁交互的使用层APl,隐藏了实现细节,你调用即可。
- 同步器,面向锁的实现者:Java并发大神DougLee,提出统一规范并简化了锁的实现,将其抽象出来屏蔽了同步状态管理、同步队列的管理和维护、阻塞线程排队和通知、唤醒机制等,是一切锁和同步组件实现的--------公共基础部分
- 能做什么?
- 加锁会导致阻塞:在阻塞就需要排队,实现排队必然需要队列
- 解释说明:
- 抢到资源的线程直接使用处理业务,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。
- 既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?
- 如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点==(Node)==,通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。
- 总结:AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改。
- 基本结构:
13.3 AQS源码分析前置知识储备
- AQS本质:双向队列+state状态位,内部靠Node节点形成队列
- AQS内部体系架构
- AQS自身:
- AQS的int变量volatile int state,也就是13.2.2图中的state
- AQS的CLH队列:也就是13.2.2图中的队列。三个科学家名字拼起来的。
- 属性:头尾指针外加state属性
private transient volatile Node head; private transient volatile Node tail; private volatile int state;
- 小总结:
- 有阻塞就需要排队,实现排队必然需要队列
- state变量+CLH双端队列
- AQS内部类Node:
等候区其它顾客(其它线程)的等待状态,队列中每个排队的个体就是一个 Node// 共享 static final Node SHARED = new Node(); // 独占 static final Node EXCLUSIVE = null; // 线程被取消了 static final int CANCELLED = 1; // 后继线程需要唤醒 static final int SIGNAL = -1; // 等待condition唤醒 static final int CONDITION = -2; // 共享式同步状态获取将会无条件地传播下去 static final int PROPAGATE = -3; // 初始为0,状态是上面的几种 volatile int waitStatus; // 很重要 // 前置节点 volatile Node prev; // 后置节点 volatile Node next; volatile Thread thread;
- Node属性说明:
13.4 AQS源码深度讲解和分析
此部分:AQS源码分析——以ReentrantLock为例
13.5 源码分析总结
14. ReentrantLock、 ReentrantReadWriteLock、 StampedLock讲解
- 本章主线:无锁——独占锁——读写锁——邮戳锁
14.1 简单聊聊ReentrantReadWriteLock
- 是什么
- 是ReadWriteLock的实现类
- 读写锁定义为:一个资源能够被多个國线程访间,或者被工个写线程访问,但是不能同时存在读写线程。
- 简单说,一锁多用;一体两面,读写互斥,读读共享
- 演变:无锁无序——加锁——读写锁演变
- 写线程饥饿问题
- 锁降级
- 读写锁意义和特点:
- 『读写锁ReentrantReadWriteLock』并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是“读/读”线程间并不存在互斥关系,只有"读/写"线程或"写/写"线程间的操作需要互斥的。因此引入ReentrantReadWriteLock。
- 一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁(切菜还是拍蒜选一个)。也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
- 只有在读多写少情境之下,读写锁才具有较高的性能体现。
- 锁降级
- 锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。
- Java8 官网说明:
重入还允许通过获取写入锁定,然后读取锁然后释放写锁从写锁到读取锁, 但是,从读锁定升级到写锁是不可能的。 - 写锁的降级,降级成为了读锁
- 目的:锁降级是为了让当前线程感知到数据的变化,目的是保证数据可见性
- 如果同一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁。这就是写锁的降级,降级成为了读锁。
- 规则惯例,先获取写锁,然后获取读锁,再择放写锁的 次序。
- 如果释放了写锁,那么就完全转换为读锁。
- 锁降级特性:
- 公平性选择:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平
- 重进入:该锁支持重进人,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁
- 锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁
- 代码演示:见https://blog.csdn.net/lannister_awalys_pay/article/details/131078204 9.4
- 不可锁升级:
- 小总结:
- 写锁和读锁是互斥的(这里的互斥是指线程间的互斥,当前线程可以获取到写锁又获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性。因为,如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。
- 因此,分析读写锁ReentrantReadWriteLock,会发现它有个潜在的问题:读锁全完,写锁有望;写锁独占,读写全堵;如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,见前面Case《code演示LockDownGradingDemo》即ReadWriteLock读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁,也就是写入必须等待,这是一种悲观的读锁,o(╥﹏╥)o,人家还在读着那,你先别去写,省的数据乱。
- =========后续讲解StampedLock时再详细展开===========
分析StampedLock(后面详细讲解),会发现它改进之处在于:读的过程中也允许获取写锁介入(相当牛B,读和写两个操作也让你“共享”(注意引号)),这样会导致我们读的数据就可能不一致!所以,需要额外的方法来判断读的过程中是否有写入,这是一种乐观的读锁,O(∩_∩)O哈哈~。 显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
- 锁降级设计思想:
- 简单讲:希望写后可以立刻读,不希望被其他写线程抢占并修改。那么释放写锁之前先加读锁。
- 锁降级 下面的示例代码摘自ReentrantWriteReadLock源码中:ReentrantWriteReadLock支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。解读在最下面:
- 解读:
- 代码中声明了一个volatile类型的cacheValid变量,保证其可见性。
- 首先获取读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true,然后在释放写锁前获取读锁;此时,cache中数据可用,处理cache中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
- 如果违背锁降级的步骤 如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。
- 如果遵循锁降级的步骤 线程C在释放写锁之前获取读锁,那么线程D在获取写锁时将被阻塞,直到线程C完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。
14.2 邮戳锁(版本锁)
- 比读写锁更快的锁与锁饥饿问题相关优化
- 是什么?
- StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化。
- 邮戳锁,也叫票据锁
- stamp (戳记, Iong类型):代表了锁的状态。当stamp返回零时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的stamp值。
- 解决问题:写锁饥饿问题;公平策略可以解决,但是牺牲了吞吐量
- StampedLock类的乐观读锁闪亮登场
- ReentrantReadWriteLock的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。但是,StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验。
- 简单说:读锁默认不会有写锁对数据进行修改,但是读完会校验版本,如果被改了则此次读作
- StampedLock特点:
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零表示获取失败,其余都表示成功:
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致:
- StampedLock是不可重入的,危险(如果一个线程己经持有了写锁,再去获取写锁的话就会造成死锁)
- StampedLock有三种访问模式
- Reading(读模式悲观):功能和ReentrantReadWriteLock的读锁类似
- Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
- Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
- API:基本和读写锁一致,多了一个validate校验方法
- 代码演示:
-
stampedLock完全可以作为读写锁用:
//悲观读 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 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 static void main(String[] args) { StampedLockDemo resource = new StampedLockDemo(); //1 悲观读,和ReentrantReadWriteLock一样 new Thread(() -> { //悲观读 resource.read(); },"readThread").start(); new Thread(() -> { resource.write(); },"writeThread").start(); } 修改是失败的,读取成功
-
乐观读:
// 乐观读 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); }
-
- stampedLock缺点:
- StampedLock 不支持重入,没有Re开头
- StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意。
- 使用 StampedLock一定不要调用中断操作,即不要调用interrupt( )方法