大厂学苑之JUC并发编程与源码分析(下)
11. Java对象内存布局和对象头
11.1 先从阿里及其它大厂面试题说起
- 你觉得目前面试,你还有那些方面理解的比较好,我没问到的,我说了juc和 jym以及同步锁机制
- 那先说juc吧,说下aqs的大致流程
- CAS自旋锁,是获取不到锁就一直自旋吗? cas和synchronized区别在哪里,为什么cas好,具体优势在哪里,我说cas避免cpu切换线程的开销,又问我在自旋的这个线程能保证一直占用cpu吗?假如cpu放弃了这个线程,不是还要带来线程再次抢占cpu的开销?
- synchronized底层如何实现的,实现向步的时候用到cas了吗?具体哪里用到了
- 我说上个问题的时候说到了对象头,问我对象头存储哪些信息,长度是多少位存储
- Object object = new Object()谈谈你对这句话的理解?一般而言JDK8按照默认情况下,new一个对象占多少内存空间
- 位置所在 JVM里堆→新生区→伊甸园区
- 构成布局 头体?想想我们的HTML报文
Object object = new Object() 谈谈你对这句话的理解?
一般而言JDK8按照默认情况下,new一个对象占多少内存空间
11.2 对象在堆内存中布局
权威定义
- 周志明老师JVM第3版

对象在堆内存中的存储布局
对象内部结构分为:对象头、实例数据、对齐填充(保证8个字节的倍数)。
对象头分为对象标记(markOop)和类元信息(klassOop),类元信息存储的是指向该对象类元数据(klass)的首地址。

1. 对象头
对象头多大
在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节。
对象头组成
- 对象标记Mark Word

HotSpot虚拟机对象头Mark Word
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间截、对象分代年龄 | 01 | 可偏向 |
在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节
Mark Word的存储结构

- 类元信息(又叫类型指针)
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2. 实例数据
存放类的属性(Field)数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
3. 对齐填充
虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐这部分内存按8字节补充对齐。
11.3 再说对象头的MarkWord
- 32位(看一下即可,不用学了,以64位为准)

64位重要


- oop.hpp

- markOop.hpp
hash: 保存对象的哈希码
age: 保存对象的分代年龄
biased_lock: 偏向锁标识位
lock: 锁状态标识位
JavaThread* :保存持有偏向锁的线程ID
epoch: 保存偏向时间戳

- markword(64位)分布图,对象布局、GC回收和后面的锁升级就是对象标记MarkWord里面标志位的变化

11.4 聊聊Object obj = new Object()
JOL证明
- JOL官网: http://openjdk.java.net/projects/code-tools/jol/
- 定位:分析对象在JVM的大小和分布
1 | <dependency> |
小试一下
1 | package com.atguigu.juc.senior.inner.object; |

代码测试
1 | package com.atguigu.juc.prepare; |

| 属性 | 作用 |
|---|---|
| OFFSET | 偏移量,也就是到这个字段位置所占用的byte数 |
| SIZE | 后面类型的字节大小 |
| TYPE | 是Class中定义的类型 |
| DESCRIPTION | DESCRIPTION是类型的描述 |
| VALUE | VALUE是TYPE在内存中的值 |
GC年龄采用4位bit存储,最大为15,
- GC年龄采用4位bit存储,最大为15,例如MaxTenuringThreshold参数默认值就是15
-XX:MaxTenuringThreshold=16(假如配置GC年龄为16直接报错)

默认开启压缩说明
java -XX:+PrintCommandLineFlags -version

-XX:+UseCompressedClassPointers

- 上述表示开启了类型指针的压缩,以节约空间,假如不加压缩???
手动关闭压缩再看看
-XX:-UseCompressedClassPointers

11.5 换成其他对象试试
- 讲一个对象O 多加入几个属性

- 结果

