JVM知识点梳理
本文链接:https://blog.csdn.net/feather_wch/article/details/132326246
JMM
定义
1、什么是内存模型?
- 在特定操作协议下,对特定内存和高速缓存读写的过程的抽象
2、JMM的作用是什么?
- 解决缓存一致性和指令重排序导致的安全问题
- 屏蔽具体的平台,保证CPU对内存访问效果一致
3、JMM的主要目的
- 定义程序中变量的访问规则:存储和读取
- 只针对线程共享变量:静态字段、实例字段、构成对象数组的元素
- 线程私有变量:局部变量、方法参数不在考虑范围内
特点
1、JMM内存结构:分为主内存、工作内存,工作内存对应于高速缓存区
- 所有变量存储在主内存
- 工作内存中放副本
- 程序运行时主要访问工作内存
目的
1、JMM的目的是什么?
- 避免数据竞争的干扰
变量
2、JMM如何保证变量的并发问题
- 线程私有
- 线程共享===>原子操作
缓存一致性
1、MESI:M-修改,E-独享互斥,S-共享,I-无效
2、缓存一致性协议是什么?
- 多个CPU从主内存读取数据到各自的高速缓存中
- 某个CPU修改缓存内数据,会立马同步到主内存
- 其他CPU会通过总线嗅探机制,感知到,并且让自己缓存内数据失效
3、缓存加锁是什么?CPU的缓存写回内存会导致其他CPU的缓存失效(基于MESI)
CPU高速缓存区
缘由:高速缓存区(Cache)作为CPU和内存中间:数据复制入Cache中,运算结束放回内存。缓解CPU和内存速度有几个数量级的差距
缓存一致性
多个CPU有缓存一致性问题,如果出现缓存数据不一致以谁的为准?
各个CPU采取一定协议:MSI、MESI等
私有工作内存
1、每个线程都有私有工作内存,操作副本其他线程无法感知到,可见性问题 => volatile
线程一:
while(!initFlag){} // 循环
线程二:
initFlag = true
协议MESI
总线嗅探机制 ==> MQ
重排序
三种
1、重排序分为三类
- 即时编译器的指令重排序
- CPU的乱序执行
- 内存系统的重排序
2、指令重排序为什么会导致问题?
- 数据竞争
3、JMM如何避免指令重排序?
- 内存屏障
as-if-serial
1、重排序需要遵守as-if-serial原则
2、as-if-serial是什么?
- 在单线程的情况下,有顺序执行的假象
- 假如数据互相依赖,不会重排序
HB
1、JMM和HB是什么?
- JMM的核心关键点在于构建一个跨线程的HB关系
2、无需同步手段就能成立的HB原则,有且仅有八种
- 程序次序规则:线程内,按照控制流顺序,书写在前面的操作 HB 书写在后面的操作
- 管程锁定规则:对一个锁的unlock操作,HB,对同一个锁的lock操作(时间顺序上)
- volatile:volatile的写操作,HB,对同一字段读操作
- 线程启动:start,HB,线程的第一个操作
- 线程终止:线程中最后一个操作,HB,线程的终止检查
- 线程中断:对线程interrupt,HB,线程收到的中断事件
- 对象终结:对象构造器的最后一个操作,HB,finalize()
- 传递性:A HB B, B HB C, A HB C
3、Happen-Before不代表时间上先发生
4、时间上先发生也不代表Happen-Before
三大特征
1、JMM的构建是围绕三大原则的
- 有序性
- 可见性
- 原子性
2、JMM如何保证原子性的?
- 提供了原子性操作:read load use assign store write
- 提供了更大范围的原子性:lock和unlock指令
- 提供了更高层面字节码指令:monitorenter、monitorExit(对应于synchronized)
3、JMM如何保证有序性?
- volatile、synchronized保证线程间操作的有序性
- 禁止指令重排序
- 持有同一锁的两个代码块串行进入
4、JMM如何保证可见性?
- 可见性:一个线程修改了变量,其他线程立即可见
- JMM规定变量修改后会同步回主内存,变量读取前从主内存刷新
- volatile是变量修改后立即同步回主内存,变量读取前立即刷新
- Java中可见性关键字:volatile、synchronized、final
内存屏障
1、JVM内存屏障有哪些类型?
- 读读,loadload
- 读写,loadstore
- 写写,storestore
- 写读,storeload
2、JVM如何实现这些屏障的
- 代码中读读、读写、写写,方法都是acquire(),X86底层是空指令
- 写读,方法是fence(),fence底层是lock指令。底层是
刷新写缓存指令
3、lock指令的作用
- 本身不是内存屏障,但可以实现内存屏障的效果
4、volatile instance的赋值,instance = xxx,内存屏障是如何做的?
StoreStore
putStatic
StoreLoad,加内存屏障
volatile
1、volatile适合什么场景?有什么限制?
1、volatile两个作用
- 禁止指令重排序
- 保证可见性
2、volatile如何做到禁止指令重排序的?
- 内存屏障
- 底层是汇编指令,x86上写读是lock相关指令,读读,读写,写写是no-op(空指令)
3、volatile如何保证可见性?
- 汇编指令,写读,情况下 lock前缀的x86指令,保证两个效果
- 1-会锁定缓存数据数据对应在主内存中的内存地址,将当前CPU的缓存行数据 立即写回到主内存
- 2-写操作,会导致其他CPU缓存了该内存地址的缓存数据失效(MESI)
1、volatile是什么?
- JMM的最轻量的同步机制
- volatile变量具有可见性
- volatile变量对其他线程可见(写操作立即反映到其他线程)
- 只能保证拿到的变量值是最新的,不能保证哪个结果被同步回主内存
- 具有有序性: 禁止指令重排序
- 双重检查加锁:避免对象半初始化问题
2、volatile线程不安全
- 不保证原子性:运算操作不是原子性的
3、volatile什么情况下是线程安全的?
- 运算结果不依赖变量当前值 ++ –
- 变量不和其他状态变量共同参与不变约束:a >= b
4、非volatile指令重排序问题
线程A:1和2交换顺序
1. 初始化
1. initFlag = true
线程B:
1. 判断 initFlag = true
1. 使用相关内容 // 会出错,根本没初始化完成
内存间交互操作
1、八大数据原子操作
- 主内存 传输到 工作内存: read
-
存储到 工作内存: load
- 工作内存 传输到 执行引擎: use
- 执行引擎 赋值到 工作内存:assign
- 工作内存 传输到 主内存:store
-
写入到 主内存:write
- lock:主内存变量加锁 => 表示变量进入线程独占状态
- unlock:主内存变量解锁
2、什么是原子操作?不可以再细分
并发
线程
1、线程是什么?
- 最轻量级,最基本的调度单元
- 可以共享进程资源(内存地址、文件IO)
- 又可以独立调度
2、Java线程实现分为三种
- 内核线程 1:1
- 用户线程 1:N
- 混合模式 N:M
内核线程
1、内核线程是什么?1:1是什么意思?
- KLT:kernel-level thread
- LWP:light-Weight thread 轻量级进程/线程
- LWP和KLT是1:1的关系
2、内核线程结构是什么?
- P:LWP、LWP、LWP,进程有多个LWP
- LWP和KLT:一一对应
- KLT通过Thread Scheduler:调度器,将线程任务映射到CPU上
- Thread Scheduler:CPU、CPU、CPU
3、内核线程实现的问题是什么?
- 基于KLT实现,线程操作需要系统调用,涉及到用户态和内核态切换,代价高
- 会消耗内核资源:KLT内核线程数量有限
用户线程 1:N 弃用
1、用户线程结构
- CPU:P、P、P CPU资源分配到进程,内核无感知
- P:UT、UT、UT 进程有多个用户线程
2、问题
- 内核无感知,导致无法帮助处理阻塞
- 无法在多CPU情况下帮助映射线程到其他CPU
- Java弃用
混合模式 N:M
1、混合模式结构
- Thread Schduler 调度多个CPU:和内核线程模式一样
- 多个LWP和KLT一一对应:和内核线程模式一样
- LWP和进程内多个UT,交叉对应or一一对应
2、LWP和UT共存,LWP是UT和KLT的桥梁
3、优点
- UT操作廉价,可以大规模并发
- 内核线程用调度器利用多CPU资源调度问题
- 内核线程可以处理阻塞等问题
线程调度
1、抢占式:系统分配
2、协同式:线程工作完成后,通知系统切换
线程状态
1、Java定义了六种线程状态
- new
- running start notify、notifyall
- block synchronized,等待获得一个排他锁
- waiting wait、join
- timed-waiting wait、join、sleep
- terminated run结束
2、释放锁的情况
- sleep不会释放锁
- wait释放锁
- join不会释放锁
- yield释放线程锁,不释放对象锁
协程
1、协程概念
- 协同式调度的用户线程
- 协程会完整的进行栈的保护和恢复
2、线程和协程比较
- 线程资源有限,调度成本高,数以百万级的请求往几十~200的线程池塞,切换损耗很大
- 轻量,协程,几百byte~几KB,并存数量数十万。
3、协程缺点
- 需要在应用层实现调用栈、调度器
- kotlin 协程 synchronized会阻塞整个线程
4、oracle fiber纤程,是在JVM共存的新并发编程模型
5、Oracle fiber介绍
- 相比于传统线程池,响应速度有50~100倍提高
- 共用基类
- fiber并发分为:
- continuation:维护执行现场,保护,切换上下文
- 调度器:编排代码执行顺序
线程安全
互斥同步
synchronized
Lock
非阻塞同步
CAS
Atomic类
ABA
自旋时间过长
无同步手段
ThreadLocal
锁优化
偏向锁
轻量级锁-LockRecord
自旋-自适应
锁消除
锁粗化
synchronized
1、synchronized底层是什么?
- 底层是monitorenter和monitorexit指令 ==> 体现了JVMM处理原子性时,提供了更高层面的字节码指令
2、synchronized和底层monitorenter需要一个对象参数
- 当前对象
- 指定对象
- 类对象
管程
3、synchronized内部加锁真正的是Monitor对象
- 操作系统中的对象
4、Monitor对象的原理
- ObjectMonitor:
- EntryList:阻塞队列,存储获得了Monitor对象锁的线程
- WaitSet:等待队列,存储调用了wait()而阻塞的线程,会释放锁
- 1-线程加锁不成功会加到等待队列中(等待队列非EntryList,是另一个数据结构)
5、为什么会有EntryList?同时获得到锁的线程不是只有一个吗?
- 在某些情况下,多个线程可以同时获得同一个锁
- 例如:在可重入锁的情况下,同一个线程可以多次获取同一个锁。EntryList以便释放时可以顺序正确。
- 例如:读写锁时,多个线程持有读锁
原子类
1、原子类的性能,最少高一倍多
CAS
1、原子类借助while+CAS实现 = (自旋)
2、CAS自称无锁是指真的没有锁吗?在CPU层面也是有锁的
3、CompareAndSet源码
->Unsafe.java
->Unsafe.cpp
->1. LOCK_IF_MP: 多核CPU返回lock指令 // 加锁,保证多CPU并行安全
->2. cmpxchg: // 原子比较和交换指令 Compare and Exchange
4、lock是什么?
- 缓存行锁
- 若超过64byte(跨缓存行)会加总线锁
5、lock cmpxchg的解析,为什么原子指令还需要lock?
- lock前缀时,在执行cmpxchg,会对总线上的其他处理器进行锁定,以防止其他线程对同一共享变量进行并发的修改
- cmpxchg的操作在多核情况下会有问题,需要lock
6、CAS具有的问题
- ABA: 加版本号 ===> AtomicStampRefrence
- 原子性:
锁升级
1、锁升级的流程,以及锁各状态之间如何切换?
无状态-001(默认4秒)
|-线程id写入markword(启用偏向锁)
偏向锁-101
|-多个线程轻量竞争,CAS轻量竞争
轻量锁-00
|-自旋不成功,自适应自旋也不行,重度竞争
重量级锁-10 // markword指向monitor
无状态-001
|-未启用偏向锁
轻量锁-00
偏向锁-101
|-调用wait,直接进入重量级锁。重量级锁才有的状态。 ====> wait
重量级锁-10
2、JVM默认4秒后自动开启偏向锁
- 4S后new的所有对象都是101,而不是无锁的001
- 未开启时,加锁,直接到轻量级锁
3、偏向锁或者无锁进入轻量级锁的检查流程
- 检查对象头,无锁(非重量级锁),在栈帧中创建LockRecord,CAS将对象头的MarkWord更新为LockRecord指针
- 成功:进入轻量级锁状态
- 失败:检查1-MW指向自己的栈帧,代表已经拥有锁,执行(可重入)
- 检查2-MW指向其他线程的栈帧中LockRecord,代表有竞争,用重度锁
4、锁升级流程
- 默认无锁,4s后进入偏向锁状态
- 偏向锁释放时,不作任何操作,方便再次进入时比较threadid
- 拿锁时,发现有其他线程拿过锁(有竞争),进入轻量级锁
- 有竞争(CAS失败n次-代表起码2个线程在竞争),进入重量级锁。指向Monitor对象。
5、CAS自旋10次,或者可能自适应自旋2~3次,进入重锁
6、分代年龄等信息暂存在其他地方,会恢复。
LongAdder
1、LongAdder用于高并发下替换Atomic类, 高并发计数器
2、LongAdder原理
- 采用分段CAS
- 只有一个线程:CAS实现,有base数值
- 多个线程:cell数组
- 1-各个线程处理自己的cell1、cell2、cell3
- 2-最后会求和,得到最终计数(无锁,每个线程负责自己的计数,不会有冲突,求和也不会冲突)
- 3-数组会根据实际情况增减-有扩容、缩容机制
QUESTION
1、双重检查加锁的对象半始化问题
- 对象创建过程中:类加载检查、分配空间、初始化零值、设置对象头、调用init、引用指向该对象(putStatic)
- 不使用volatile的instance,会导致在init初始化和引用指向该对象重排序的情况下,拿到还未初始化的实例。
- 不会违背as-if-serial原则和HB原则
- volatile禁止指令重排序和保证可见性
执行引擎
字节码
1、下面字节码指令导致的效果是怎么样的?
地址 | 指令 |
---|---|
0 | ICONST_1 |
1 | ISTORE 0 |
2 | ICONST_2 |
3 | ISTORE 1 |
4 | ILOAD 0 |
5 | ILOAD 1 |
6 | ADD |
7 | ISTORE 2 |
8 | RETURN |
阐述在PC、操作数栈、局部变量表中是如何变换的
编译优化
泛型
解释器
即时编译器
热点代码
探测
优化(OSR)
PSO、ASO、LTO
ART
AOT
分支一
分支二:动态JIT
方法内联
目的
冗余xxx
无用xxx
复写传播
逃逸分析
栈上分配
标量替换
同步消除
隐式异常优化
CHA
非虚
虚->守护内联
->内敛缓存
->单态
->多态
-进程层面
-JVM性能监控
自动内存管理
方法区
1、方法区
- 线程共享
- 存放JVM已经加载的:类型信息、静态变量、常量、JIT编译后的代码缓存
- 回收:常量池回收、对类型卸载
2、运行时常量池
- 类加载后会将class文件中常量池的内容放入
- 三类数据:字面量、符号引用、直接引用
3、符号引用有哪些?
- 类和接口的名称
- 字段名称和描述符
- 方法名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
3、具有动态性:String的intern方法
- 返回常量池中对应的引用,不存在就新建放入常量池,再返回
堆
2、堆
- 最大区域
- 存放:对象实例、数组、字符串常量池
- OOM:堆无法扩展时 ======================> 多进程
2、为什么要年龄划分?
- 为了更好的分配和回收
- 新生代、老年代、Eden空间、永久代知识GC技术的具体实现 =====> CMS
GC
新生代(Eden、Surviovr)
老年代
虚拟机栈
1、虚拟机栈是什么?
- 线程私有,和线程生命周期同步
- 具有OOM和StackOverflow异常
- 描述的是Java方法执行的线程内存模型,每个方法执行,代表栈帧入栈和出栈的过程
2、栈帧StackFrame里面是什么?
- 局部变量表
- 操作数栈
- 动态链接 => 1.静态解析(符号-直接) 2.动态链接 3.静态分派
- 方法出口
3、局部变量表存放的是什么?
- 基本数据类型-存储单位是slot
- 对象引用
- returnAddress 废弃,异常表处理替代跳转指令
4、压缩指针是什么?
- 64位指针压缩为32位,占据4byte,节省4byte
本地方法栈
- 面向Natiev方法
- 会OOM和StackOverflow
- HotSpot将两个栈合二为一
PC寄存器
1、PC
- 当前字节码指令的行号指示器 => 所有流程控制(操作)都依赖PC
- 字节码解释器通过改变PC值,来获取下一个指令
- 线程私有
- 较小内存空间,是JVM中唯一没有OOM的区域
- 线程执行 Java方法时:PC值为JMV字节码指令的地址。
- 线程执行 Native方法时:为空
直接内存
1、直接内存是什么?
- 不是JVM规范中的一部分,也不是运行时数据区的一部分
- JDK 1.4引入NIO,引入了基于Channel和缓冲区的IO方式
- 可以通过unsafe相关API直接分配堆外内存,通过Java堆的DirectByteBuffer对这块内存进行操作
- 性能:一定场景,可以避免Native堆和Java堆来回复制数据
- 不受JVM大小限制,收到物理内存大小限制
对象
1、对象内存布局
- 对象头:Markword、类型指针、数组元素长度
- 实例数据:各种类型字段的数据
- 对齐填充:占位符,对象起始地址需要是8byte的整数倍(大量实验和理论结果)
2、Markword
- 8byte
- 包含:hashcode、分代年龄、偏向线程ID、偏向锁、锁状态
3、类型指针:指向类型元数据(方法区)
new指令
1、什么场景下会有new指令
- new关键字创建对象
- 对象克隆
- 对象序列化
创建
1、对象创建流程
- 类加载检查
- 分配内存空间
- 初始化零值:内存空间都设置为0,等效于成员变量都设置为零值
- 设置对象头:Markwork、类型引用、对齐 ===> 对象头
- init实例初始化 ===> invokespecial
- 引用赋值
2、对象创建流程中,new指令对应于1,2,3,4
3、volatile,在对象创建流程中,123456步骤上下会插入monitorEnter和monitorExit?
五种方式
流程
分配内存
TLAB、
父类private隐藏字段也占据空间
内存布局(对象头、实例数据、对齐)
1、对象的内部结构
- 对象头
- 实例数据(Data1、Data2)
- Padding
对象头
1、对象头组成部分
- MarkWord
- 类型元数据指针
- 数组对象长度
2、对象头的MarkWord包含哪些数据?
- 偏向锁、偏向线程ID、锁状态、 ====> 锁升级
- hashcode
- 分代年龄 ===> GC
3、Markword在不同锁状态下的内容
无锁 | hashcode | 对象分代年龄 | 0 | 01 |
偏向锁 | thread id | 对象分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中LockRecord的指针 | 00 | ||
重量级锁 | 指向重量级锁的指针 | 10 | ||
GC | 11 |
4、为什么起始地址需要是8字节的整数倍?
- 实验出的寻址最优解(硬件级别大量实验)
5、如何打印对象的内部组成?
ClassLayout.parseInstance(user).toPrintable()
6、字节序
- 大端字节序:高位字节在低地址,方便人类阅读,网络传输 ==================>
- 小端字节序:低位字节在低地址,计算机效率高 ====> MMKV
===> HashMap浪费空间