深入理解Java虚拟机读书笔记
JVM相关知识1.Java虚拟机准确式内存管理一款名为Exact VM的虚拟机,采用准确是内存管理,指虚拟机可以知道内存中某个位置的数据具体是什么类型。如内存中有一个32bit的整数123456,虚拟机有能力分辨出它是指向一个123456的内存地址的引用类型还是一个数值为123456的整数2.Java内存区域与内存溢出异常Java内存区域(运行时数据区域)和内存模型(JMM)...
JVM相关知识
1.Java虚拟机
准确式内存管理
一款名为Exact VM的虚拟机,采用准确是内存管理,指虚拟机可以知道内存中某个位置的数据具体是什么类型。
如内存中有一个32bit的整数123456,虚拟机有能力分辨出它是指向一个123456的内存地址的引用类型还是一个数值为123456的整数
2.Java内存区域与内存溢出异常
Java内存区域(运行时数据区域)和内存模型(JMM)
- 内存区域:JVM运行时将数据分区域存储,强调对内存空间的划分
- 内存模型:线程和主内存之间的抽象关系,JVM在计算机内存中的工作方式
Java运行时数据区域
-
程序计数器(Program Counter Register)
- 一块较小内存空间,可看作当前线程所执行的字节码行号指示器
- 多线程实际上是通过线程轮流切换并分配处理器执行时间的方式来实现,即任一确定时间,一个处理器内核都只会执行一条线程中的指令,只不过切换得很快,不易察觉
- 线程私有:切换后能恢复到正确执行位置,每个线程都要有一个独立额程序计数器
- 若正在执行Native方法,则计数器数值为空
-
Java虚拟机栈
-
线程私有
-
描述Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法对应一个栈帧
-
活动线程中,只有栈顶的帧才有效,称为当前栈帧
-
1、局部变量表(内存)
- 存放方法参数和局部变量,必须显式初始化
- 若是非静态方法,在index[0]位置上存储方法所属对象的实例引用(占4字节),随后存储参数和局部变量
- 字节码中STORE指令:将操作栈中计算完成的布局变量写回局部变量表
- 两种异常:
- StackOverflowError:线程请求栈深度大于虚拟机允许的深度
- OutOfMemoryError:若虚拟机可动态扩展,扩展时无法申请到足够内存
-
2、操作栈(寄存器)
- 初始状态为空的桶式结构栈;JVM执行引擎基于的栈
- 栈的深度:方法元信息的stack属性中
- i++和++i的区别:
- i++:从局部变量表取出i并压入操作栈;对局部变量表中i自增1;将操作栈栈顶值取出使用;使用栈顶值更新局部变量表
- ++i:先对局部变量表的i自增1;取出并压入操作栈;将操作栈栈顶值取出使用;使用栈顶值更新局部变量表
- **i++不是原子操作!**用volatile修饰也不安全,分3步完成,可能会被其他线程打断
-
3、动态链接
- 每个栈帧中包含一个在常量池中对当前方法的引用
-
4、方法返回地址
- 正常退出和异常退出
- 方法退出相当于弹出当前栈帧,三种方式:
- 返回值压入上层调用栈帧
- 异常处理信息抛给能处理的栈帧
- PC计数器指向方法调用后的下一条指令
-
-
本地方法栈
- 线程私有
- 与Java虚拟机栈类似,它为Native方法服务(HotSpot将其合二为一)
- 本地方法可通过JNI(Java Native Interface)访问虚拟机运行时数据区,调用寄存器
- 本地方法栈内存不足时,抛出NativeHeapOutOfMemory异常
-
Java堆
- Java堆一般来说是虚拟机管理最大的一块内存,线程共享,虚拟机启动时创建
- 唯一目的:存放对象实例
- 别名:GC堆
- 细分方法:
- 内存回收角度:新生代、老年代;Eden空间,From Survivor和To Survivor空间
- 内存分配角度:划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer),即TLAB
- 逻辑连续,物理可不连续;一般可扩展(通过参数-Xmx和-Xms控制)
-
方法区
- 线程共享,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 可以不实现垃圾收集,内存回收目标:常量池回收以及类型卸载
- 方法区无法满足内存分配需求,抛出OutOfMemoryError异常
- 为什么使用元空间取代永久代?
- 字符串存在永久代中,容易出现性能问题和内存溢出
- 类及方法信息难定大小,永久代大小指定不好控制
- 永久代为GC带来复杂度,降低回收效率
- 将HotSpot和JRockit合并
- 1、运行时常量池
- Class文件中除了类的版本、字段、方法、接口等描述信息,还有常量池
- 用于存放编译期生成的各种字面量和符号引用
- **动态性:**不同于Class文件常量池,运行期间可以将新常量放入池(String类的intern()方法)
- 2、直接内存
- JDK1.4引入NIO,一种基于Channel和Buffer的I/O方式,可以使用Native函数库直接分配堆外内存,通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了Java堆和Native堆中来回复制数据
Java内存模型
-
共享内存的并发模型,线程间通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信
-
计算机高速缓存和缓存一致性
- CPU(高速)——缓存——内存(低速),有可能有多个处理器(核),会导致缓存不一致,处理器访问回缓存时需要遵循一些协议
-
JVM主内存与工作内存
- 线程A先把本地内存A更新的共享变量刷新到主内存中
- 线程B到主内存中读线程A之前已更新过的共享变量
- 这样线程AB就完成了通信
volatile关键字
-
JVM提供的最轻量级的同步机制,当变量定义为volatile后,它将具备两种特性:
- 保证此变量对所有线程的可见性:普通变量在线程中传递时均需要通过主内存来完成
- 注意:虽然volatile保证可见行,但Java里的运算并非原子操作,并发下的volatile变量依然不安全;而synchronized关键字则是“一个变量在同一时刻只允许一条线程对其进行lock操作”
- 禁止指令重排序优化:普通变量无法保证变量赋值操作与程序代码中执行顺序一致,会出现读取的值其实已经被改变了,却还是读取原来的值
- 保证此变量对所有线程的可见性:普通变量在线程中传递时均需要通过主内存来完成
HotSpot虚拟机堆中的对象
-
对象的创建(new 指令)
- **1)类加载检查:**检查该指令参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过。若没有,先把该类加载进内存;
- **2)分配内存:**类加载检查通过后,虚拟机将为对象分配内存,此时可以确定存储该对象所需要的内存大小
- 如何在堆中为其分配内存?
- 指针碰撞(内存规整)
- 由一个指针分隔用过的和没用的内存,分局两侧
- 将指针向没用的内存方向移动所需要的长度即可
- 空闲列表(内存不规整)
- 维护一个列表,记录哪些内存块可用
- 从列表中选一个足够大的内存分配给对象,并更新列表
- 指针碰撞(内存规整)
- 3)内存初始化
- 4)设置对象头中的数据
- **5)Java程序开始创建对象:**此时虚拟机认为对象已经创建好了,但Java程序还没开始执行构造函数
-
如何处理多线程创建对象时,划分内存的指针同步问题?
- 对分配内存空间的动作进行同步处理(CAS)
- 把内存分配动作按照线程划分在不同的空间中进行
- 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)
- 哪个线程要分配内存就在哪个线程的TLAB上分配,TLAB用完需要分配新的TLAB时,才需要同步锁定
- 通过
-XX:+/-UseTLAB
参数设定是否使用 TLAB。
-
对象的内存布局
- 对象头:
- 第一部分:存储对象自身运行时的数据,HashCode、GC分代年龄。。。
- 第二部分:类型指针,指向它的类的元数据,虚拟机通过这个指针判断该对象是哪个类的实例(HotSpot采用的是直接指针)
- 数组对象对象头中由一块记录数据长度
- 实例数据:
- 默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),同宽分配在一起,长度由长到短(oops除外)
- 默认分配顺序下,父类字段分配在子类字段前面
- 对齐填充(HotSpot VM要求对象的起始地址必须是8字节的整数倍,所以不够要补齐)
- 对象头:
-
对象的访问
- Java需要通过虚拟机栈上的reference来操作堆上的具体对象,reference数据指向对象的引用,这个引用定位到具体对象主要有两种方式:
- 句柄访问
- 在Java堆中划分一块内存作为句柄池,每个句柄存放到对象实例数据和对象类型数据的指针
- 优点:对象移动时,仅需要更新句柄池中对象实例数据指针,reference较稳定
- 直接指针访问
- 在Java堆对象的实例数据中存放一个指向对象类型数据的指针,HotSpot在对象头中存放一个指向对象类型数据指针
- 优点:减少一次指针访问,速度块
OOM异常
-
Java堆溢出
- 出现标志:
java.lang.OutOfMemoryError: Java heap space
- 解决方法:
- 内存映像分析工具,分析Dump出来的堆转储快照,确定是内存泄漏还是内存溢出
- 内存泄漏:泄漏对象到GC Root引用链,定位泄漏位置
- 不存在内存泄漏,查看虚拟机大小是否可以增加,哪些对象生命周期过长
- 虚拟机参数:-XX:HeapDumpOnOutOfMemoryError
- 出现标志:
-
Java虚拟机栈和本地方法栈溢出
- 单线程下,栈帧过大、虚拟机容量过小导致StackOverFlowError
- 如果因为多线程导致的OutOfMemoryError,在不能减少线程数或更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多线程
- Java堆出现OOM要调大堆内存,栈出现OOM则要考虑调小栈容量
-
方法区和运行时常量池溢出
- 经常动态生成大量类的应用中(Spring框架,使用CGLib字节码技术),方法区溢出比较常见
-
直接内存溢出
- Heap Dump 文件中看不见明显异常,程序中直接或间接用了 NIO;
3.垃圾收集器与内存分配策略
主要发生在Java堆和方法区中
判断对象生死
-
引用计数算法
- 给对象添加一个引用计数器
- 每被引用一次,计数器加一
- 引用失效时,计数器减一
- 计数器为0时对象不再可用
- **缺点:**难以解决循环引用问题
-
可达性分析算法(主流)
-
从GC Root对象作为起点开始向下搜索,走过的路径称为引用链
-
从GC Root开始,不可达的对象被判为不可用
-
可作为GC Root的对象:
-
栈中:
- 虚拟机栈中,栈帧中的本地变量表引用的对象
- 本地方法栈中,JNI引用的对象
-
方法区中:
- 类的静态属性引用的对象
- 常量引用的对象
-
可总结为:
1,所有老年代对象
2,所有全局对象
3,所有jni句柄
4,所有上锁对象
5,jvmti持有的对象
6,代码段code_cache
7,所有classloader,及其加载的class
8,所有字典
9,flat_profiler和management
10,最重要的,所有运行中线程栈上的引用类型变量作者:cao
链接:https://www.zhihu.com/question/33093157/answer/89516243
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
-
-
对象在判为不可达后,仍有自救方法
-
-
四种引用类型
- 强引用:Object obj = new Object();强引用还在,GC永远不会回收被引用独享
- 软引用:引用存在但不是必须的对象,在OOM前,虚拟机将这些对象列入回收范围进行第二次回收,若内存依然不够,再抛出OOM
- 弱引用:只能生存到下一次GC前,一旦发生垃圾收集,就会被清理
- 虚引用:不影响对象,无法获取对象实例,唯一用途:回收时让系统知道
-
宣告对象死亡的两次标记过程
- 发现对象不可达,第一次标记,判断是否执行finalize()
- 不需要执行:对象没有覆盖finalize方法或finalize方法已经执行过(finalize方法只被执行一次)
- 需要执行:将该对象放入 F-Queue,稍后由虚拟机自动创建一个低优先级线程执行
- finalize()无法像C,C++析构函数那样,虚拟机不等待它执行结束
- 发现对象不可达,第一次标记,判断是否执行finalize()
-
方法区的回收
- 主要回收 **废弃常量 **和 无用的类
- 废弃常量:字符串“abc”,没有引用指向它,它就是废弃常量
- 无用的类:
- 该类所有实例以已被回收
- 加载该类的Classloader已被回收
- 该类的Class对象没有被任何地方引用,反射无法访问该类的方法
- 主要回收 **废弃常量 **和 无用的类
垃圾收集算法
-
标记-清除算法
- 标记所有需要回收的对象
- 标记完毕,统一回收所有被标记对象
- 缺点:
- 标记和清理过程效率低下(很多需要被清理)
- 空间碎片问题:导致下一次找不到大块内存,提前触发GC
-
标记-复制算法
- 将可用内存分为两块,每次只用一块
- 一块内存用完时,将这块内存中还存活的对象复制到另一块内存上,清除这块内存
- 缺点:
- 内存缩小为一半,浪费空间,仅适合新生代
- 节省内存的方法:
- 分为1块较大的Eden区,2块较小的Survivor区
- 每次使用1块Eden区和1块Survivor区
- 回收时,将上面两部分存活的对象复制到另一块Survivor区中,然后清除
- 原因:假说1 新生代中对象98%都是朝生夕死的
-
标记-整理算法
- 标记方法同 标记-清除算法
- 标记完后,将所有存货对象向一端移动,然后直接清理边界外的内存
- 缺点:
- 存在效率问题(移动耗费较大),只适合老年代
-
进化:分代收集算法
- 新生代:GC过后只存活少量对象 复制算法
- 老年代:GC过后对象存活率搞 标记-整理算法
HotSpot中GC算法的实现
GC算法的步骤:找到死掉的对象;清理
-
枚举GC Root(可达性分析的第一步)
- 因:每次枚举如果都要遍历栈,时间成本太大
- 果:使用OopMap数据结构,记录栈上本地变量到堆上对象的引用关系
- 因:Oop需要更新,但执行一条指令就改一次,成本太高
- 果:采用在 安全点更新的方法,安全点需要将程序运行时间划分合理,一般在
- 方法调用、循环跳转、异常跳转这些长时间执行的指令复用行为中设置安全点
- 因:为保证枚举根节点的准确性
- 果:需要Stop the world(GC停顿),冻结整个应用
-
如何让所有线程跑到最近的安全点再停顿下来以便进行GC?
- 抢先式中断:
- 先中断所有线程
- 发现有线程没中断在安全点,恢复他让它跑到安全点
- 主动式中断(主要使用):
- 设置一个中断标记
- 每个线程到达安全点时,检查此标记,选择是否中断自己
- 抢先式中断:
-
安全区域
- 指在一段代码片段中,引用关系不会发生变化,在这个区域中任意位置开始GC都是安全的
- 因:一个Sleep或Blocked状态的线程无法自己到达安全点并中断自己,GC不能一直等待它
- 果:线程执行到安全区域时,检查系统是否在GC,若不在GC,则继续执行;若在GC,等GC结束再继续执行
-
记忆集
- 解决跨代引用,使用记忆集可以缩减GC Root扫描范围
-
7个垃圾收集器
-
Serial 搭配 Serial Old收集器:新生代-复制算法;老年代-标记整理算法
-
ParNew 搭配 Serial Old收集器:新生代-复制算法;老年代-标记整理算法(新生代收集改为多线程)
-
Parallel搭配Parallel Scavenge收集器:关注达到一个可控的吞吐量,都是多线程处理
-
CMS收集器(Concurrent Mark Sweep):
- 并发低停顿
- 1初始标记——2并发标记——3重新标记——4并发清理——5重置线程
- 1标记GC Root直接关联的对象,为下一步并发标记做准备
- 2进行GC Root 可达性分析
- 3修正并发标记期间因用户线程继续运作导致产生的变动部分的标记记录
- 4清除阶段
- 缺点:
- 1.对CPU资源敏感:并发执行会占用一部分CPU资源
- 2.无法处理浮动垃圾:在CMS并发标记、清理阶段,用户线程产生的垃圾(并发要求为用户预留空间,有时候预留的空间会不足)
- 标记-清除产生大量空间碎片
-
G1收集器
- 可面向堆内存任何部分组成回收集(Collection Set),而非整个新生/老年代,称为Mixed GC模式
- 维护一个优先级列表,每次优先处理回收价值最大的Region
- 可预测停顿模型,可设用户期望时间
- Region:
- 将Java堆划分为多个大小相等的独立区域
- 根据需要扮演不同代的内存空间
- 每次回收空间大小都是Region的整数
- 维护一个优先级列表,每次优先处理回收价值最大的Region
- 细节问题:
- 跨Region引用对象问题:维护记忆集,但存在双向卡表,很耗费内存
- 保证用户线程与收集线程互不干扰:原始快照(SATB),设立两个指针TAMS,并把Region中一部分空间划分出来用于并发回收过程中新对象分配
- 可靠的停顿预测模型:使用最新的状态数据
- 1初始标记——2并发标记——3最终标记——4筛选回收
- 1 标记GC Root直接关联的对象,修改TAMS指针
- 2 并发地进行可达性分析
- 3 短暂暂停用户线程,处理遗留SATB记录
- 4 对Region进行回收价值成本排序,标记-复制算法
- 123 都是Stop The World ,4不和用户线程并发
- 全功能收集器
- 相较于CMS,可指定最大停顿时间、分Region内存布局、按收益动态确定回收集、标记-复制算法也不会造成过多碎片;但垃圾收集内存占用高,额外执行负载也高,
-
-
GC日志分析
如何选择垃圾收集器?
-
应用程序关注点:
- 数据分析、科学计算,尽快出结果的,关注吞吐量
- SLA应用,停顿时间影响服务质量,关注延迟
- 客户端应用,嵌入式应用,关注内存占用
-
运行应用的基础设施:
- 系统架构x86?32-64?ARM?处理器数量,内存,操作系统等
-
JDK的发行商,版本号。。。
Java内存分配策略
-
对象优先在Eden分配
- Eden空间不足直接触发Minor GC
-
大对象直接进入老年代
- 需要大量连续内存空间的 Java 对象。例如那种很长的字符串或者数组。
-XX:PretenureSizeThreshold
:单位是字节,规定大对象大小- 只对 Serial 和 ParNew 两款收集器有效
- 防止新生代复制大对象消耗资源
-
长期存活对象进入老年代
- 固定对象年龄判定: 虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,达到
-XX:MaxTenuringThreshold
设定值后,会被晋升到老年代,-XX:MaxTenuringThreshold
默认为 15; - 动态对象年龄判定: Survivor 中有相同年龄的对象的空间总和大于 Survivor 空间的一半,那么,年龄大于或等于该年龄的对象直接晋升到老年代。
- 固定对象年龄判定: 虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,达到
-
空间分配担保
- 如果Eden中所有新生代对象都要存活,但剩下的一个Survivor空间明显不够,就要老年代空间来进行担保
- 如果老年代空间不够,尝试进行Full GC
6.类文件结构
Class类文件的结构
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,我们之后也主要对这两种类型的数据类型进行解析。
- 无符号数: 无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,可以用它来描述数字、索引引用、数量值或 utf-8 编码的字符串值。
- 表: 表是由多个无符号数或其他表为数据项构成的复合数据类型,名称上都以
_info
结尾。
Class文件格式
-
魔数与Class文件的版本(头8个字节)
- 头4个字节:魔数,确定此文件是否为一个能被虚拟机接收的Class文件(0xCAFEBABE)
- 后4个字节:版本号 56 次版本号Minor Version 78 主版本号Major Version
-
常量池
- 第九个字节开始
- 资源仓库,与其他项目关联最多,占用空间最大,第一个出现的表类型数据项目
- 第9 10字节
- constant_pool_count 常量池容量计数值
- 从1开始计数,如果不引用任何一个常量项目,则指向0
- 字面量
- 相当于Java中的常量(文本字符串,final常量值)
- 符号引用
- 被模块导出或开放的包
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
- 每一项常量都是一个表
- 常量池中有17种数据类型
-
访问标志
- 常量池结束后紧接着两个字节代表访问标志
- 是类还是接口;public?abstract?等
-
类索引、父类索引与接口索引集合
- 确定该类的继承关系
-
字段表集合
- 描述接口或类中声明的变量,不包含局部变量
- 修饰符(都是布尔型,要么有要么无,适合用标志位来表示):
- 作用域(public、private、protected)
- static修饰符 实例变量还是类变量
- final 可变性
- volatile 并发可见性
- transient 可否被序列化
- 字段名称,字段数据类型(不是布尔值,无法固定,引用常量池种常量表示)
- access_flags,name_index, descriptor_index, attributes_count, attributes
-
方法表集合
- access_flags,name_index, descriptor_index, attributes_count, attributes
-
属性表集合
- 。。。
7.虚拟机类加载机制
- Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制
类加载的时机
类的生命周期
- 加载——验证——准备——解析——初始化——使用——卸载
- 其中 验证,准备和解析三部分统称为连接
- 加载、验证、准备、初始化、卸载五个阶段顺序确定
- 解析有时候可以在初始化之后进行,为了支持动态绑定
必须初始化的时刻
- 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时
- 使用java.lang.reflect包方法对类型进行反射调用的时候
- 初始化类的时候,父类还没有初始化
- 虚拟机启动时,用户需要指定一个要执行的主类
- 使用JDK7中的动态语言支持
- 接口中定义了JDK8新加入的默认方法时
类的加载过程
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
此阶段第一步不严格要求从class文件中获取,还可以是压缩包,网络数据等,只要是二进制字节流就可以,灵活性很高
加载阶段结束后,外部二进制字节流就按照虚拟机设定的格式存储在方法区之中
类型数据存在方法区中后,Java堆内存中实例化一个java.lang.Class类对象,作为访问方法区中类型数据的对外接口
验证
- 文件格式验证
- 保证输入字节流正确的解析,并存储于方法区之内
- 基于二进制字节流进行的
- 元数据验证
- 对字节码描述的信息进行语义分析,保证描述信息符合《Java语言规范》
- 如是否继承不允许继承的类,是否实现了接口的所有方法,是否与父类冲突等
- 字节码验证
- 对方法体进行验证,分析数据流和控制流
- 复杂度高,将尽可能多的校验辅助挪到Javac编译器里进行,在属性表中引入“StackMapTable”,将类型推导转变为类型检查
- 符号引用验证
- 将符号引用转化为直接引用时,进行校验
- 看该类是否缺少或被禁止访问它依赖的某些外部类、方法、字段等
很重要,但不是必不可少,如果代码都被反复使用验证过,可以关闭部分验证措施
准备
正式为类中定义的变量(静态变量)分配内存并设置类变量初始值的阶段
- 此时进行内存分配的只有类变量,不包括实例变量
- 初始值是数据的零值
public static int value = 123;
此时value初始值为 0 ,而不是123,到初始化阶段才会赋值
解析
- 将符号引用转化为直接引用的阶段
- 符号引用:任何形式字面量,能无歧义定义到目标即可,与内存布局无关
- 直接引用:直接指向目标指针、偏移量或间接定位到目标的句柄,与内存布局相关
- D类中有个从未解析过的符号引用N,解析为一个类或接口C的直接引用
- 类或接口的解析
- 1)C不是数组类型,代表N的全限定名传递给D的类加载器区加载类C
- 2)C是数组类型,元素类型为对象,按第一点加载数组元素类型,虚拟机生成一个代表该数组维度和元素的数组对象
- 3)上两步成功,C在虚拟机中已经成为一个有效的类或借口了,再进行符号引用验证,确认D是否对C有访问权限
- 字段解析
- C本身包含简单名称和字段描述符都与目标相匹配的字段,返回直接引用
- 实现接口找接口,实现父类找父类
- 方法解析
- 与字段解析差不多,先判断C是不是接口,如果是接口,直接抛出异常
- 然后按照字段解析的步骤走
- 接口方法解析
- 与方法解析差不多,但如果C不是接口,抛出异常
初始化
执行类构造器()方法的过程
- ()方法:
- 编译器自动收集类中所有类变量的赋值动作和静态语句块中语句合并产生的
- 与类构造函数(虚拟机中称为实例构造器()方法)不同,不需要显式调用父类构造器,Java虚拟机保证在子类()方法执行前,父类()方法已执行完毕
- 父类中的静态语句块优先于子类的变量赋值操作,因为父类()方法先执行
- 对于类或接口不是必须的,如果类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法
- 必须保证一个类的()方法在多线程环境中被正确的加锁同步
类加载器
将”通过一个类的全限定名获取描述该类的二进制流“这个动作放到Java虚拟机外部实现,便于让应用程序决定如何获取所需的类
类与类加载器
任意一个类,由加载它的类加载器和它本身一起共同确立其在Java虚拟机中的唯一性,只有加载类的类加载器是同一个,才认定这两个类相等
三层类加载器
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器
双亲委派模型
Java虚拟机角度来看,只存在两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader),是虚拟机的一部分
- 其他所有的类加载器,都由Java实现,独立存在于虚拟机外部,都继承自抽象类java.lang.ClassLoader
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成
- 故 所有类加载请求,都会被传到启动类加载器
- 只有当父类加载器反馈自己无法完成加载,子加载器才自己去完成加载
实现:
- 检查该类是否已被加载
- 将类加载请求委派给父类
- 如果父类加载器为null,默认使用启动类加载器
parent.loadClass(name,false)
- 当父类加载器加载失败时
- catch ClassNotFoundException 但不做任何处理
- 调用自己的findClass()去加载
- 优点:
- Javal类随着它的类加载器一起具备了带有优先层级的关系
- 保护核心API,避免重复加载类造成覆盖
破坏双亲委派模型
线程上下文类加载器:
父类加载器请求子类加载器去完成类加载
OSGi…
Java模块化系统
JDK9中引入
**模块化关键目标:**可配置的封装隔离机制
Java模块内容:
- 代码
- 依赖其他模块的列表
- 导出的包列表,其他模块可以使用的列表
- 开放的包列表,其他模块可反射访问模块的列表
- 使用的服务列表
- 提供服务的实现列表
解决的问题:
- 基于类路径来查找依赖的可靠性问题,提前告知启动失败
- 类路径上跨jar文件的public类型可访问的问题(虽然是public,但也要声明可以被哪些模块访问,更加精细化的控制)
模块的兼容性
只要存放在类路径上的jar文件,无论是否包含模块化信息,都会被当作传统jar包处理
只要存放在模块路径上的jar文件,即使没使用JMOD后缀,或不包含模块化信息,仍然会被当作一个模块对待
- jar文件在类路径的访问规则:被当作自动打包在一个匿名模块,没有任何隔离
- 模块在模块路径上的访问规则:具名模块只能访问到它依赖定义中列明依赖的模块和包,看不到匿名模块
- jar文件在模块路径的访问规则:自动模块,默认依赖整个模块路径中的所有模块
模块化下的类加载器
- 平台类加载器取代扩展类加载器
12.Java内存模型与线程
Amdahl定律通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算能力**
硬件的效率与一致性
缓存一致性问题
多个处理器每个处理器都有自己的高速缓存,而它们又共享同一主内存,这叫共享内存多核系统。
多个缓存都涉及到同一内存,但多个缓存的数据可能不一致,以谁的为准?
- **内存模型:**在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象,即读写数据需要按照规矩来
- **乱序执行:**处理器将输入代码乱序执行,计算之后重组,无法保证按代码先后顺序执行
Java内存模型
实现让Java在不同平台下达到一致的内存访问效果,屏蔽硬件和操作系统的内存访问差异
目的:定义程序中各种变量的访问规则
主内存与工作内存
关注虚拟机中把变量值存储到内存;从内存中取出变量值
-
变量(不同于Java编程中的变量):
- 实例字段
- 静态字段
- 构成数组对象的元素
- 不包括局部变量和方法参数
-
规定所有变量都存储在主内存,类比主内存
-
每条线程有自己的工作内存,类比高速缓存
-
线程的工作内存中保存了被该线程使用的变量的主内存副本
-
线程对变量的所有操作都必须在工作内存中,不能直接读写主内存中的数据
-
不同线程也无法访问对方工作内存中的变量
内存间的交互操作
下面8个操作都是原子的,不可再分的
- lock:作用于主内存的变量,把一个变量标识为一条线程独占的状态
- unlock:作用于主内存的变量,将一个锁定的变量释放出来,释放后才能被其他线程锁定
- read:作用于主内存的变量,把一个变量的值从主内存传输到线程工作内存,为load做准备
- load:作用于工作内存的变量,把read操作取得的变量值放入内存副本中
- use:作用于工作内存的变量,把工作内存中变量值传递给执行引擎,使用变量值的字节码会告诉虚拟机执行这个操作
- assign(赋值):作用于工作内存的变量,把从执行引擎接受的值赋给工作内存中的变量,赋值的字节码会告诉虚拟机执行这个操作
- store:作用于工作内存的变量,把工作内存中的变量值传递到主内存中,为write做准备
- write:作用于主内存的变量,把store操作从工作内存中取得的值放入主内存变量中
注意:
- 不允许read和load,store和write操作之一单独出现
- 不允许线程丢弃assign操作,即工作内存改变后必须把变化同步回主内存
- 不允许工作内存无原因地把变量同步到主内存
- 新变量只能在主内存中诞生
- 一个变量同一时刻只允许一条线程对其lock,lock几次就要unlock几次进行解锁
- lock操作会清空工作内存中此变量的值,执行引擎要使用这个变量前,需要重新执行load或assign
- 不允许unlock不是自己lock的变量
- 对一个变量unlock前,必须先同步回主内存,即执行store,write
对volatile型变量的特殊规则
-
适用场景:
- 运算结果并不依赖变量的当前值,或能够确保只有单一线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
原因:Java运算操作符并不是原子操作
-
两项特性
- 保证此变量对所有线程的可见性,不需要写回主内存,其他线程再读才能得知变更
- 禁止指令重排序优化
针对long和double型变量的特殊规则
-
long和double的非原子性协定:
允许没有被volatile修饰的64位数据读写操作划分为两次32位的操作来进行
原子性、可见性和有序性
原子性
- 基本数据类型的访问读写都具备原子性
- synchronized块间操作具备原子性
可见性
- 实现方法:
- 变量修改后将新值同步回主内存,变量读取前从主内存刷新变量值
- synchronized块:
- 对一个变量unlock前,必须先把此变量同步回主内存中(store、write)
- final:
- 被final修饰的字段在构造器中被初始化完成,且构造器没有把this引用传递出去,那么其他线程就能看见final字段的值
有序性
- 线程内穿行语义,保证在线程内观察所有操作都是有序的
- 指令重排序和工作内存与主内存同步延迟,造成观察另一个线程是完全无序的
- synchronized保证一个变量同一时刻只允许一条线程对其进行lock操作
先行发生原则
两项操作之间的偏序关系
A先行发生于B,那么B操作之前,A操作产生的影响能被B观察到
天然的先行发生关系
- 程序次序规则:线程内,按照控制流顺序,写在前面的操作先行发生于写在后面的操作。是控制流顺序而不是程序代码顺序
- 管程锁定规则:一个lock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对volatile变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个动作
- 线程终止规则:线程中所有操作先行发生于对此线程的终止检测。
Thread:join();Thread:isAlive()
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 对象终结规则:对象初始化完成先行发生于它的finalize()方法开始
- 传递性:A先行发生于B,B先行发生于C,那么A先行发生于C
Java与线程
线程的实现
-
线程是比进程更加轻量级的调度执行单位
-
把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源,又可以独立调度
-
一般都是native方法,在本地方法栈中,无法保证平台无关
三种方式
- 内核线程实现 1:1实现
- 用户线程实现 1:N实现
- 用户线程加轻量级线程混合实现 N:M实现
内核线程实现
- 基于内核线程实现,使用内核线程高级接口,轻量级进程(Light Weight Process)
- 缺点:
- 系统调用代价高,需要在用户态和内核态来回切换
- 轻量级进程需要内核线程支持,要消耗内核资源
用户线程实现
- 广义上来讲,一个线程只要不是内核线程,就是用户线程
- 狭义上来讲,用户线程库中线程完全在用户态中,不需要内核帮助
- 优点:
- 无需内核支持,开销小
- 缺点:
- 没有内核支持,所有线程操作都要由用户程序自己处理,很复杂且困难
混合实现
整合了上面两个实现的优点,互补了缺点
Java线程的实现
取决于具体虚拟机
Java线程调度
系统为线程分配处理器使用权的过程
- 调度方式:
- 协同式
- 执行时间自身控制,执行完之后主动通知系统切换到另一个线程上去
- 好处:实现简单,没有线程同步问题
- 坏处:线程执行时间不可控,造成阻塞
- 抢占式
- 由系统分配时间,不由自身控制(Java中Thread::yield()主动让出执行时间)
- 使用优先级可以指定一些策略
- 缺点:
- 优先级各种操作系统不一样,有的优先级可能很少,导致多个优先级被强行变成一种
- 系统可能自行改变优先级
- 协同式
状态转换
6种线程状态
- 新建(New):创建后尚未启动的线程
- 运行(Runnable):包括操作系统线程状态中的Running和Ready,有可能正在执行,有可能等待操作系统给他分配执行时间
- 无限期等待(Waiting):不会被分配处理器执行时间,需要等待被其他线程显式唤醒,陷入此状态的方法:
- 没设置Timeout参数的
Object::wait()
方法 - 没设置Timeout参数的
Thread::join()
方法 LockSupport::park()
方法
- 没设置Timeout参数的
- 限期等待(Timed Waiting):不会被分配处理器执行时间,但无需等待被其他线程显式唤醒,一定时间后会由系统自动唤醒,陷入此状态的方法:
Thread::sleep()
方法- 设置了Timeout参数的
Object::wait()
方法 - 设置了Timeout参数的
Thread::join()
方法 LockSupport::parkNanos()
方法LockSupport::parkUtil()
方法
- 阻塞(Blocked):阻塞状态等待着获取到一个拍他锁;等待状态等待一段时间,或唤醒动作
- 结束(Terminated):线程结束执行
Java与协程
内核线程的局限
1:1内核线程切换调度成本高,系统能容纳的线程数量有线
单体应用允许请求花费时间,切换线程成本无伤大雅
但现在微服务架构下执行时间短、数量多,线程切换开销可能接近于计算开销,浪费严重
协程的复苏
为什么内核线程调度切换起来成本高?
- 主要来自于用户态和内核态间的转换
- 响应中断
- 保护和恢复执行现场
协程更加轻量级
需要在应用层实现的东西更多(调用栈,调度器等)
13.线程安全与锁优化
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,就称这个对象是线程安全的
Java语言中的线程安全
线程安全不是非真即假的,它指的是安全程度
五类安全程度
- 不可变
- 基本数据类型,用final修饰
- 对象要自行保证行为不会对状态产生影响
- 绝对线程安全
- 代价非常高
- 相对线程安全
- 通常意义上讲的线程安全
- 单次操作是线程安全的,调用时不需要进行额外保障措施
- 特定顺序连续调用,可能要在调用端使用额外同步手段
- 线程兼容
- 对象本身不是线程安全的,在调用端正确使用同步手段来保证对象在并发环境中可以安全使用
- 线程对立
- 是否在调用端采取同步措施,都无法并发使用代码
线程安全的实现方法
互斥同步
-
同步:多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或一些,当使用信号量的时候)线程使用
-
互斥:实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式
-
使用synchronized的注意点:
- 被synchronized修饰的同步块对同一条线程来说是可重入的,同一线程反复进入同步块,也不会死锁
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入
- 为什么synchronized是重量级操作?
- Java线程需要映射到操作系统内核线程之上,需要操作系统来完成,就要切换用户态和内核态,这种切换耗费处理器时间
-
重入锁(ReentrantLock)增加了一些功能如下
- 等待可中断:当持有锁的线程长期不释放锁,正在等待的线程可以放弃等待,处理其他事情
- 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁。会导致性能急剧下降
- 锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象
-
为什么依然选用synchronized而不是重入锁?
- synchronized 在Java语法层面同步,足够清晰简单
- Java虚拟机保证及时synchronized出现异常,锁也能自动释放
- Java虚拟机优化synchronized更方便
非阻塞同步
-
互斥同步,也叫阻塞同步,因为
进行线程阻塞和唤醒会带来性能开销
悲观的,默认认为不加锁就会出现问题,导致
- 用户态到内核态的转换
- 维护锁计数器
- 检查是否有被阻塞的线程需要被唤醒
等开销
-
非阻塞同步:基于冲突检测的乐观并发策略,先进行操作,如果共享数据被争用,再进行补偿措施
-
需要硬件支撑
- 操作具备原子性
- 冲突检测具备原子性
-
硬件保证语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,如
-
测试并设置(Test-and-Set)
-
获取并增加(Fetch-and-Increment)
-
交换(Swap)
-
比较并交换(Compare-and-Swap)CAS
-
加载链接/条件储存(Load-Linked/Store-Conditional)
Java中CAS的过程:
内存位置(Java中简单理解为变量内存地址,用V表示)
旧的预期值A,准备设置的新值B
当且仅当V符合A时,处理器才会用B更新V,否则不执行更新,最终无论是否更新都返回V的地址
-
-
CAS 的ABA问题:
- 一个变量V初次读取是A,但被改为B,最后在准备赋值前又被改为了A,这时候检测到仍为A值
无同步方案
同步和线程安全没有必然联系,同步只是保障在共享数据争用时的正确手段,如果方法不涉及共享数据,就不需要任何同步措施保证其正确性
- 可重入代码(纯代码)
- 可以在代码执行任何时刻中断它,转而去执行另外一段代码(包括递归调用本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响
- 不依赖局部变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入等
- 线程本地存储(Thread Local Storage)
- 如果一段代码所需要的数据必须与其他代码共享,就看看这些共享数据的代码能否保证在同一个线程中执行。如果可以保证,就把共享数据的可见范围限制在同一个线程之内,无需同步就可以保证线程间不出现数据争用问题
锁优化
自旋锁与自适应自旋
-
自旋锁
- 通常共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程不值得
- 让后面请求锁的线程稍等,但不放弃处理器的执行时间,看看持有锁的线程能否狠快释放,为了让该线程等待,只须让线程执行一个忙循环(自旋)
- 但必须是多处理器多核心
- 不能代替阻塞,无法预测这个等待的时间有多久
-
自适应自旋
- 自旋刚刚成功获得锁,且持有锁的线程正在运行,认为很有可能自旋再次成功,允许自旋等待时间更长
- 经常自旋失败,减少等待时间
-
锁消除
- 虚拟机即时编译器运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除
- 判断依据来自逃逸分析的数据支持
- 判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的
-
锁粗化(膨胀)
- 原则本来是尽量将同步块的作用范围限制的小
- 但如果一系列连续操作都对同一个对象反复加锁解锁,甚至在循环体内加锁解锁,那么频繁的进行互斥同步操作也会导致不必要的性能损耗
- 把加锁范围适当扩展粗化,让加锁解锁操作次数尽量少
-
轻量级锁
设计初衷:没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗
- HotSpot虚拟机对象头两部分
- 存储对象自身的运行时数据,如哈希码,GC分代年龄等(Mark Word)
- 指向方法区对象类型数据的指针,数组对象还有额外部分存储数组长度
- 轻量级锁工作过程:
- 代码即将进入同步块时,如果此同步对象没有被锁定(锁标志为01),虚拟机先在当前线程栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝
- 虚拟机使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针;
- 更新成功,代表该线程拥有了这个对象的锁,且对象的Mark Word标志位转变为 00 ,表示此对象属于轻量级锁定状态
- 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁
- 虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,直接进入同步块继续执行即可,否则就说明这个锁对象已经被其他线程抢占了
- 两条以上的线程争用同一个锁,轻量级锁必须膨胀为重量级锁(互斥量)
- 提升性能的依据
- 绝大部分锁,整个同步周期内都是不存在竞争的
- 如果竞争过多,还不如重量级锁
- HotSpot虚拟机对象头两部分
-
偏向锁
- 消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能
- 偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗
- 偏向锁假定只有将来第一个申请锁的线程会使用锁,后来的都不使用
- 因此,只需要在Mark Word中用CAS算法记录owner,若记录成功,则偏向锁获取成功,记录锁状态为偏向锁,只要当前线程为owner就可以零成本获取锁
- 否则,就说明有其他线程进行竞争,膨胀为轻量级锁
文章中有些图片转自TangBean的阅读笔记,特别感谢,从他的笔记中学到很多东西,附上他的笔记链接:https://github.com/TangBean/understanding-the-jvm
更多推荐
所有评论(0)