11.6 当Java处在偏向锁、重量级锁状态时,hashcode值存储在哪?
这是一个针对HotSpot VM的锁实现的问题。
简单答案是:
- 当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
- 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
- 重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁可以存下identity hash code。
请一定要注意,这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。
Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。
12. Synchronized与锁升级
12.1 先从阿里及其它大厂面试题说起
- 谈谈你对Synchronized的理解
- Synchronized的锁升级你聊聊
- Synchronized的性能是不是一定弱于Lock
- 5synchronized实现原理,monitor对象什么时候生成的?知道monitor的monitorenter和monitorexi文两个是怎么保证同步的吗,或者说,这两个操作计算机底层是如何执行的
- 刚刚你提到了synchronized的优化过程,详细说一下吧。(偏向锁和轻量级锁有什么区别?

12.2 本章路线总纲
synchronized 锁优化的背景
- 用锁能够实现数据的安全性,但是会带来性能下降。
- 无锁能够基于线程并行提升程序性能,但是会带来安全性下降。
- 求平衡???

12.3 Synchronized的性能变化
- java5以前,只有Synchronized,这个是操作系统级别的重量级操作
- 重量级锁,假如锁的竞争比较激烈的话,性能下降
Java5之前,用户态和内核态之间的切换

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因
Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁
为什么每一个对象都可以成为一个锁????
- markOop.hpp

Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
- Monitor(监视器锁)

Mutex Lock
Monitor是在jvm底层实现的,底层代码是c++。本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,状态转换需要耗费很多的处理器时间成本非常高。所以synchronized是Java语言中的一个重量级操作。
Monitor与java对象以及线程是如何关联 ?
1.如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址
2.Monitor的Owner字段会存放拥有相关联对象锁的线程id
Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。
java6开始,优化Synchronized
- Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁
- 需要有个逐步升级的过程,别一开始就捅到重量级锁
12.4 synchronized锁种类及升级步骤
多线程访问情况,3种
- 只有一个线程来访问,有且唯一Only One
- 有2个线程A、B来交替访问
- 竞争激烈,多个线程来访问
升级流程
synchronized用的锁是存在Java对象头里的Mark Word中锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位
- 64位标记图再看

无锁
1 |
|
- 程序不会有锁的竞争


偏锁
- 当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
- 同一个老顾客来访,直接老规矩行方便
- 看看多线程卖票,同一个线程获得体会一下
- Hotspot 的作者经过研究发现,大多数情况下:多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
理论落地:
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。
那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
技术实现:
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还
会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。
- 64位标记图再看,通过CAS方式修改markword中的线程ID

- 细化案例Account对象举例说明
偏向锁的操作不用直接捅到操作系统,不涉及用户到内核转换,不必要直接升级为最高级,我们以一个account对象的“对象头”为例,

假如有一个线程执行到synchronized代码块的时候,JVM使用CAS操作把线程指针ID记录到Mark Word当中,并修改标偏向标示,标示当前线程就获得该锁。锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。

这时线程获得了锁,可以执行同步代码块。当该线程第二次到达同步代码块时会判断此时持有锁的线程是否还是自己(持有锁的线程ID也在对象头里),JVM通过account对象的Mark Word判断:当前线程ID还在,说明还持有着这个对象的锁,就可以继续进入临界区工作。由于之前没有释放锁,这里也就不需要重新加锁。 如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
结论:JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。
上述就是偏向锁:在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。
- 偏向锁JVM命令
java -XX:+PrintFlagsInitial |grep BiasedLock*

- 重要参数说明
- 实际上偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,
- 所以需要添加参数-XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。
- 开启偏向锁:
- -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:关闭之后程序默认会直接进入——————————————>>>>>>>> 轻量级锁状态。
- -XX:-UseBiasedLocking
- Code演示
1 | package com.atguigu.juc.senior.inner.object; |

- 因为参数系统默认开启
-XX:+UseBiasedLocking 开启偏向锁(默认)
-XX:-UseBiasedLocking 关闭偏向锁
-XX:BiasedLockingStartupDelay=0 关闭延迟(演示偏向锁时需要开启)
参数说明:
偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活,可以使用JVM参数来关闭延迟 -XX:BiasedLockingStartupDelay=0
如果确定锁通常处于竞争状态则可通过JVM参数 -XX:-UseBiasedLocking 关闭偏向锁,那么默认会进入轻量级锁
- 关闭延时参数,启用该功能 -XX:BiasedLockingStartupDelay=0

- 偏向锁的撤销
当有另外线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为轻量级锁
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁。
偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。**撤销需要等待全局安全点(该时间点上没有字节码正在执行)**,同时检查持有偏向锁的线程是否还在执行:
① 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
② 第一个线程执行完成synchronized方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向 。

- 总体步骤流程图示

轻锁
- 有线程来参与锁的竞争,但是获取锁的冲突时间极短
- 本质就是自旋锁
轻量级锁的获取
轻量级锁是为了在线程近乎交替执行同步块时提高性能。
主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。
升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
假如线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁了。
而线程B在争抢时发现对象头Mark Word中的线程ID不是线程B自己的线程ID(而是线程A),那线程B就会进行CAS操作希望能获得锁。
此时线程B操作中有两种情况:
如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程”被”释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位;

如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。-XX:-UseBiasedLocking
- Code演示
如果关闭偏向锁,就可以直接进入轻量级锁
-XX:-UseBiasedLocking

- 步骤流程图示

- 自旋达到一定次数和程度
java6之前 默认启用,默认情况下自旋的次数是 10 次 -XX:PreBlockSpin=10来修改 或者自旋线程数超过cpu核数一半 上述了解即可,别用了。
Java6之后 自适应 自适应意味着自旋的次数不是固定不变的 而是根据:同一个锁上一次自旋的时间。拥有锁线程的状态来决定。
- 轻量锁与偏向锁的区别和不同
争夺轻量级锁失败时,自旋尝试抢占锁
轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
重锁
有大量的线程参与锁的竞争,冲突性很高
- 锁标志位

- Code演示

小总结
- 各种锁优缺点、synchronized锁升级和实现原理
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
| 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。
实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式
synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。
JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
偏向锁: 适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
12.5 JIT编译器对锁的优化
- JIT Just In Time Compiler,一般翻译为即时编译器
锁消除
1 | package com.atguigu.itdachang; |
锁粗化
1 | package com.atguigu.itdachang; |
13. AbstractQueuedSynchronizer之AQS
13.1 是什么
前置知识
- 公平锁和非公平锁
- 可重入锁
- 自旋锁
- LockSupport
- 数据结构之链表
- 设计模式之模板设计模式
字面意思:抽象的队列同步器
源代码
- AbstractOwnableSynchronizer
- AbstractQueuedLongSynchronizer
- AbstractQueuedSynchronizer
- 通常地:AbstractQueuedSynchronizer简称为AQS

技术解释
是用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态

- CLH:Craig、Landin and Hagersten 队列,是一个单向链表,AQS中的队列是CLH变体的虚拟双向队列FIFO
13.2 AQS为什么是JUC内容中最重要的基石
和AQS有关的

- ReentrantLock

- CountDownLatch

- ReentrantReadWriteLock

- Semaphore

进一步理解锁和同步器的关系
- 锁,面向锁的使用者
- 定义了程序员和锁交互的使用层API,隐藏了实现细节,你调用即可。
- 同步器,面向锁的实现者
- 比如Java并发大神DougLee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等。
13.3 能干嘛
- 加锁会导致阻塞
- 有阻塞就需要排队,实现排队必然需要队列
解释说明
抢到资源的线程直接使用处理业务,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。
既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。

13.4 AQS初步
AQS初识
- 官网解释

- 有阻塞就需要排队,实现排队必然需要队列
AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改。

AQS内部体系架构

AQS自身
AQS的int变量
- AQS的同步状态State成员变量
- 银行办理业务的受理窗口状态
类比银行办理业务的受理窗口状态
- 零就是没人,自由状态可以办理
- 大于等于1,有人占用窗口,等着去

- AQS的CLH队列
- CLH队列(三个大牛的名字组成),为一个双向队列
- 类比银行候客区的等待顾客

- 小总结
有阻塞就需要排队,实现排队必然需要队列
state变量+CLH双端队列
内部类Node(Node类在AQS类内部)
Node的int变量
- Node的等待状态waitState成员变量
- volatile int waitStatus
类比 等候区其它顾客(其它线程)的等待状态
- 队列中每个排队的个体就是一个 Node
Node此类的讲解

属性说明

AQS同步队列的基本结构
- CLH:Craig、Landin and Hagersten 队列,是个单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO)

