笔记:深入理解Java虚拟机(JVM学习脑图)
2022-02-06 05:17:28 1 举报
AI智能生成
Java虚拟机体系整理,最详尽的JVM结构脑图,JVM内存分布,JVM垃圾收集器整理、JMM内存模型整理、JVM前期编译优化和JVM后期编译优化等,面试、学习、工作、提升必备
作者其他创作
大纲/内容
ART
GC方案
Android 8 (Oreo) 开始
默认方案:并发复制 (CC)
另一个是:并发标记清除 (CMS)
Android 8 (Oreo) 开始
默认方案:并发复制 (CC)
另一个是:并发标记清除 (CMS)
并发复制 (CC)
特性完全像G1
Remember Set
CardTable
CardTable
Region
并发标记清除 (CMS)
自动内存回收
JVM内存区域
运行时区域
程序计数器
线程私有
字节码解释器工作时改变这个计数器选取下一条需要执行的字节码指令程序流程
指示器(分支、循环、跳转异常处理、线程恢复)
没有规定OOM
Java虚拟机栈
线程私有、JVM在方法被执行时创建栈帧
存储局部变量表、操作数栈、动态连接、方法出口信息
局部变量表
存储内容:基本类型、对象引用、returnAddress
存储空间:以局部变量槽表示(变量槽占用bit由虚拟机实现决定32bit/64bit);long/double占2个槽,其他占用1个
分配时机:编译时空间大小确定(变量槽数量),分配变量槽
超出栈深度:StackOverflowError/栈容量可扩展而无法申请:OOM
本地方法栈
线程私有
和虚拟机栈作用相似,是为本地方法服务
使用方式和数据结构没有强制规定,JVM可自由实现
StackOverflowError / OOM
Java堆
线程共享、虚拟机启动时创建
物理上空间不连续,逻辑上连续
对象分配在堆上(标量替换、栈上分配导致也并不绝对)
GC管理的区域,新生代、老年代、永久代、Eden/Survivor空间
OOM
方法区
线程共享
存储类型信息、常量、静态变量、即时编译器编译后的代码
和堆是物理隔离,但是属于堆的逻辑部分,但是别名叫做非堆
永久代(PermGen):HotSpot把收集器扩展到方法区(使用永久代实现扩展区)
OOM
运行时常量池
JDK1.6:属于方法区的一部分
JDK1.7:属于堆的一部分
JDK1.7:属于堆的一部分
常量池表:编译生成的字面量、符号引用
运行期间也可以将常量放入池中
String.intern() :
JDK1.6 :常量池存放对象/没有就在常量池重新创建对象
JDK1.7 :常量池存放堆中对象的地址/常量池没有引用地址就把堆内的字符串常量对象地址复制到常量池中
JDK1.6 :常量池存放对象/没有就在常量池重新创建对象
JDK1.7 :常量池存放堆中对象的地址/常量池没有引用地址就把堆内的字符串常量对象地址复制到常量池中
OOM
直接内存
不是运行时数据区
OOM
HotSpot虚拟机对象
对象创建
遇到new 初始化过程:
1. 在常量池定位符号引用
2. 检查符号引用代表的类是否被 加载、解析、初始化
3. 没有就执行类加载流程
类加载检查通过,分配对象内存(类加载完成即可确定所需内存大小)
1. 在常量池定位符号引用
2. 检查符号引用代表的类是否被 加载、解析、初始化
3. 没有就执行类加载流程
类加载检查通过,分配对象内存(类加载完成即可确定所需内存大小)
内存分配过程:
内存规整:指针碰撞:空闲一边/占用一边/指针在中间,分配时指针在空闲区域移动对象内存大小相等的距离(Serial、ParNew)
不规整内存:空闲列表:维护列表记录可用内存,分配时找到足够大的内存空间(CMS)
内存规整:指针碰撞:空闲一边/占用一边/指针在中间,分配时指针在空闲区域移动对象内存大小相等的距离(Serial、ParNew)
不规整内存:空闲列表:维护列表记录可用内存,分配时找到足够大的内存空间(CMS)
并发安全:
修改指针位置:A线程准备修改,B线程进行修改后,A继续修改,内容被覆盖
方案:
1. 同步方案:分配内存动作同步
2. TLAB方案:线程内预分配小块内存(本地线程分配缓冲 TLAB),缓冲用完后再启用同步方案
(Thread Local AllocationBuffer,线程本地分配缓冲区)
修改指针位置:A线程准备修改,B线程进行修改后,A继续修改,内容被覆盖
方案:
1. 同步方案:分配内存动作同步
2. TLAB方案:线程内预分配小块内存(本地线程分配缓冲 TLAB),缓冲用完后再启用同步方案
(Thread Local AllocationBuffer,线程本地分配缓冲区)
内存分配完成,内存空间初始化为0
对象头设置(参照对象内存布局)
虚拟机角度对象创建完成,Java角度开始执行构造函数即<init>()方法
对象内存布局
对象头(Header)
运行时数据 Mark Word
占用32bit或64bit 与对象自身定义的数据无关的额外存储成本
例如:同步锁未锁定时32bit中
例如:同步锁未锁定时32bit中
- 25 bit存储hashCode
- 4 bit存储分代年龄
- 2 bit存储锁标志
- 1 bit固定0
锁标志介绍:
状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀 重量级锁定 10 指向重量级锁的指针
GC 标记 11 空,不需要记录信息
可偏向 01 偏向线程ID,偏向时间戳,对象分代年龄
状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀 重量级锁定 10 指向重量级锁的指针
GC 标记 11 空,不需要记录信息
可偏向 01 偏向线程ID,偏向时间戳,对象分代年龄
biased_lock:
对象是否启用偏向锁标记。lock和biased_lock共同表示对象处于什么锁状态。
对象是否启用偏向锁标记。lock和biased_lock共同表示对象处于什么锁状态。
age:
Java对象年龄,占用4位。
在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。
默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。
由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
Java对象年龄,占用4位。
在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。
默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。
由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:
31位的对象标识hashCode,采用延迟加载技术。
调用方法System.identityHashCode()计算,并会将结果写到该对象头中。
当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
31位的对象标识hashCode,采用延迟加载技术。
调用方法System.identityHashCode()计算,并会将结果写到该对象头中。
当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
thread:
持有偏向锁的线程ID。
持有偏向锁的线程ID。
epoch:
偏向锁的时间戳。
偏向锁的时间戳。
ptr_to_lock_record:
轻量级锁状态下,指向栈中锁记录的指针。
轻量级锁状态下,指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:
重量级锁状态下,指向对象监视器Monitor的指针。
重量级锁状态下,指向对象监视器Monitor的指针。
类型指针 Class Word
指向方法区Class信息的指针,方便对象获取Class类型信息
注:并不是所有JVM实现都必须在对象上保留类型指针,也就是说查找对象类型信息不一定要经过对象本身
注:并不是所有JVM实现都必须在对象上保留类型指针,也就是说查找对象类型信息不一定要经过对象本身
数组长度(可选)
类型是数组时才会有此组成部分
实例数据/对象体(Instance Data)
保存对象属性和值
存储顺序和分配策略
默认顺序:
long/double
ints
shorts
chars
bytes/booleans
oops(Oridinary Object Pointers, OOPS)
long/double
ints
shorts
chars
bytes/booleans
oops(Oridinary Object Pointers, OOPS)
分配策略:
1. 相同宽度字段总是被分配到一起
2. 父类中定义的变量会出现在子类之前
3. 通过+XX:CompactFields 设置 true可允许子类中较窄变量插入父变量空隙中节省空间
1. 相同宽度字段总是被分配到一起
2. 父类中定义的变量会出现在子类之前
3. 通过+XX:CompactFields 设置 true可允许子类中较窄变量插入父变量空隙中节省空间
对齐填充/对齐字节(Padding)
起到占位符作用
HotSopt要求对象起始地址必须是8字节整倍数
由于对象头设计为8字节整倍数,因此如果对象体部分不是8字节整倍数,通过对齐填充补全
也就是说:任何对象大小都必须是8字节整倍数
并不是必然存在
对象的访问定位
句柄
堆中划分出一块内存作为句柄池
栈中reference存储了句柄池地址
句柄池中 对象实例数据 指向 堆 中对象地址
句柄池中 对象类型数据 指向 方法区 中类型地址
句柄池中 对象类型数据 指向 方法区 中类型地址
优点:稳定的句柄池地址,移动对象时只改变句柄池中对象地址
直接指针
堆中对象内存布局必须考虑如何放置类型数据信息
栈中reference存储的是对象地址
堆中包含 对象实例数据,类型数据指针 指向方法区的类型地址
优点:访问速度更快
垃圾收集器和内存分配策略
对象引用判定
引用计数法
对象中添加引用计数器,增加引用+1,引用失效-1
对象之间互相循环引用
可达性分析法
GC Roots根对象节点,从根节点向下搜索
局部回收,避免GC Roots包含过多对象而过度膨胀
根节点:
1. 虚拟机栈(栈帧中本地变量表)中引用的对象(具体是变量槽中引用没有释放,也就是变量槽没有清空或复用)
2. 方法区中类静态属性引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈中JNI引用的对象
5. Java虚拟机内部的引用:基本类型Class对象、常驻异常对象、系统类加载器
6. 所有被同步锁(synchronized关键字)持有的对象
7. 反应JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
1. 虚拟机栈(栈帧中本地变量表)中引用的对象(具体是变量槽中引用没有释放,也就是变量槽没有清空或复用)
2. 方法区中类静态属性引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈中JNI引用的对象
5. Java虚拟机内部的引用:基本类型Class对象、常驻异常对象、系统类加载器
6. 所有被同步锁(synchronized关键字)持有的对象
7. 反应JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
引用类型
强引用
只要引用关系存在,永远不回收
软引用
系统将要OOM前,会把此类对象列入回收范围,进行第二次回收,如果这次回收还没有足够内存,就会发生OOM
弱引用
对象只会生存到下一次GC发生时
虚引用
PhantomReference初始化时,传入ReferenceQueue,其中remove方法可以阻塞,对象被回收可触发
垃圾收集算法
分代收集理论
弱分代假说:大多数对象朝生夕灭
强分代假说:多次未被回收的对象就越难被回收
强分代假说:多次未被回收的对象就越难被回收
这两个假说是分代收集的理论基础
堆应该划分不同区域
根据年龄回收
新生代(Young Generation)
老年代(Old Generation)
每次回收某个或某些区域
部分收集(Paritial GC)
新生代收集(Minor/Young GC)
老年代收集(Major/Old GC)
混合收集(Mixed GC) G1有次收集方式
整堆收集(Full GC)
堆应该划分不同区域
根据年龄回收
新生代(Young Generation)
老年代(Old Generation)
每次回收某个或某些区域
部分收集(Paritial GC)
新生代收集(Minor/Young GC)
老年代收集(Major/Old GC)
混合收集(Mixed GC) G1有次收集方式
整堆收集(Full GC)
跨代引用假说:跨代引用相对于同代引用占少数
为解决跨代引用问题,在新生代中建立全局数据结构:记忆集(Remembered Set)
目的:
为避免为了少量跨代引用扫描整个老年代
也不必浪费空间记录每个对象的跨代引用
目的:
为避免为了少量跨代引用扫描整个老年代
也不必浪费空间记录每个对象的跨代引用
标记 - 清除算法
标记所有需要回收的对象,标记完成统一清理
也可以反过来标记存活对象,统一回收未标记对象
也可以反过来标记存活对象,统一回收未标记对象
缺点:
1. 效率不稳定,需要标记和清除的数量越多,执行效率越低
2. 内存碎片化严重,无法分配时会触发GC
1. 效率不稳定,需要标记和清除的数量越多,执行效率越低
2. 内存碎片化严重,无法分配时会触发GC
优点:低延时
CMS
CMS
标记 - 复制算法
内存分为两块,每次只是用一块,用完后将存活对象复制到另一半,然后执行一次清理操作
优点:
简单高效
缺点:
内存占用高
简单高效
缺点:
内存占用高
大多虚拟机是用此算法收集新生代;
由于存活率较高会有较多复制的低效率操作、更重要的是空间不足需要分配担保的逃生门,所以老年代一般不直接选用此算法
由于存活率较高会有较多复制的低效率操作、更重要的是空间不足需要分配担保的逃生门,所以老年代一般不直接选用此算法
Appel式回收
一块较大Eden,两块较小Survivor空间
HotSpot比例 8:1:1
当Eden区满的时候,发生minor gc,有存活对象,将对象转移到S0中
下次再发生minor gc的时候
将Eden区和S0区的存活对象复制到S1中(这种复制算法可以保证S1中来自Eden和S0中对象的地址是连续的)
清空Eden区和S0的空间
然后交换S0和S1的角色
之后发生minor gc时,循环往复,直到存活对象old enough,升入老年代。
这种情况下我们可以保证始终有一个Survivor的空间是没有碎片的,而另外一个Survivor是空着的。
下次再发生minor gc的时候
将Eden区和S0区的存活对象复制到S1中(这种复制算法可以保证S1中来自Eden和S0中对象的地址是连续的)
清空Eden区和S0的空间
然后交换S0和S1的角色
之后发生minor gc时,循环往复,直到存活对象old enough,升入老年代。
这种情况下我们可以保证始终有一个Survivor的空间是没有碎片的,而另外一个Survivor是空着的。
逃生门:无法保证对象小于Survivor空间大小,所以当超出后,老年代需要做担保进行分配
标记 - 整理算法
针对老年代对象存亡特征而提出
标记过程和标记 - 清除算法相同
清理过程是将对象向空间另一端移动,然后清理掉边界另一边的内存
清理过程是将对象向空间另一端移动,然后清理掉边界另一边的内存
缺点:老年代移动对象和更新引用操作耗时长,会造成STW
优点:
吞吐量高:用户进程/收集器
Parallel Scavenge
吞吐量高:用户进程/收集器
Parallel Scavenge
HotSpot算法细节
根节点枚举
准确式垃圾收集:并不需要从GC Roots开始查找
OopMap数据结构
类加载完成:对象内各偏移量上类型数据计算出来(找出变量)
即时编译过程:记录 栈和寄存器里的引用位置
GC会通过OopMap获得这些引用位置,不用从GC Roots开始查找
OopMap数据结构
类加载完成:对象内各偏移量上类型数据计算出来(找出变量)
即时编译过程:记录 栈和寄存器里的引用位置
GC会通过OopMap获得这些引用位置,不用从GC Roots开始查找
安全点 Safe point
如果每条指令都生成OopMap的话,需要额外空间
HotSpot没有为每条指令生成OopMap,只在“特定位置”记录信息,这些位置称作“安全点”
各线程都跑到最近安全点后确保能停下来的方案
1. 抢先式:几乎没有用;是指系统把所有用户线程中断,如果有不在安全点上就恢复线程直到跑到安全点
2. 主动式:线程中断时,设置标记,各线程不断轮询标记,发现为true表示在最近安全点上挂起
1. 抢先式:几乎没有用;是指系统把所有用户线程中断,如果有不在安全点上就恢复线程直到跑到安全点
2. 主动式:线程中断时,设置标记,各线程不断轮询标记,发现为true表示在最近安全点上挂起
安全区
线程阻塞或休眠无法执行时,无法到达安全点,因此引入安全区
安全区保证代码片段中引用不变,因此在安全区中任意位置开始垃圾回收都安全
安全区保证代码片段中引用不变,因此在安全区中任意位置开始垃圾回收都安全
当用户线程进入到安全区域的代码时,会标记进入安全区,退出时标记退出
记忆集和卡表
Remember Set
CARD_TABLE
Remember Set
CARD_TABLE
新生代中记忆集解决跨代引用问题 G1/ZGC/Shenandoah
记忆集记录从非收集区域指向收集区域的指针集合的抽象数据结构
记录精度:
1. 字长精度
2. 对象精度
3. 卡精度
1. 字长精度
2. 对象精度
3. 卡精度
卡精度是“卡表(Card Table)”的方式实现的记忆集,也是最常用的方式
卡表定义了记忆集的记录精度、与堆内存的映射关系
卡表最简单的就是数组结构,卡表中存放的是地址段,称为卡页
卡页:卡页存储的是起始地址,一般卡页大小是2的N次幂
如果卡页内存在跨代指针对象,卡表的数组元素表示为1,没有就标识为0
写屏障
写屏障是指:虚拟机层面对“引用类型字段赋值”动作的AOP切面执行其他动作,分写前和写后屏障
应用写屏障后,只要收集器在写屏障中增加了更新卡表操作,就需要更新引用,就会产生额外开销
卡表面临“伪共享”问题:多线程修改变量在同一缓存行,会影响效率
并行可达性分析
对象消失问题:
对象O引用链断开了
GC从根节点扫描了A节点后
A节点和O建立关系
但是A不会重新扫描了,因此O会被回收
对象O引用链断开了
GC从根节点扫描了A节点后
A节点和O建立关系
但是A不会重新扫描了,因此O会被回收
解决方案:
CMS 增量更新(Incremental Update)
G1 原始快照(Snapshot At The Beginning, SATB)
CMS 增量更新(Incremental Update)
G1 原始快照(Snapshot At The Beginning, SATB)
经典垃圾收集器
Serial
标记 - 复制
标记 - 复制
单线程:
单处理器收集和清理
STW
单处理器收集和清理
STW
新生代
用于桌面场景、微服务应用(内存一般来说不会很大)
ParNew
标记 - 复制
标记 - 复制
Serial的并行版本,公用了很多代码
ParNew + CMS组合
单线程Serial性能优于ParNew
JDK9开始,ParNew+CMS不被官方推荐,推荐G1
Parallel Scavenge
标记 - 复制算法
标记 - 复制算法
关注吞吐量(运行用户代码时间 / 处理器总耗时)即:
高吞吐量保证最高效利用处理器资源,尽快完成程序的运算任务,适合后台运算,不适合界面交互
被称作“吞吐量优先收集器”
两个参数控制吞吐量:最大垃圾收集停顿时间 -XX:MaxGCPauseMillis 和 直接设置吞吐量大小 -XX:GCTimeRatio
最大垃圾收集停顿时间:不能设置太小,垃圾收集停顿时间是以牺牲吞吐量和新生代空间换取的
如:收集300MB比500MB时间短,但是会更频繁,10s一次收集,停顿100ms 变成 5s一次收集,停顿70ms,吞吐量下降
直接设置吞吐量大小:0-100的整数,吞吐量的倒数
最大垃圾收集停顿时间:不能设置太小,垃圾收集停顿时间是以牺牲吞吐量和新生代空间换取的
如:收集300MB比500MB时间短,但是会更频繁,10s一次收集,停顿100ms 变成 5s一次收集,停顿70ms,吞吐量下降
直接设置吞吐量大小:0-100的整数,吞吐量的倒数
-XX:+UseAdaptiveSizePolicy 设置后不用手动设置:
新生代(-Xmm)
Eden/Survivor比例(-XX:SurvivorRatio)
晋升老年代对象大小(-XX:PretenureSizeThreshold)
等细节参数,虚拟机收集性能监控信息,动态调整
新生代(-Xmm)
Eden/Survivor比例(-XX:SurvivorRatio)
晋升老年代对象大小(-XX:PretenureSizeThreshold)
等细节参数,虚拟机收集性能监控信息,动态调整
Serial Old
标记 - 整理
标记 - 整理
Serial老年代版本
CMS收集器发生失败的后备预案
单线程收集清理、STW
Parallel Old
标记 - 整理
标记 - 整理
Parallel Scavenge的老年代版本
多线程收集,STW
CMS
标记 - 清除
标记 - 清除
关注用户线程停顿时间,提高交互体验
过程:
初始标记 STW 标记GC Roots直接关联的对象,速度快
并发标记 标记整个对象图,耗时长
重新标记 STW 修正并发标记期间变动过的对象,用时比初始标记稍长
并发清楚 清理标记阶段已经死亡的对象
初始标记 STW 标记GC Roots直接关联的对象,速度快
并发标记 标记整个对象图,耗时长
重新标记 STW 修正并发标记期间变动过的对象,用时比初始标记稍长
并发清楚 清理标记阶段已经死亡的对象
缺点:
1. 对处理器资源非常敏感,因为并发设计,占用线程,降低吞吐量;线程数:(cpu + 3) / 4,因此四核及以上性能好
2. 由于 浮动垃圾 可能会出现“并发失败(Concurrent Mode Failure)”而导致Full GC,引发STW
浮动垃圾是由于 并发标记和并发清理时 用户程序还在运行,会产生新垃圾,只能下一次收集再清理,因此需要在老年代预留足够内存空间
给浮动垃圾的预留内存不足会导致“并发失败”,启动后备预案,冻结用户线程,启用Serial Old
3. 标记 - 清除会产生空间碎片,多次Full GC后,再启动碎片合并整理过程,减少碎片整理以及更改引用耗时
1. 对处理器资源非常敏感,因为并发设计,占用线程,降低吞吐量;线程数:(cpu + 3) / 4,因此四核及以上性能好
2. 由于 浮动垃圾 可能会出现“并发失败(Concurrent Mode Failure)”而导致Full GC,引发STW
浮动垃圾是由于 并发标记和并发清理时 用户程序还在运行,会产生新垃圾,只能下一次收集再清理,因此需要在老年代预留足够内存空间
给浮动垃圾的预留内存不足会导致“并发失败”,启动后备预案,冻结用户线程,启用Serial Old
3. 标记 - 清除会产生空间碎片,多次Full GC后,再启动碎片合并整理过程,减少碎片整理以及更改引用耗时
G1
整体:标记 - 整理
局部:标记 - 复制
整体:标记 - 整理
局部:标记 - 复制
面向服务端应用
Mixed GC模式:G1不再按代收集,是根据内存存放垃圾数量最多,回收收益最大的标准进行收集
基于Region的堆内存布局实现Mixed GC,每个Region可以扮演不同角色:新生代Eden、Survivor或者老年代
Region中特殊的Humongous区域存储大对象
大对象:超过Region容量一半被定义为大对象
大对象:超过Region容量一半被定义为大对象
细节问题
1. Region跨代引用
记忆集避免全堆扫描
记忆集记录下Region指向自己的指针,并标记这些指针在卡表的范围
G1的记忆集存储结构是Hash表,Key是Region的起始地址,Value是结合存储卡表索引号
2. 并发标记阶段用户线程和收集线程互不干扰的方法
1. 解决用户线程改变对象引用关系式,不能打破原本图结构:
CMS采用的是增量更新,G1是原始快照(SATB)
CMS采用的是增量更新,G1是原始快照(SATB)
2. 回收过程新增对象内存分配时:
G1为每个Region设计了两个名为TAMS(Top at Mark Start)指针
回收时新对象地址必须在两个指针位置上
G1为每个Region设计了两个名为TAMS(Top at Mark Start)指针
回收时新对象地址必须在两个指针位置上
3. 停顿预测模型
满足用户期望的停顿时间方法:
以衰减均值为理论基础实现
以衰减均值为理论基础实现
步骤:
1. 初始标记 STW 标记GC Roots关联的对象,修改TAMS指针值
2. 并发标记 可达性分析,扫描整个对象图,处理SATB并发时引用变动的对象
3. 最终标记 STW 处理并发阶段结束后遗留下来的少量SATB记录
4. 筛选回收 STW 更新Region的统计数据,复制Region存活数据到空Region,清理旧Region
1. 初始标记 STW 标记GC Roots关联的对象,修改TAMS指针值
2. 并发标记 可达性分析,扫描整个对象图,处理SATB并发时引用变动的对象
3. 最终标记 STW 处理并发阶段结束后遗留下来的少量SATB记录
4. 筛选回收 STW 更新Region的统计数据,复制Region存活数据到空Region,清理旧Region
Shenandoah
和G1的异同
相同
和G1有相同的内存布局,共享一部分实现代码
不同
1. 支持并发整理
2. 回收阶段多线程并行,但不能和用户线程并发
3. 不使用分代收集,摒弃了G1耗费大量内存和计算资源维护的记忆集,改为“连接矩阵”
记录跨Region引用,同时降低“伪共享”概率
连接矩阵可理解为二维表格,RegionN对象指向RegionM,就在表格的N行M列标记1
记录跨Region引用,同时降低“伪共享”概率
连接矩阵可理解为二维表格,RegionN对象指向RegionM,就在表格的N行M列标记1
收集过程:
1. 初始标记 STW GC Roots关联对象
2. 并发标记 建立对象图
3. 最终标记 STW SATB扫描,找出Region构成的回收集(Collection Set)
4. 并发清理 清理完全没有存活对象的Region
5. 并发回收 复制存活对象到未使用的Region(读屏障/转发指针Brooks Pointers 解决对象引用旧地址问题)
6. 初始引用更新 STW 修正就对象引用,更改为新地址,目的是设置线程集合点,确保收集线程都完成分配对象移动任务
7. 并发引用更新 真正更新引用,与并发标记不同,不用沿对象图搜索,只需按内存物理地址顺序,线性搜索引用类型并更改为新值即可
8. 最终引用更新 STW 最后一次停顿,修正GC Roots中的引用
9. 并发清理 并发回收和引用更新后,回收集的Region中不再有存货对象,调用一次并发清理过程回收Region
1. 初始标记 STW GC Roots关联对象
2. 并发标记 建立对象图
3. 最终标记 STW SATB扫描,找出Region构成的回收集(Collection Set)
4. 并发清理 清理完全没有存活对象的Region
5. 并发回收 复制存活对象到未使用的Region(读屏障/转发指针Brooks Pointers 解决对象引用旧地址问题)
6. 初始引用更新 STW 修正就对象引用,更改为新地址,目的是设置线程集合点,确保收集线程都完成分配对象移动任务
7. 并发引用更新 真正更新引用,与并发标记不同,不用沿对象图搜索,只需按内存物理地址顺序,线性搜索引用类型并更改为新值即可
8. 最终引用更新 STW 最后一次停顿,修正GC Roots中的引用
9. 并发清理 并发回收和引用更新后,回收集的Region中不再有存货对象,调用一次并发清理过程回收Region
转发指针
Brooks Pointer
Brooks Pointer
转发指针存储在对象头前面
并发问题:通过CAS保证更新转发指针正确性
并行访问效率:对象访问保证原对象和复制对象一致性,同时设置了读屏障和写屏障,读屏障代价比写屏障更大
ZGC
内存布局
1. 与 Shenandoah和G1一样 也采用Region的堆内存布局
2. 不同的是ZGC的Region具有动态性:动态创建、销毁、动态区域容量大小
1. 与 Shenandoah和G1一样 也采用Region的堆内存布局
2. 不同的是ZGC的Region具有动态性:动态创建、销毁、动态区域容量大小
小容量Region:固定2MB,放置 <256KB 的小对象
中型Region:固定32MB,放置>=256KB && <4MB的对象
大型Region:动态容量,最小4MB,2MB整倍数,放置>=4MB的对象
大型Region在ZGC的实现中不会被“重分配”,因为复制大对象代价很昂贵
大型Region在ZGC的实现中不会被“重分配”,因为复制大对象代价很昂贵
并发 - 整理的实现:
Shenandoah使用 转发指针和读屏障实现
ZGC同样用到了读屏障,但更加复杂精巧
Shenandoah使用 转发指针和读屏障实现
ZGC同样用到了读屏障,但更加复杂精巧
染色指针
不需访问对象,可通过指针信息获取对象信息
通过将少量额外信息存储在指针上
Linux下64位指针高18位不能用于寻址,高4位存储4个标志,仅剩下42位用于寻址,因此,ZGC内存不能超过4TB(2^42)
内存限制4TB、不支持32位平台,不支持压缩指针
优势
一但某个Region的存活对象移走,这个Region就能立刻被释放和重用(Shenandoah需要等引用更新结束才能释放回收集中的Region)
大幅减少垃圾收集过程中内存屏障的使用数量
内存屏障主要解决引用变动情况,但维护在指针上就可以省去
ZGC内有使用读屏障,但到目前并未使用任何写屏障
内存屏障主要解决引用变动情况,但维护在指针上就可以省去
ZGC内有使用读屏障,但到目前并未使用任何写屏障
可扩展记录更多与对象标记、重定位过程相关数据,提高性能
实现方式:内存映射
通过先行虚拟空间与物理地址空间的页之间建立映射表
分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换
在Linux/x86-64平台上,ZGC使用多重映射(Multi-Mapping)将多个不同虚拟内存地址映射到同一物理内存地址上
这是一对多对一映射,ZGC在虚拟内存中的地址空间比实际堆内存容量更大
通过先行虚拟空间与物理地址空间的页之间建立映射表
分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换
在Linux/x86-64平台上,ZGC使用多重映射(Multi-Mapping)将多个不同虚拟内存地址映射到同一物理内存地址上
这是一对多对一映射,ZGC在虚拟内存中的地址空间比实际堆内存容量更大
步骤:
1. 并发标记 STW 与G1、Shenandoah一样遍历对象图做可达性分析,标记更新染色指针的Marked 0、Marked 1标志位
2. 并发预备重分配 根据特定查询条件获取需要清理的Region,组成重分配集(Relocation Set)
重分配集和回收集区别:
G1是要做收益优先的增量回收
ZGC是用“更大范围”的扫描省去G1中记忆集的维护成本
因此,重分配集只是决定了里面存活对象会被重新复制到其他Region中,里面的Region会被释放
并不能说明回收行为“只针对这个集合里面的Region”,因为标记过程是针对全堆
3. 并发重分配 核心阶段,把重分配集中存活对象复制到新Region中
为每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系
通过染色指针即可确定是否对象处于重分配集
如果用户线程此时并发访问重分配集中的对象,访问会被预置的内存屏障捕获
然后根据Region的转发记录转发到新复制的对象上,同时修正更新该引用值,指向新对象(指针的“自愈”能力)
这样只有第一次访问就对象会陷入转发
4. 并发重映射 修正堆中指向重分配集就对象的所有引用,并不需要及时完成,因为可以“自愈”
1. 并发标记 STW 与G1、Shenandoah一样遍历对象图做可达性分析,标记更新染色指针的Marked 0、Marked 1标志位
2. 并发预备重分配 根据特定查询条件获取需要清理的Region,组成重分配集(Relocation Set)
重分配集和回收集区别:
G1是要做收益优先的增量回收
ZGC是用“更大范围”的扫描省去G1中记忆集的维护成本
因此,重分配集只是决定了里面存活对象会被重新复制到其他Region中,里面的Region会被释放
并不能说明回收行为“只针对这个集合里面的Region”,因为标记过程是针对全堆
3. 并发重分配 核心阶段,把重分配集中存活对象复制到新Region中
为每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系
通过染色指针即可确定是否对象处于重分配集
如果用户线程此时并发访问重分配集中的对象,访问会被预置的内存屏障捕获
然后根据Region的转发记录转发到新复制的对象上,同时修正更新该引用值,指向新对象(指针的“自愈”能力)
这样只有第一次访问就对象会陷入转发
4. 并发重映射 修正堆中指向重分配集就对象的所有引用,并不需要及时完成,因为可以“自愈”
虚拟机执行子系统
类文件结构
Class文件结构
魔数/Class版本
魔数:class文件头4个字节0xCAFEBABE
版本:魔数后面4个字节
5、6字节是次版本号
7、8字节是主版本号
5、6字节是次版本号
7、8字节是主版本号
常量池
版本号后是常量池入口
表类型
u2类型的数据:常量不固定,代表常量池容量计数器,从1开始
存放:字面量(Literal)和符号引用(Symbolic References)
字面量:接近常量概念,如
文本字符串
final修饰的常量等
文本字符串
final修饰的常量等
符号引用:属于编译原理方面的概念,包括
1. 被模块导出或开放的包
2. 类和接口的全限定名
3. 字段的名称和描述符
4. 方法的名称和描述符
5. 方法句柄和方法类型
6. 动态调用点和动态常量
1. 被模块导出或开放的包
2. 类和接口的全限定名
3. 字段的名称和描述符
4. 方法的名称和描述符
5. 方法句柄和方法类型
6. 动态调用点和动态常量
常量池项目类型
总表
访问标志
索引
类索引:u2类型数据
父类索引:u2类型数据
接口索引集合:u2类型数据集合
字段表
修饰符 access_flag
字段简单名称 name_index
方法描述符 descriptor_index
方法表
属性表
方法定义可以通过访问标志、名称索引、描述符索引等来表达
代码是在方法属性表集合中名为“Code”的属性里面
代码是在方法属性表集合中名为“Code”的属性里面
字段表和方法表中的attribute_info
字节码指令
数据类型
加载和存储指令
运算指令
类型转换指令
对象创建和访问指令
操作数栈管理指令
控制转移指令
方法调用和返回指令
异常处理指令
同步指令
类加载机制
类加载时机
JVM没有强制约束“加载”阶段
但规定了“初始化”阶段,类初始化时机:
1. 遇到new、getstatic、putstatic或invokestatic时,类型没有初始化就要触发初始化阶段
以上4个指令触发场景如下:
· 使用new实例化对象
· 读取设置静态字段(不包括final、编译期结果放入常量池的静态字段)
· 调用类型的静态方法
2. java.lang.reflect反射调用的时候,没有初始化就触发初始化
3. 初始化类型时,父类还没初始化,需要先触发父类初始化
4. 虚拟机启动时会初始化主类
5. 动态语言java.lang.invoke.MethodHandle解析结果是:
REF_getStatic
REF_putStatic
REF_invokeStatic
REF_newInvokeSpecial
以上四种类型方法句柄对应的类没有初始化,需要先触发初始化
6. 接口定义默认方法后,实现类发生初始化时接口要先进行初始化
但规定了“初始化”阶段,类初始化时机:
1. 遇到new、getstatic、putstatic或invokestatic时,类型没有初始化就要触发初始化阶段
以上4个指令触发场景如下:
· 使用new实例化对象
· 读取设置静态字段(不包括final、编译期结果放入常量池的静态字段)
· 调用类型的静态方法
2. java.lang.reflect反射调用的时候,没有初始化就触发初始化
3. 初始化类型时,父类还没初始化,需要先触发父类初始化
4. 虚拟机启动时会初始化主类
5. 动态语言java.lang.invoke.MethodHandle解析结果是:
REF_getStatic
REF_putStatic
REF_invokeStatic
REF_newInvokeSpecial
以上四种类型方法句柄对应的类没有初始化,需要先触发初始化
6. 接口定义默认方法后,实现类发生初始化时接口要先进行初始化
类加载过程
加载
“加载”是“类加载”第一个阶段,需要完成
1. 通过类全限定名获取二进制字节流
2. 静态存储结构转化为方法区的运行时数据结构
3. 内存中生成java.lang.Class对象,作为方法区各数据访问入口
1. 通过类全限定名获取二进制字节流
2. 静态存储结构转化为方法区的运行时数据结构
3. 内存中生成java.lang.Class对象,作为方法区各数据访问入口
获取字节流方式很灵活:zip、网络、动态代理、其他文件、数据库、加密文件等
数组不通过类加载器创建,是由虚拟机动态构造出来的,需要遵循以下规则
1. 数组组件(Component Type)类型是引用类型,递归加载组件类型,数组将被标识在加载该组件类型的类加载器的类名空间上
2. 如果数组的组件类型不是引用类型,JVM将会把数组标记为与引导类加载器关联
3. 数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到
1. 数组组件(Component Type)类型是引用类型,递归加载组件类型,数组将被标识在加载该组件类型的类加载器的类名空间上
2. 如果数组的组件类型不是引用类型,JVM将会把数组标记为与引导类加载器关联
3. 数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到
验证
文件格式验证
验证字节流是否符合Class文件格式规范
魔数开头、主次版本号是否在JVM接受范围内等
魔数开头、主次版本号是否在JVM接受范围内等
元数据验证
对字节码描述信息进行语义分析,验证点包括:
是否有父类、是否继承不允许继承的类、非抽象类是否实现抽象方法等
是否有父类、是否继承不允许继承的类、非抽象类是否实现抽象方法等
字节码验证
· 最复杂的阶段,目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的
对Class中的Code属性进行校验,需要保证被校验类的方法运行时不会危害虚拟机安全行为
· 但是通过验证也不一定是安全的,不可能通过程序准确判断一段程序是否存在bug
· 由于数据流分析和程序流分析的高度复杂,避免过多耗时,JDK6 进行了一项优化:在Code属性中增加了一项“StackMapTable”的属性,描述方法体所有基本块开始时本地变量表和操作栈应有的状态,验证期间只需要检查状态是否合法即可
对Class中的Code属性进行校验,需要保证被校验类的方法运行时不会危害虚拟机安全行为
· 但是通过验证也不一定是安全的,不可能通过程序准确判断一段程序是否存在bug
· 由于数据流分析和程序流分析的高度复杂,避免过多耗时,JDK6 进行了一项优化:在Code属性中增加了一项“StackMapTable”的属性,描述方法体所有基本块开始时本地变量表和操作栈应有的状态,验证期间只需要检查状态是否合法即可
符号引用验证
· 校验虚拟机将符号引用转化为直接引用(将在解析阶段进行转化)
· 符号引用校验:可看作类自身以外的各类信息进行匹配性校验,通俗讲是否缺少或被禁止访问它依赖的某些外部类、方法、字段等资源
· 目的是确保解析行为能正常执行
· 符号引用校验:可看作类自身以外的各类信息进行匹配性校验,通俗讲是否缺少或被禁止访问它依赖的某些外部类、方法、字段等资源
· 目的是确保解析行为能正常执行
准备
为类中定义的 变量分配内存 并 设置类变量初始值 的阶段
概念上讲,这些变量都应当在方法区分配
方法区本身是逻辑上的区域:
JDK7及之前,使用永久代来实现方法区时,方法区实现完全符合逻辑概念
JDK8及之后,类变量会随着Class对象一起存在Java堆中,这时候“类变量在方法区”只是一种对逻辑概念的表述了
方法区本身是逻辑上的区域:
JDK7及之前,使用永久代来实现方法区时,方法区实现完全符合逻辑概念
JDK8及之后,类变量会随着Class对象一起存在Java堆中,这时候“类变量在方法区”只是一种对逻辑概念的表述了
· 内存分配仅包含类变量,不包含实例变量(实例变量会在对象实例化时随对象一起分配在Java堆中)
· 通常初始值一般是0值,比如static int value = 123;初始化后值不是123而是0,
因为尚未开始执行任何Java方法,赋值为123的动作要到类的初始化阶段才被执行
· 也有非0值的情况,如过类字段的字段属性表中存在ConstantValue属性,在准备阶段变量就会被初始化为ConstantValue属性所指定的初始值,如static final int value = 123,那javac将会为value生成ConstantValue属性,准备阶段就会根据ConstantValue属性将value设置为123
· 通常初始值一般是0值,比如static int value = 123;初始化后值不是123而是0,
因为尚未开始执行任何Java方法,赋值为123的动作要到类的初始化阶段才被执行
· 也有非0值的情况,如过类字段的字段属性表中存在ConstantValue属性,在准备阶段变量就会被初始化为ConstantValue属性所指定的初始值,如static final int value = 123,那javac将会为value生成ConstantValue属性,准备阶段就会根据ConstantValue属性将value设置为123
解析
符号引用替换为直接引用
符号引用在Class文件中以:
CONSTANT_Class_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
等类型的常量出现
符号引用在Class文件中以:
CONSTANT_Class_info
CONSTANT_Fieldref_info
CONSTANT_Methodref_info
等类型的常量出现
直接引用和符号引用的关联:
1. 符号引用:用符号描述引用目标,符号可以是任何形式的字面量
和内存布局无关
2. 直接引用:可以直接指向目标指针、相对偏移量、间接定位到目标的句柄
和内存布局直接相关
1. 符号引用:用符号描述引用目标,符号可以是任何形式的字面量
和内存布局无关
2. 直接引用:可以直接指向目标指针、相对偏移量、间接定位到目标的句柄
和内存布局直接相关
解析动作
类或接口的解析
字段解析
方法解析
接口方法解析
初始化
开始执行类中编写的Java程序代码,主导权交给应用程序
初始化类变量和其他资源
执行类构造器<init>()方法
静态块、构造器、动态块顺序
类加载器
类与类加载器
不同类加载器加载的类不相等
双亲委派模型
要求除顶层启动类加载器外
其余类加载器都应有自己的父类加载器
父子之间是组合关系
工作过程:
1. 类加载器收到类加载请求
2. 把请求委派给父类加载器
3. 最终请求到达顶层启动类加载器
4. 父类加载器无法完成这个加载请求
5. 子类才会去尝试自己加载
要求除顶层启动类加载器外
其余类加载器都应有自己的父类加载器
父子之间是组合关系
工作过程:
1. 类加载器收到类加载请求
2. 把请求委派给父类加载器
3. 最终请求到达顶层启动类加载器
4. 父类加载器无法完成这个加载请求
5. 子类才会去尝试自己加载
启动类加载器
<JAVA_HOME>\lib或-Xbootclasspath,(JVM可识别的:rt.jar、tools.jar)
扩展类加载器
<JAVA_HOME>\lib\ext或java.ext.dirs系统变量制定的目录
应用程序类加载器
sun.misc.Launcher$AppClassLoader实现,加载用户类路径上类的库
破坏双亲委派模型
双亲委派不是一个具有具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载实现方式
大部分类加载器都遵循这个模型
但有3次较大规模的破坏
但有3次较大规模的破坏
JDK1.2以后引入双亲委派,在之前版本为了兼容而妥协
自身缺陷导致
JNDI服务有启动类加载器完成加载
但需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口SPI
启动类加载器不可能认识加载这些代码
为解决这个问题,引入了线程上下文类加载器:
类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置
JDK6提供java.util.ServiceLoader类,以META-INF/services中配置信息,借助责任链模式提供了相对合理的解决方案
JNDI服务有启动类加载器完成加载
但需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口SPI
启动类加载器不可能认识加载这些代码
为解决这个问题,引入了线程上下文类加载器:
类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置
JDK6提供java.util.ServiceLoader类,以META-INF/services中配置信息,借助责任链模式提供了相对合理的解决方案
用户对程序动态性的追求而导致:热替换、热部署等
OSGi关键点:自定义类加载机制
每个程序模块(Bundle)都有自己的类加载器,需要更换Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换
在OSGi环境下,类加载器不再使用双亲委派的树状模型,而是网状结构
当收到类加载请求时,OSGi将按照下面顺序进行类搜索:
1. 以java.*开头的类,委派给父类加载器加载
2. 否则,委派列表名单内的类,委派给父类加载器加载
3. 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载
4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
7. 否则,类查找失败
OSGi关键点:自定义类加载机制
每个程序模块(Bundle)都有自己的类加载器,需要更换Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换
在OSGi环境下,类加载器不再使用双亲委派的树状模型,而是网状结构
当收到类加载请求时,OSGi将按照下面顺序进行类搜索:
1. 以java.*开头的类,委派给父类加载器加载
2. 否则,委派列表名单内的类,委派给父类加载器加载
3. 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载
4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
7. 否则,类查找失败
Java模块化系统
JDK9引入Java模块化系统的重要升级
目标:可配置的封装隔离机制
JVM对类加载架构做了相应变动,使模块化系统顺利运行
解决的问题:
1. JDK9之前基于类路径查找依赖可靠性问题
2. 类路径上跨JAR文件的public类型的可访问性问题
目标:可配置的封装隔离机制
JVM对类加载架构做了相应变动,使模块化系统顺利运行
解决的问题:
1. JDK9之前基于类路径查找依赖可靠性问题
2. 类路径上跨JAR文件的public类型的可访问性问题
模块兼容性
模块化系统按照以下规则保证传统类路径依赖的Java程序可以不用修改直接运行在JDK9以上版本上:
1. JAR文件在类路径的访问规则:
所有类路径下JAR文件和其他资源都被视为匿名模块(Unnamed Module)
2. 模块在模块路径的访问规则:
模块路径下的具名模块(Named Module)只能访问到她依赖定义中列明依赖的模块和包
匿名模块里所有内容对具名模块来说都是不可见的
3. JAR文件在模块路径的访问规则:
如果把一个传统、不包含模块定义的JAR文件放置到模块路径它就会变成自动模块
尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块
因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包
1. JAR文件在类路径的访问规则:
所有类路径下JAR文件和其他资源都被视为匿名模块(Unnamed Module)
2. 模块在模块路径的访问规则:
模块路径下的具名模块(Named Module)只能访问到她依赖定义中列明依赖的模块和包
匿名模块里所有内容对具名模块来说都是不可见的
3. JAR文件在模块路径的访问规则:
如果把一个传统、不包含模块定义的JAR文件放置到模块路径它就会变成自动模块
尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块
因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包
模块化下的类加载器
四点变动
1. 扩展类加载器被平台类加载器取代
1. 扩展类加载器被平台类加载器取代
2. 平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader
如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK9及更高版本的JDK中崩溃
如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK9及更高版本的JDK中崩溃
3. 为了保证和之前代码兼容,所有获取启动类加载器的场景中任然会返回null来替代,而不会得到BootClassLoader的实例
(如Object.class.getClassLoader)
(如Object.class.getClassLoader)
4. 委派关系发生变化:自定义类加载器->应用程序类加载器<->平台类加载器->启动类加载器
虚拟机字节码执行引擎
运行时栈结构
JVM以方法作为最基本执行单元
“栈帧”是用于支持虚拟机进行方法调用和执行的数据结构
JVM以方法作为最基本执行单元
“栈帧”是用于支持虚拟机进行方法调用和执行的数据结构
局部变量表
变量值存储空间
存放方法参数、方法内部定义的局部变量
编译class文件时,在方法的Code属性的max_locals数据项中
确定该方法需分配的局部变量表的最大容量
确定该方法需分配的局部变量表的最大容量
容量以变量槽为单位,变量槽空间没有明确规定
但规定都可以使用32位或更小物理内存存储
且规定每个变量槽必须能够存储以下八种类型数据:
boolean/byte/char/short/int/float/reference/returnAddress
但规定都可以使用32位或更小物理内存存储
且规定每个变量槽必须能够存储以下八种类型数据:
boolean/byte/char/short/int/float/reference/returnAddress
long/double需要占用2个连续的变量槽
变量槽线程私有,连续读写两个不会有线程安全问题
变量槽线程私有,连续读写两个不会有线程安全问题
对象中的方法,第0号变量槽用于传递this引用,其他按照参数顺序排列
变量槽中持有对象引用,gc不会回收
如:
{
byte[] data = new byte[64 * 1024 * 1204];
}
// 这行打开可回收,因为代码已离开data的作用于,并且变量槽被复用
// 不打开,data所在变量槽还持有data引用
// int a = 0;
System.gc();
如:
{
byte[] data = new byte[64 * 1024 * 1204];
}
// 这行打开可回收,因为代码已离开data的作用于,并且变量槽被复用
// 不打开,data所在变量槽还持有data引用
// int a = 0;
System.gc();
操作数栈
LIFO栈
方法执行时,字节码指令会入栈出栈
JVM解释执行引擎被称为“基于栈的执行引擎”,“栈”指的就是操作数栈
动态连接
为支持动态连接,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
静态解析:符号引用在类加载阶段或第一次使用时转化为直接引用
动态连接:每次运行期间把符号引用转化为直接引用
动态连接:每次运行期间把符号引用转化为直接引用
方法返回地址
方法退出:
1. 正常退出
2. 异常退出
1. 正常退出
2. 异常退出
任何退出方式,退出后都必须返回方法调用处
附加信息
允许JVM规范中没有描述的信息增加到栈中
方法调用
唯一的任务:
确定被调用方法的版本
唯一的任务:
确定被调用方法的版本
解析
静态过程
静态过程
一部分方法在解析期间符号引用转换为直接引用;
这类方法有明确的方法版本、运行期此方法版本不会改变;
这类方法的调用过程叫做解析
这类方法有明确的方法版本、运行期此方法版本不会改变;
这类方法的调用过程叫做解析
可在类加载的时候解析的:
静态方法、私有方法、实例构造器、父类方法、被final修饰的方法
静态方法、私有方法、实例构造器、父类方法、被final修饰的方法
分派
可能是静态,也可能是动态过程
多态特性的实现
可能是静态,也可能是动态过程
多态特性的实现
静态分派
Human man = new Man()
Human 静态类型
Man 实际类型
Say.sayHello(man) // 方法版本1 : sayHello(Human human)
Say.sayHello((Man) man) // 方法版本2 : sayHello(Man man)
Human 静态类型
Man 实际类型
Say.sayHello(man) // 方法版本1 : sayHello(Human human)
Say.sayHello((Man) man) // 方法版本2 : sayHello(Man man)
所有依赖 静态类型 来决定方法执行 版本 的分派动作,都成为静态分派
静态分派发生在编译阶段
方法重载的本质:编译期间选择静态分派目标的过程
方法重载的本质:编译期间选择静态分派目标的过程
动态分派
重写的重要体现
运行期确定实际类型
根据方法接受者的实际类型选择方法版本,这就是重写的本质
根据方法接受者的实际类型选择方法版本,这就是重写的本质
单分派与多分派
多分派:编译阶段选择过程、静态分派、静态类型对应的方法的选择过程
编译时:
Father son = new Son();
son.hardChoice(QQ);
invokevirtual指令指向的是Father的hardChoice(QQ)的方法
编译时:
Father son = new Son();
son.hardChoice(QQ);
invokevirtual指令指向的是Father的hardChoice(QQ)的方法
单分派:运行阶段选择过程、动态分派、实际类型对应方法的选择过程
运行时:
根据实际对象选择真正执行的对象方法
son.hardChoice(QQ)的方法
运行时:
根据实际对象选择真正执行的对象方法
son.hardChoice(QQ)的方法
虚拟机动态分派的实现
虚方法表(Virtual Method Table,也叫vtable)
提高性能
提高性能
子类没重写父类方法,子类vtable中地址指向和父类相同的方法地址入口
子类重写了父类方法,子类vtable中地址被替换为指向子类实现版本的入口地址
子类重写了父类方法,子类vtable中地址被替换为指向子类实现版本的入口地址
动态类型语言
由java.lang.invoke包实现:例:MethodHandle
和反射区别
1. Reflection是Java代码层次,MethodHandle是字节码层次
2. Reflection信息比MethodHandle信息多,Reflection太重
3. MethodHandle是对字节码方法指令调用的模拟,各种JVM层面优化可以得到支持
invokedynamic
字节码解释执行引擎
解释执行
基于栈的指令集与基于寄存器的指令集
优缺点
基于栈可移植,寄存器由硬件提供
寄存器指令集执行速度快
基于栈的解释器执行过程
变量以出栈入栈作为信息交换途径
程序编译与代码优化
前端编译与优化
javac
编译过程:1个准备过程,3个处理过程
1. 准备:初始化插入式注解处理器
2. 解析与填充符号表过程
词法/语法分析
词法分析是将源代码字符流变为标记集合的过程
语法分析是根据标记序列构造抽象语法树的过程
语法书每个节点都代表程序代码中的一个语法结构
语法书每个节点都代表程序代码中的一个语法结构
填充符号表
符号表是由一组符号地址和符号信息构成的数据结构
该过程的产出物是一个待处理列表
3. 插入式注解处理器的注解过程
注解本是运行期发挥作用
JDK6引入插入式注解处理器,可在编译期对代码中的特定注解进行处理
JDK6引入插入式注解处理器,可在编译期对代码中的特定注解进行处理
4. 分析与字节码生成过程
标注检查
检查包括:变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等
数据流及控制流分析
对程序上下文更进一步的验证,如:
程序局部变量在使用前是否有赋值
方法的每条路径是否都与返回值
是否所有受查异常都被正确处理了等
程序局部变量在使用前是否有赋值
方法的每条路径是否都与返回值
是否所有受查异常都被正确处理了等
编译期和类加载时的数据流及控制流分析的目的基本可以看做一致
但校验范围有区别:有一些校验只有在编译期或运行期才能进行
但校验范围有区别:有一些校验只有在编译期或运行期才能进行
解语法糖
语法糖能够减少代码量,增加程序可读性,减少程序出错机会
常见:泛型、变长参数、自动装箱拆箱等
运行时并不支持这些语法,在编译阶段被还原,成为解语法糖
字节码生成
前面各步生成的信息转化为字节码指令写入磁盘
同时进行少量代码添加和转换
同时进行少量代码添加和转换
如:添加实例构造器<init>()和类构造器<cinit>()方法
(注意:默认构造器是在填充符号表阶段添加)
(注意:默认构造器是在填充符号表阶段添加)
JVM会自动保证构造器被正确执行
无论源码顺序如何,一定是按:先执行父类实例构造器,然后初始化变量,最后执行语句块的顺序执行
无论源码顺序如何,一定是按:先执行父类实例构造器,然后初始化变量,最后执行语句块的顺序执行
语法糖
泛型
类型擦除:
兼容旧版本容器类,在编译阶段去掉泛型,在插入获取时增加强转和检查
兼容旧版本容器类,在编译阶段去掉泛型,在插入获取时增加强转和检查
问题1. int、long等类型和Object之间不支持强转,使用了包装类,自动装箱拆箱导致性能问题
问题2. 运行期无法获取到泛型信息
编译阶段擦除只是把Signature的字节码层面的特征签名进行了擦除
实际上元数据中还保留了泛型信息,这也是可以通过反射获取参数化类型的根本依据
Signature:是重要的属性,作用:
存储方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息
实际上元数据中还保留了泛型信息,这也是可以通过反射获取参数化类型的根本依据
Signature:是重要的属性,作用:
存储方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息
自动装箱、拆箱与遍历循环
条件编译
根据if中boolean常量值的真假,把分支中不成立的代码块消除掉
后端编译与优化
即时编译器
解释器与编译器
解释器:
程序需要迅速启动和执行时发挥作用,省去编译时间
可以作为编译器激进优化时的“逃生门”
内存资源限制较大时,可以使用解释器节省内存
程序需要迅速启动和执行时发挥作用,省去编译时间
可以作为编译器激进优化时的“逃生门”
内存资源限制较大时,可以使用解释器节省内存
编译器:
程序启动后,把越来越多的代码编译成本地代码,减少解释器的中间损耗,提高执行效率
当激进优化假设不成立,如加载新类后,继承结构发生变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行
程序启动后,把越来越多的代码编译成本地代码,减少解释器的中间损耗,提高执行效率
当激进优化假设不成立,如加载新类后,继承结构发生变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行
编译器和解释器经常是相辅相成配合工作
HotSpot虚拟机内置两个(或三个)即时编译器,客户端编译器和服务端编译器(C1和C2编译器)
第三个是JDK10出现,长期目标是代替C2的Graal编译器
第三个是JDK10出现,长期目标是代替C2的Graal编译器
为了在程序启动相应速度与运行效率之间达到最佳平衡,HotSpot在编译子系统中加入了分层编译功能
分层编译出现前,HotSpot采用解释器与其中一个编译器搭配,程序采用哪个编译器取决于虚拟机运行模式
分层编译出现前,HotSpot采用解释器与其中一个编译器搭配,程序采用哪个编译器取决于虚拟机运行模式
分层编译根据编译器编译、优化的规模与耗时划分出不同编译层次如下:
0层:程序纯解释执行,并且解释器不开启性能监控功能
1层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能
2层:仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限性能监控
3层:仍然使用客户端编译器执行,开启全部性能监控
除第2层统计信息外,还收集如分支跳转、虚方法调用版本等全部统计信息
除第2层统计信息外,还收集如分支跳转、虚方法调用版本等全部统计信息
4层:使用服务端编译器将字节码编译为本地代码
相比客户端编译器,服务端编译器会启用更多编译耗时更长的优化
还会根据性能监控信息进行一些不可靠的激进优化
相比客户端编译器,服务端编译器会启用更多编译耗时更长的优化
还会根据性能监控信息进行一些不可靠的激进优化
实施分层编译,解释器、C1、C2会同时工作,热点代码可能会多次编译,C1获取更高编译速度,C2获取更高编译质量
编译器对象与触发条件
热点代码
两种情况下,编译的目标对象都是整个方法体
两种情况下,编译的目标对象都是整个方法体
被多次调用的方法
编译整个方法,标准的即时编译器方式
被多次执行的循环体
虽然编译动作是循环体触发,热点只是方法的一部分
但是编译器依然必须以整个方法作为编译对象
因为编译发生在方法执行过程中,因此称为“栈上替换”
但是编译器依然必须以整个方法作为编译对象
因为编译发生在方法执行过程中,因此称为“栈上替换”
热点探测
基于采样的热点探测
周期性检查各线程调用栈顶,经常出现的方法就是“热点方法”
基于计数器的热点探测
为每个方法建立计数器,超过阈值认为是“热点方法”
阈值:
客户端模式下默认1500
服务端模式下默认10000
阈值:
客户端模式下默认1500
服务端模式下默认10000
方法调用计数器
在一定时间内,调用次数达不到阈值,计数器减半
这个过程叫做“方法调用计数器热度的衰减”
这段时间叫做“半衰时间”
热度衰减动作是在垃圾回收期间顺便进行
这个过程叫做“方法调用计数器热度的衰减”
这段时间叫做“半衰时间”
热度衰减动作是在垃圾回收期间顺便进行
回边计数器
在循环边界往回跳转
统计方法中循环体代码执行次数
通过-XX:OnStackReplacePercentage(OSR)间接调整阈值:
1. C1:阈值公式:CompileThreshold * OSR / 100;
OSR默认933,默认阈值13995
2. C2:阈值公式:CompileThreshold * (OSR - InterpreterProfilePercentage) / 100;
OSR默认140,InterpreterProfilePercentage默认33,默认阈值10700
统计方法中循环体代码执行次数
通过-XX:OnStackReplacePercentage(OSR)间接调整阈值:
1. C1:阈值公式:CompileThreshold * OSR / 100;
OSR默认933,默认阈值13995
2. C2:阈值公式:CompileThreshold * (OSR - InterpreterProfilePercentage) / 100;
OSR默认140,InterpreterProfilePercentage默认33,默认阈值10700
回边计数器没有热度衰减过程
编译过程
客户端
三段式编译
关注局部优化
放弃许多耗时较长的全局优化
关注局部优化
放弃许多耗时较长的全局优化
阶段1:一个平台独立的前端将字节码构造成一种高级中间代码标识HIR
在此前编译器已经会完成一部分基础优化:方法内联、常量传播等优化
在此前编译器已经会完成一部分基础优化:方法内联、常量传播等优化
阶段2:一个平台相关的后端从HIR中产生低级中间代码标识LIR
在此前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除
在此前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除
阶段3:平台相关的后端用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码
服务端
会执行大部分经典优化动作
无用代码消除
循环展开
循环表达式外提
消除公共子表达式
常量传播
基本快重排序等
无用代码消除
循环展开
循环表达式外提
消除公共子表达式
常量传播
基本快重排序等
还会实施一些与Java语言特性密切相关的优化如:
范围检查消除
控制检查消除
范围检查消除
控制检查消除
另外还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化如:
守护内联
分支频率预测等
守护内联
分支频率预测等
提前编译器
提前编译
ART
ART
提前编译的优劣得失
1. 传统提前编译
最大弱点:占用程序运行时间和运算资源
Android 5/6提前编译,安装应用慢
Android 7重启解释执行和即时编译,空闲时间后台自动进行提前编译
最大弱点:占用程序运行时间和运算资源
Android 5/6提前编译,安装应用慢
Android 7重启解释执行和即时编译,空闲时间后台自动进行提前编译
2. 动态提前编译(及时编译缓存JIT Caching)
本质是给即时编译器做缓存加速,改善Java程序的启动时间,需要一段时间预热后才能达到最高性能
本质是给即时编译器做缓存加速,改善Java程序的启动时间,需要一段时间预热后才能达到最高性能
问题:提前编译和即时编译代码输出质量对比
提前编译:没有执行时间和资源限制压力
即时编译:
1. 性能分析制导优化:运行中不断收集性能监控信息,可进行热点代码优化
2. 激进预测性优化:即时编译优化措施的基础,根据性能监控信息大胆按照高概率的假设进行优化,
如果有问题再退回到低级编译器甚至解释器上去执行,并不会出现无法挽回的后果
虚方法可进行方法内联优化,是通过类继承关系等激进猜测去做去虚拟化
3. 链接时优化LTO:和C/C++不同,C/C++主程序与动态链接库是完全独立的
提前编译:没有执行时间和资源限制压力
即时编译:
1. 性能分析制导优化:运行中不断收集性能监控信息,可进行热点代码优化
2. 激进预测性优化:即时编译优化措施的基础,根据性能监控信息大胆按照高概率的假设进行优化,
如果有问题再退回到低级编译器甚至解释器上去执行,并不会出现无法挽回的后果
虚方法可进行方法内联优化,是通过类继承关系等激进猜测去做去虚拟化
3. 链接时优化LTO:和C/C++不同,C/C++主程序与动态链接库是完全独立的
编译器优化技术
方法内联
把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用
无法内联的原因:
除上述四种方法外
其他Java方法调用必须在运行时进行多态选择
他们都可能存在多于一个版本的方法接收者,简而言之,Java语言中默认的实例方法都是虚方法
- 使用invokespecial指令调用的私有方法
- 实例构造器
- 父类方法
- 使用invokestatic指令调用的静态方法
除上述四种方法外
其他Java方法调用必须在运行时进行多态选择
他们都可能存在多于一个版本的方法接收者,简而言之,Java语言中默认的实例方法都是虚方法
对于虚方法,编译器静态的去做内联的时候很难确定方法版本,面向对象加重了这种情况
为解决这个问题,JVM引入“类型集成关系分析(CHA)”的技术
用于确定某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等
内联时可根据不同情况采取不同处理:
非虚方法直接内联
虚方法会查询CHA,如果方法只有一个版本,就进行内联,这种内联被称为“守护内联”
守护内联是激进优化,当加载的新类型改变了CHA的结论,使用“逃生门”进行解释执行
为解决这个问题,JVM引入“类型集成关系分析(CHA)”的技术
用于确定某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等
内联时可根据不同情况采取不同处理:
非虚方法直接内联
虚方法会查询CHA,如果方法只有一个版本,就进行内联,这种内联被称为“守护内联”
守护内联是激进优化,当加载的新类型改变了CHA的结论,使用“逃生门”进行解释执行
内联缓存:
当CHA查询到多个方法版本,即时编译器还会进行最后一次努力,使用内联缓存
这种状态下,会直接进行方法调用,但是比直接查虚方法表要快
内联缓存工作原理:
· 第一次调用后,缓存记录下方法接收者版本信息
每次进行方法调用时都比较接收者版本,每次调用接收者版本都一样,那它就是一种单态内联缓存
比用不内联的非虚方法仅多一次类型判断开销
· 如果真出现方法接收者不一致,说明用了多态特性,这时会退化成超多态内联缓存
开销相当于真正查找虚方发表进行方法分派
当CHA查询到多个方法版本,即时编译器还会进行最后一次努力,使用内联缓存
这种状态下,会直接进行方法调用,但是比直接查虚方法表要快
内联缓存工作原理:
· 第一次调用后,缓存记录下方法接收者版本信息
每次进行方法调用时都比较接收者版本,每次调用接收者版本都一样,那它就是一种单态内联缓存
比用不内联的非虚方法仅多一次类型判断开销
· 如果真出现方法接收者不一致,说明用了多态特性,这时会退化成超多态内联缓存
开销相当于真正查找虚方发表进行方法分派
逃逸分析
分析对象动态作用域:
当一个对象在方法里面被定以后,它可能被外部方法引用称为“方法逃逸”
当被外部线程访问到,称为“线程逃逸”
从 不逃逸、方法逃逸、线程逃逸 称为对象有低到高的不同逃逸程度
当一个对象在方法里面被定以后,它可能被外部方法引用称为“方法逃逸”
当被外部线程访问到,称为“线程逃逸”
从 不逃逸、方法逃逸、线程逃逸 称为对象有低到高的不同逃逸程度
如果能证明对象不会逃逸,或逃逸程度低(只是方法逃逸),可采取优化
栈上分配
线程私有的对象,可以将它们分配在栈上,而不是堆上
对象内存占用随出栈销毁,不用GC介入,降低GC压力
支持方法逃逸,不支持线程逃逸
对象内存占用随出栈销毁,不用GC介入,降低GC压力
支持方法逃逸,不支持线程逃逸
标量替换
标量:无法再分解成更小的数据,比如int、long等数值类型以及reference类型
聚合量:还可以再进行分解的数据,比如对象
聚合量:还可以再进行分解的数据,比如对象
标量替换:把Java对象拆散,将程序用到的成员变量恢复为原始类型来访问的过程
假如逃逸分析能够证明对象不会被方法外部访问,并且可拆散,那程序不会创建这个对象,而是直接改为被这个方法使用的成员变量来代替。
将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步和优化手段创造条件
将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步和优化手段创造条件
同步消除
如果逃逸分析能够确定变量不会逃逸出线程,那变量读写肯定不会有竞争,同步措施可以安全消除
公共子表达式消除
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E就称为公共子表达式
这种表达式没必要再进行计算,直接使用前面结果代替E
局部公共子表达式消除:仅限于程序基本块内的优化
全局公共子表达式消除:优化范围涵盖多个基本快
全局公共子表达式消除:优化范围涵盖多个基本快
数组边界检查消除
编译期根据数据流分析数组长度,执行时无需判断
高效并发
Java内存模型和线程
处理器之间“缓存一致性”问题:
处理器<->高速缓存<->MSI/MESI等<->主内存
处理器之间“缓存一致性”问题:
处理器<->高速缓存<->MSI/MESI等<->主内存
Java内存模型(JMM)
主内存和工作内存
Java线程<->工作内存<->Save/Load操作<->主内存
主内存对应于物理硬件内存、工作内存对应于寄存器或高速缓存
线程工作内存中保存了变量的主内存副本
线程对变量的所有操作都不洗在工作内存中进行
线程间变量值的传递需要通过主内存来完成
线程对变量的所有操作都不洗在工作内存中进行
线程间变量值的传递需要通过主内存来完成
内存交互
lock
作用于主内存:变量标识为独占
unlock
作用于主内存:释放锁定状态的变量
read
作用于主内存:变量值从主内存传输到线程工作内存
load
作用于工作内存:把read获取的值放入工作内存副本中
use
作用于工作内存:把工作内存值传递给执行引擎
assign
作用于工作内存:把执行引擎接收的值给工作内存变量
store
作用于工作内存:把工作内存变量传送到主内存
write
作用于主内存:把store获取的值放入主内存
volatile
可见性
禁止指令重排序
重排序是机器级的优化
指令依赖情况下保证得到正确结果
重排序是机器级的优化
指令依赖情况下保证得到正确结果
读操作和普通变量几乎没差别
写操作会慢一些,因为需要在代码中插入内存屏障指令(lock指令,可引起其他处理器无效化Invalidate其缓存)
保证处理器不发生乱序执行
写操作会慢一些,因为需要在代码中插入内存屏障指令(lock指令,可引起其他处理器无效化Invalidate其缓存)
保证处理器不发生乱序执行
long/double
允许虚拟机将没有被volatile修饰的64位数据读写操作划分为2次32位操作
即允许虚拟机自行选择是否要保证64位数据类型load/store/read/write操作的原子性
即允许虚拟机自行选择是否要保证64位数据类型load/store/read/write操作的原子性
原子性/可见性/有序性
原子性
基本类型读写是原子性(除long/double)
monitorenter/monitorexit对应的是synchronized关键字保证原子性
可见性
volatile
synchronized
final
有序性
如果在本线程中观察,所有操作都有序(线程内表现为串行的语句)
如果在其他线程观察本线程,所有操作都无序(“指令重排序”和“工作内存与主内存同步延时”)
如果在其他线程观察本线程,所有操作都无序(“指令重排序”和“工作内存与主内存同步延时”)
synchronized
先行发生原则
程序次序规则
管程锁定原则
volatile变量规则
线程启动规则
线程终止规则
线程中断规则
对象终结规则
传递性
Java与线程
线程的实现
内核线程(1:1实现)
直接由操作系统内核支持,内核完成线程切换,通过操纵调度器对线程进行调度
程序一般不实用内核线程,而是使用内核线程高级接口:轻量级进程,就是我们通常所说的线程
程序一般不实用内核线程,而是使用内核线程高级接口:轻量级进程,就是我们通常所说的线程
用户线程(1:N实现)
不需要系统内核支持,所有线程操作由用户自己处理
用户线程+轻量级进程(N:M实现)
既存在用户线程,也存在轻量级进程
用户线程完全建立在用户空间,创建、切换、析构等操作依然廉价
而轻量级进程作为用户线程和内核线程的桥梁,可使用内核提供的调度功能和处理器映射
并且用户线程的系统调用要通过轻量级进程完成,大大降低整个进程被完全阻塞的风险
用户线程完全建立在用户空间,创建、切换、析构等操作依然廉价
而轻量级进程作为用户线程和内核线程的桥梁,可使用内核提供的调度功能和处理器映射
并且用户线程的系统调用要通过轻量级进程完成,大大降低整个进程被完全阻塞的风险
Java线程的实现
JVM规范不限定使用哪种线程模型来实现
Java线程调度
协同式
线程执行时间由线程本身控制,工作完成通知系统切换另一线程
最大好处是实现简单
劣势是线程执行时间不可控
甚至一个线程阻塞一直不告诉系统进行线程切换,那程序就会一直阻塞在那里
甚至一个线程阻塞一直不告诉系统进行线程切换,那程序就会一直阻塞在那里
抢占式
每个线程由系统分配执行时间,线程切换不由线程本身来决定
线程执行时间系统可控,不会有一个线程导致整个进程甚至系统阻塞
Java使用的是抢占式调度
优先级不稳定,和系统实现强相关
线程状态转换
协程
Java线程:
实现主流选择:1:1内核线程模型
线程切换本质是用户态和内核态之间的转换,AB线程切换,需要把A的所有数据(内存、缓存、寄存器)保存好,当切回时,再恢复到之前状态
1:1内核线程模型天然缺陷是 切换、调度成本高昂,系统能容纳的线程数量也很有限
请求时间短、请求数量多,线程切换开销甚至可能接近于计算开销,资源严重浪费
实现主流选择:1:1内核线程模型
线程切换本质是用户态和内核态之间的转换,AB线程切换,需要把A的所有数据(内存、缓存、寄存器)保存好,当切回时,再恢复到之前状态
1:1内核线程模型天然缺陷是 切换、调度成本高昂,系统能容纳的线程数量也很有限
请求时间短、请求数量多,线程切换开销甚至可能接近于计算开销,资源严重浪费
协程:
主要优势是轻量
协程原理:
通过在内存里划出一片额外空间来模拟调用栈,其他“线程”中方法压栈、退栈遵守规则,不破坏这块空间即可
最初多数设计成“协同式调用”,因此称为“协程”;但是现在非协同、可自定义调度的协程实现很多
又由于协程会完整做调用栈的保护、恢复工作,也被称为“有栈协程”
主要优势是轻量
协程原理:
通过在内存里划出一片额外空间来模拟调用栈,其他“线程”中方法压栈、退栈遵守规则,不破坏这块空间即可
最初多数设计成“协同式调用”,因此称为“协程”;但是现在非协同、可自定义调度的协程实现很多
又由于协程会完整做调用栈的保护、恢复工作,也被称为“有栈协程”
线程安全和锁
线程安全
Java线程安全
不可变
final
相对线程安全
线程安全类
绝对线程安全
绝大多数类都不是绝对线程安全的
线程兼容
对象本身不是线程安全,需要使用同步手段保证对象在并发环境中可安全使用
线程对立
过时的suspend()/resume()
线程安全的实现方法
互斥同步
互斥是方法、同步是目的
synchronized
Lock
如果需要使用以下功能,可使用ReentrantLock
1. 等待可中断
2. 公平锁
3. 锁绑定多个条件
如果需要使用以下功能,可使用ReentrantLock
1. 等待可中断
2. 公平锁
3. 锁绑定多个条件
非阻塞同步
互斥同步是悲观并发策略,面临阻塞和唤醒的性能开销,属于阻塞同步
非阻塞同步是乐观并发策略,基于冲突检测和重试不再需要阻塞挂起线程
非阻塞同步是乐观并发策略,基于冲突检测和重试不再需要阻塞挂起线程
指令:
测试并设置(Test-and-Set)
获取并增加(Fetch-and-Increment)
交换(Swap)
比较并交换(Compare-and-Swap即CAS)
加载链接/条件存储(Load-Linked/Store-Conditional即LL/SC)
测试并设置(Test-and-Set)
获取并增加(Fetch-and-Increment)
交换(Swap)
比较并交换(Compare-and-Swap即CAS)
加载链接/条件存储(Load-Linked/Store-Conditional即LL/SC)
无同步方案
可重入代码(纯代码 Pure Code)
特点:不依赖全局变量,存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等
判断:如果一个方法的返回结果可预测,相同数据有相同结果,就满足可重入性要求
线程本地存储(Thread Local Storage)
锁优化
自旋锁与自适应锁
自旋不能代替阻塞,虽避免线程切换开销,但占用处理器时间
因此自旋时间有限,超时用传统方式挂起线程,自旋默认次数是10次
JDK6引入自适应自旋,自旋时间不固定,由前一次同一个锁上自旋时间和锁拥有者来决定
如果统一锁对象上,自旋刚获得过锁,并且持有锁的线程正在运行,JVM认为下次也成功,允许自旋时间延长
如果某个锁自旋很少成功,那以后会省掉自旋过程避免浪费处理器资源
如果统一锁对象上,自旋刚获得过锁,并且持有锁的线程正在运行,JVM认为下次也成功,允许自旋时间延长
如果某个锁自旋很少成功,那以后会省掉自旋过程避免浪费处理器资源
锁消除
即时编译器运行时,对不可能存在共享数据竞争的锁进行消除
锁消除判定依据来源于逃逸分析数据支持
锁粗化
虚拟机探测到连续操作对同一对象反复加锁解锁,会把锁同步范围扩大到整个序列外部,减少性能消耗
轻量级锁
JDK6加入的新型锁机制
“轻量级”相对于使用操作系统互斥量来实现传统所而言的,传统锁机制被称为“重量级”锁
代码即将进入同步块时,如果同步对象没被锁定(对象头 Mark Word 的锁标志位01)
虚拟机将在当前线程栈帧中建立名为所记录(Lock Record)空间,存储锁对象目前的Mark Word的拷贝
虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
如果更新成功,代表线程拥有了这个对象锁,并且对象的Mark Word锁标志位将变为“00”
如果失败,说明有竞争,虚拟机会检查Mark Word是否指向当前线程
如果是说明当前线程已经拥有这个对象锁,直接进入同步块
否则说明对象锁被其他线程抢占
如果两条以上线程抢占同一个锁,那轻量级锁不再有效,必须膨胀为中重量级锁,标志位“10”
解锁过程同样是通过CAS进行
如果Mark Word 仍然指向线程的锁记录,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来
假如替换成功,同步完成
替换失败说明其他线程尝试过获取锁,就要释放锁时,唤醒被挂起的线程
虚拟机将在当前线程栈帧中建立名为所记录(Lock Record)空间,存储锁对象目前的Mark Word的拷贝
虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
如果更新成功,代表线程拥有了这个对象锁,并且对象的Mark Word锁标志位将变为“00”
如果失败,说明有竞争,虚拟机会检查Mark Word是否指向当前线程
如果是说明当前线程已经拥有这个对象锁,直接进入同步块
否则说明对象锁被其他线程抢占
如果两条以上线程抢占同一个锁,那轻量级锁不再有效,必须膨胀为中重量级锁,标志位“10”
解锁过程同样是通过CAS进行
如果Mark Word 仍然指向线程的锁记录,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来
假如替换成功,同步完成
替换失败说明其他线程尝试过获取锁,就要释放锁时,唤醒被挂起的线程
轻量级锁同步依据:“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”
有竞争的情况下,除了互斥量本身开销,还有CAS操作,会比重量级锁更慢
有竞争的情况下,除了互斥量本身开销,还有CAS操作,会比重量级锁更慢
偏向锁
假设虚拟机启用了偏向锁
当锁对象第一次被线程获取时,会把对象头标志位设置为01,偏向模式设置为“1”进入偏向模式
同事CAS操作把获取到锁的线程ID记录在对象的Mark Word中,如果CAS成功,偏向锁线程以后每次进入这个锁相关的同步开,虚拟机都可以不进行任何同步操作
一但出现另一个线程尝试获取锁的情况,偏向模式马上宣告结束,根据所对象目前是否处于被锁定状态决定是否撤销偏向“0”,撤销后恢复为“01”或“00”
后续同步操作按照轻量级锁逻辑执行
当锁对象第一次被线程获取时,会把对象头标志位设置为01,偏向模式设置为“1”进入偏向模式
同事CAS操作把获取到锁的线程ID记录在对象的Mark Word中,如果CAS成功,偏向锁线程以后每次进入这个锁相关的同步开,虚拟机都可以不进行任何同步操作
一但出现另一个线程尝试获取锁的情况,偏向模式马上宣告结束,根据所对象目前是否处于被锁定状态决定是否撤销偏向“0”,撤销后恢复为“01”或“00”
后续同步操作按照轻量级锁逻辑执行
0 条评论
下一页