13.5 从我们的ReentrantLock开始解读AQS
Lock接口的实现类,基本都是通过【聚合】了一个【队列同步器】的子类完成线程访问控制的
ReentrantLock的原理

从最简单的lock方法开始看看公平和非公平



可以明显看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()
hasQueuedPredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法

非公平锁走起,方法lock()
对比公平锁和非公平锁的 tryAcquire()方法的实现代码,其实差别就在于非公平锁获取锁时比公平锁中少了一个判断 !hasQueuedPredecessors()
hasQueuedPredecessors() 中判断了是否需要排队,导致公平锁和非公平锁的差异如下:
公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中;
非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)

Lock

acquire()


tryAcquire(arg)
- 本次走非公平锁


- nonfairTryAcquire(acquires)

return false;继续推进条件,走下一个方法
return true; 持有锁,结束
addWaiter(Node.EXCLUSIVE)
- addWaiter(Node mode)

enq(node);

双向链表中,第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是从第二个节点开始的。
假如3号ThreadC线程进来 会走到 else 中
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
- acquireQueued

刚进来会尝试再次强锁tryAcquire,假如再抢抢失败就会进入 shouldParkAfterFailedAcquire 和 parkAndCheckInterrupt 方法中

- shouldParkAfterFailedAcquire
如果前驱节点的 waitStatus 是 SIGNAL状态,即 shouldParkAfterFailedAcquire 方法会返回 true 程序会继续向下执行 parkAndCheckInterrupt 方法,用于将当前线程挂起

- parkAndCheckInterrupt

unlock
- sync.release(1);
- tryRelease(arg)
- unparkSuccessor
- tryRelease(arg)
AQS源码解读案例图示

14. ReentrantLock、ReentrantReadWriteLock、StampedLock讲解
- 本章路线总纲: 无锁→独占锁→读写锁→邮戳锁
14.1 关于锁的大厂面试题
- 你知道Java里面有哪些锁?
- 你说你用过读写锁,锁饥饿问题是什么?
- 有没有比读写锁更快的锁?
- StampedLock知道吗?(邮戳锁/票据锁)
- ReentrantReadWriteLock有锁降级机制策略你知道吗?
14.2 请你简单聊聊ReentrantReadWriteLock
- 读写锁定义为一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
『读写锁』意义和特点
『读写锁ReentrantReadWriteLock』并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是“读/读”线程间并不存在互斥关系,只有”读/写”线程或”写/写”线程间的操作需要互斥的。因此引入ReentrantReadWriteLock。
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁**(切菜还是拍蒜选一个)。也即一个资源可以被多个读操作访问或一个写操作访问**,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。
特点
- 可重入
- 读写分离
- ReentrantReadWriteLock可以降级
无锁无序→加锁→读写锁演变复习
1 | package com.atguigu.juc.rwlock; |

从写锁→读锁,ReentrantReadWriteLock可以降级
《Java 并发编程的艺术》中关于锁降级的说明:
锁的严苛程度变强叫做升级,反之叫做降级
| 特性 | 说明 |
|---|---|
| 公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
| 重进入 | 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁 |
| 锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
- 说人话 锁降级:将写入锁降级为读锁(类似Linux文件读写权限理解,就像写权限要高于读权限一样)
读写锁降级演示
- 可以降级
锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。
如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。

Java8 官网说明

重入还允许通过获取写入锁定,然后读取锁然后释放写锁从写锁到读取锁, 但是,从读锁定升级到写锁是不可能的。
- code演示LockDownGradingDemo
1 | package com.atguigu.juc.rwlock; |
如果有线程在读,那么写线程是无法获取写锁的,是悲观锁的策略
- 不可锁升级
线程获取读锁是不能直接升级为写入锁的。

在ReentrantReadWriteLock中,当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。所以,需要释放所有读锁,才可获取写锁

写锁和读锁是互斥的
写锁和读锁是互斥的(这里的互斥是指线程间的互斥,当前线程可以获取到写锁又获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性。因为,如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。因此,分析读写锁ReentrantReadWriteLock,会发现它有个潜在的问题:
读锁全完,写锁有望;写锁独占,读写全堵;
如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,见前面Case《code演示LockDownGradingDemo》即ReadWriteLock读的过程中不允许写,只有等待线程都释放了读锁,当前线程才能获取写锁,也就是写入必须等待,这是一种悲观的读锁,o(╥﹏╥)o,人家还在读着那,你先别去写,省的数据乱。
================================后续讲解StampedLock时再详细展开=======================
**分析StampedLock(后面详细讲解)**,会发现它改进之处在于:
读的过程中也允许获取写锁介入(相当牛B,读和写两个操作也让你“共享”(注意引号)),这样会导致我们读的数据就可能不一致!所以,需要额外的方法来判断读的过程中是否有写入,这是一种乐观的读锁,O(∩_∩)O哈哈~。
显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
读写锁之读写规矩,再说降级
Oracle公司ReentrantWriteReadLock源码总结
锁降级 下面的示例代码摘自ReentrantWriteReadLock源码中:ReentrantWriteReadLock支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。
解读在最下面:

代码中声明了一个volatile类型的cacheValid变量,保证其可见性。
首先获取读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true,然后在释放写锁前获取读锁;此时,cache中数据可用,处理cache中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
如果违背锁降级的步骤
如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。
如果遵循锁降级的步骤
线程C在释放写锁之前获取读锁,那么线程D在获取写锁时将被阻塞,直到线程C完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。
14.3 邮戳锁StampedLock
- 面试题:有没有比读写锁更快的锁?
是什么
StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化。
邮戳锁也叫票据锁
stamp(戳记,long类型)代表了锁的状态。当stamp返回零时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的stamp值。
它是由锁饥饿问题引出
锁饥饿问题:
ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,假如当前1000个线程,999个读,1个写,有可能999个读取线程长时间抢到了锁,那1个写线程就悲剧了 因为当前有可能会一直存在读锁,而无法获得写锁,根本没机会写,o(╥﹏╥)o
如何缓解锁饥饿问题?
- 使用“公平”策略可以一定程度上缓解这个问题 new ReentrantReadWriteLock(true);
- 但是“公平”策略是以牺牲系统吞吐量为代价的
StampedLock类的乐观读锁闪亮登场
ReentrantReadWriteLock
允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的synchronized速度要快很多,原因就是在于ReentrantReadWriteLock支持读并发
StampedLock横空出世
ReentrantReadWriteLock的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。但是,StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验。
StampedLock的特点
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零表示获取失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
- StampedLock是不可重入的,危险(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
StampedLock有三种访问模式
- ①Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
- ②Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
- ③Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
乐观读模式code演示
读的过程中也允许获取写锁介入
1 | package com.atguigu.mapreduce.yasuo; |


StampedLock的缺点
- StampedLock 不支持重入,没有Re开头
- StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意。
- 使用 StampedLock一定不要调用中断操作,即不要调用interrupt() 方法
- 如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly()和写锁writeLockInterruptibly()
15. 课程总结与回顾
- CompletableFuture
- “锁”事儿
悲观锁
乐观锁
自旋锁
可重入锁(递归锁)
写锁(独占锁)/读锁(共享锁)
公平锁/非公平锁
死锁
偏向锁
轻量锁
重量锁
邮戳(票据)锁
- JMM
- synchronized及升级优化
锁的到底是什么
作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;
作用于代码块,对括号里配置的对象加锁。
作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;
无锁→偏向锁→轻量锁→重量锁
Java对象内存布局和对象头
64位图

- CAS
CAS的底层原理
比较并交换
//unsafe.cpp
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper(“Unsafe_CompareAndSwapInt”);
oop p = JNIHandles::resolve(obj);
jint addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_ENDJDK提供的CAS机制,在汇编层级,会禁止变量两侧的指令优化,然后使用cmpxchg指令比较并更新变量值(原子性)
ABA问题
问题:
线程X准备将变量的值从A改为B,然而这期间线程Y将变量的值从A改为C,然后再改为A;最后线程X检测变量值是A,并置换为B。但实际上,A已经不再是原来的A了解决方法,是把变量定为唯一类型。值可以加上版本号,或者时间戳。
解决:
如加上版本号,线程Y的修改变为A1->B2->A3,此时线程X再更新则可以判断出A1不等于A3
- volatile
特性
内存屏障
- LockSupport
是什么
LockSupport是基于Unsafe类,由JDK提供的线程操作工具类,主要作用就是挂起线程,唤醒线程。
LockSupport.park
LockSupport.unpark
LockSupport.park和Object.wait区别
线程在Object.wait之后必须等到Object.notify才能唤醒
LockSupport可以先unpark线程,等线程执行LockSupport.park是不会挂起的,可以继续执行
- AbstractQueuedSynchronizer
是什么
volatile+cas机制实现的锁模板,保证了代码的同步性和可见性,而AQS封装了线程阻塞等待挂起,解锁唤醒其他线程的逻辑。AQS子类只需根据状态变量,判断是否可获取锁,是否释放锁,使用LockSupport挂起、唤醒线程即可
//AbstractQueuedSynchronizer.java
1 | public class AbstractQueuedSynchronizer{ |
出队入队Node
AQS内部维护一个同步队列,元素就是包装了线程的Node。
同步队列中首节点是获取到锁的节点,它在释放锁的时会唤醒后继节点,后继节点获取到锁的时候,会把自己设为首节点。
线程会先尝试获取锁,失败则封装成Node,CAS加入同步队列的尾部。在加入同步队列的尾部时,会判断前驱节点是否是head结点,并尝试加锁(可能前驱节点刚好释放锁),否则线程进入阻塞等待。

- ThreadLocal
当使用ThreadLocal声明变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本
- 原子增强类



