深入理解Java虚拟机总结
2024-09-03 18:15:09 30 举报
AI智能生成
深入理解Java虚拟机知识总结,面向面试。 2024年9月3日18:15:03:调整思维导图的样式风格。
作者其他创作
大纲/内容
Java、JVM的前世今生就不细说了,这里主要提前提及几个概念
虚拟机可以知道内存中某个位置的数据具体是什么类型,相较于句柄的对象查找方式,准确式内存管理可以减少对象定位的次数,以此减少开销,显著提升执行性能
准确式内存管理
通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用或者方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译行为(后边会提到的栈上分配)。
热点代码探测技术
PS:jdk8默认的hotspot虚拟机具有上边的两个特性
自JDK 17发布以来,这个词的遇见频率应该挺高的,“Run Programs Faster Anywhere”是它的口号
Graal VM
走进Java
线程私有
程序控制流的指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等都依赖这个计数器完成
tip:计数器记录的是正在执行的字节码指令的地址,如果正在执行的是本地方法,则计数器值为空
tip:该区域内存空间较小,规范中唯一一个没有规定任何OOM的情况
程序计数器
栈帧:用于存储局部变量表、操作数栈、动态连接、方法出口等信息
描述的是方法执行的线程内存模型,方法对应栈帧,方法的调用和执行完毕对应栈帧的入栈出栈
tip:局部变量表:存放虚拟机基本数据类型、对象引用、返回地址类型(指向一条字节码指令的地址)
tip:局部变量表所需的内存空间在编译期间完成分配,空间大小是确定的,运行期间不会改变
tip:规定异常SOE、OOM
虚拟机栈
类似虚拟机栈,区别于本地方法栈调用的是本地方法
本地方法栈
线程共享
存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
运行时常量池是方法区的一部分
Class文件中的常量池表在类加载后存放到方法区的运行时常量池中
运行时常量池
tip:规定异常OOM
自打JKD7起到JDK8完全移除永久代后,方法区中的字符串常量池和静态变量的存储位置放到了堆中,方法区的其他东西,主要是类信息使用本地内存,即元空间存储
这里漏掉了一个很关键的东西,得补上。
PS:2024年4月1日01:54:50
方法区
不过由于栈上分配等优化技术的出现,这句话也并不是那么绝对了
此内存区域的唯一目的是存放对象实例
PS:这里有我们最常用的调优参数,-Xmx和-Xms,在实际中,这俩一般是设置一样的值,总的来说目的就是防止出现内存抖动影响性能
堆
不属于虚拟机运行时数据区的一部分,也不是规范中定义的内存区域。但是有使用意义
本地内存
运行时数据区
执行引擎
本地库接口
本地方法库
Java内存区域
1、通常都是一个new指令(clone、反序列化除外)
2、在常量池中定位类的符号引用,并检测类是否已被加载、解析、初始化过
3、如果没有,则先执行相应的类加载过程
方式一:指针碰撞
方式二:空闲列表
ps:选择那种方式取决于Java堆是否规整,而是否规整取决于用了什么垃圾收集器
4、接着为对象分配内存
方式一:CAS+失败重试保证操作原子性
方式二:本地线程分配缓存(线程私有)
5、解决并发问题
6、初始化零值,保证对象的实例字段在Java代码中可以不赋初值就可以直接使用
7对象信息设置:对象头中的信息等
8、执行<init>()方法,就是执行构造函数
对象的创建
01 未锁定
00 轻量级锁定
10 重量级锁定
11 GC标志
01 可偏向
拓展:标志位
存储对象自身的运行时数据:Hshcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等
MarkWork
确定该对象是哪个类的实例
类型指针
tip:如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据
对象头
对象真正存储的有效信息
实例数据
不必然存在,也没特别含义,起占位作用(对象大小必须是8字节的整数倍)
对齐填充
对象的内存布局
句柄访问
直接指针访问
tip:HotSpot主要使用直接指针进行对象访问,开头走进Java那里提到的准确内存管理就是在这里实践
对象的访问定位
HotSpot虚拟机对象分配布局、访问全过程
这块不细讲,提一下几点
①了解一下内存泄漏和内存溢出的区别
②JDK8以后,永久代完全退出了历史舞台,元空间作为替代者登场
OOM实战
①哪些内存需要回收
②什么时候回收
③如何回收
谈垃圾回收之前再提一嘴,思考几个问题
1、概述:主要针对线程共享的堆和方法区
简单粗暴,但是有循环引用的问题
引用计数法
主流算法
基本思路:通过一系列称为GCRoots的根对象作为初始节点集,从这些节点根据引用向下搜索,搜索过程走过的路径称为“引用链”,如果某个对象到GCRoots间没有任何引用链相连,则证明此对象不可能再被使用
虚拟机栈栈帧中局部变量表中引用的对象
本地方法栈本地方法引用的对象
栈中引用的对象
被堆中其他对象所引用(这是完全有可能的)
类静态属性引用的对象
常量引用的对象
虚拟机内部引用:基本数据类型对应的Class对象
被同步锁持有的对象
什么对象可作为GCRoots对象(从内存区域角度分析,哪里会使用到对象)
可达性分析算法
2、如何判断对象已“死”?
最传统的引用定义,在程序代码中普遍存在,类似new
无论任何情况,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
强引用
还有用但非必须的对象
在系统发生内存溢出前,会把这些对象列入回收范围中进行二次回收
软引用
非必须对象,只能活到下一次垃圾收集之前
弱引用
虚引用
3、再谈引用
可达性分析中判定为不可达的对象,并不是非死不可。最多经历两次标记过程
1、可达性分析第一次标记
2、筛选:是否有必要执行finalize方法
3、没必要,则直接死亡;有必要,置入F-Queue队列等待执行finalize方法
4、在执行finalize方法过程中可以自救,重新引用
5、对F-Queue队列中的对象进行第二次标记
tip:任何一个对象的finalize方法只会被系统自动调用一次
4、再谈死亡
回收的主要内容:废弃的常量和不再使用的类型
常量的判断方法相对简单
该类的所有实例已被回收
加载该类的类加载器已经被回收
该类对应的Class对象没有被引用
类型的判断:
tip:即使满足上述条件,类型也不一定必然被回收,仅仅是允许被回收,方法区的回收条件相对是比较苛刻的,实际取决于虚拟机
5、方法区的回收
引用计数式垃圾收集
追踪式垃圾收集(我们讨论的算法都属于这类)
算法大类分类
熬过越多次垃圾收集过程的对象就越难以消亡
强分代假说
绝大多数对象都是朝生夕灭
弱分代假说
跨代引用相对于同代引用来说只占少数
跨代引用假说
虽然这样增加了记忆集维护的开销,但是这比起去扫描整个老年代来说还是更划算的
在新生代建立全局的数据结构:记忆集
跨代引用的解决:
分代收集理论
特点:简单粗暴
1、执行效率不稳定
2、内存空间碎片化,易引起提前GC
问题
标记-清除算法
version1:半区复制,空间对半开,浪费空间
version2:优化之后,以8:1:1将内存分为一个伊甸区和两个生存区,同时以老年代兜底,surviror空间实在是装不下了,直接进入老年代
标记-复制算法
标记过程与标记清楚算法一样,但是区别在于它不是直接清理,而是先移动再清理
特点:专注系统整体吞吐量
1、移动对象会使内存回收更复杂
2、移动对象的操作会触发STW
标记-整理算法
平时多数时间用标记-清楚,当内存碎片化程度到一定程度时,再用标记-整理算法收集一次,我们后面会提到是CMS收集器使用的就是这种处理方法
还有一种和稀泥式办法
6、垃圾收集算法
问题:标记GCRoots很简单,但是往下找引用不可避免会面临STW问题,必须得保存枚举期间的一致性
一致性:枚举期间执行子系统看起来像被冻结在某个时间点上,不会出现分析过程,根节点集合的对象引用还在不断变化的情况
解决:OopMap数据结构
根节点枚举
解决:主动式中断
主动式中断:设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时,就自己在最近的安全点上主动中断挂起。
tip:轮询标志的地方与安全点是重合的。
tip:安全点:具有指令序列复用功能的指令才回产生安全点,例如方法调用、循环跳转、异常跳转等
安全点
理解为将安全点拓展延伸
安全区域
记忆集与卡表
写屏障
增量更新解决并发扫描时对象消失的问题
原始快照解决并发扫描时对象消失的问题。
对象未被垃圾收集器访问过
白色
对象已被垃圾收集器访问过且它的引用也都被扫描过
黑色
对象被垃圾收集器访问过,但它至少还有一个引用对象没被垃圾收集全器访问过
灰色
三色标记法
并发的可达性分析
7、算法实现细节(这个个人觉得有点儿抽象,除了三色标记的内容,一般你不引导也不会往这块问吧?)
单线程、简单高效,HotSpot新生代默认收集器
标记复制算法
Serial
Serial的多线程版本
ParNew
专注吞吐量
基于标记-复制算法的多线程收集器
Parallel Scavenge
新生代
标记-清除算法 + 标记整理算法
简单标记一下GCRoot能关联的对象
初始标记(有STW)
开始遍历整个对象图
并发标记
修正并发期间产生变动的标记
重新标记(有STW)
并发清除
过程
1、对处理器资源敏感,通俗的说就是对硬性要求挺高
并发清除期间还会有新的垃圾产生,如果这些垃圾很多,老年代内存不够的话,会出现并发失败,强制唤醒serialOld对老年代进行清理
2、浮动垃圾问题,容易引发GC
有和稀泥解决办法,来一次标记-整理
3、空间碎片问题
CMS(最短回收停顿时间)
标记整理算法
单线程
Serial Old
多线程
Parallel Old
老年代
整体是基于标记-整理,局部是基于标记-复制
特点:面向局部收集的设计思路和基于Region的内存布局
停顿预测模型:支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒
1、跨Region引用→使用底层数据结构为哈希表的记忆集
2、并发问题→原始快照
3、停顿预测模型的建立→衰减均值
问题和解决
标记一下GCRoot是能关联到的对象
初始标记
扫描整个对象图
处理并发标记期间发送改变的记录
最终标记
筛选回收
G1(JDK9里程碑式的成果)
Shenandoah(过于冷门了,它自身也不受Oracle待见,pass)
ZGC
低延迟垃圾收集器
并行:多条垃圾收集器线程之间的关系,同一时间有多条这样的线程在协同工作,默认此时用户线程处于等待状态
并发:垃圾收集线程与用户线程之间的关系,同一时间垃圾收集器线程与用户线程都在运行。
垃圾收集语境下的并发和并行
tip:
8、经典垃圾收集器
1、对象优先分配在伊甸区
2、大对象直接进入老年代
3、长期存活对象进入老年代,默认15(没熬过一次YoungGC就+1)
4、动态对象年龄判断:如果生存区空间中低于或等于某年龄的所有对象大小的总和大于生存区空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
5、空间分配担保,老年代兜底
9、内存分配与回收策略
垃圾收集器与内存分配策略
jps虚拟机进程状况工具
jstat虚拟机统计信息监视工具
jinfo:java配置信息工具
jmap:java内存映象工具
jhat:虚拟机堆转储快照分析工具
jstack:Java堆栈跟踪工具
JHSDB:基于服务型代理的调试工具
JConsole:Java监视与管理控制台
JVisualVM:多合-故障处理工具(个人工作中用的最多的是这个,结合jps使用)
Java Mission Control:可持续在线的监控工具
可视化故障处理工具
性能监控、故障处理工具
Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与Class文件这种特定的二进制文件格式所关联,虚拟机丝毫不关心Class文件的来源是什么语言
虚拟机和字节码存储格式
无关性的基石
Class文件是一组以字节为基础单位的二进制流。任何一个Class文件都对应着唯一个一个类或接口的定义信息
无符号数:u1、u2、u4、u8
表:由多个无符号数或者其他表作为数据项构成的符合数据类型
Class文件格式只有两种数据类型:无符号数和表
经典的CAFEBABE
魔数
次版本
主版本
Class文件版本
字面量
符号引用
常量池
标志这个Class是类还是接口、是否定义为public、是否为抽象类型、是否声明final等
访问标志
类索引、父类索引、接口索引集合
字段表集合
属性表集合
格式详情
Class类文件结构
类文件结构(这块也挺抽象的,思维导图不好描述,最好认真看一下书呗)
加载、验证、准备、解析、初始化、使用、卸载
这些阶段通常都是相互交叉地混合进行
1、New对象或操作静态内容的时候
2、反射调用
3、初始化带继承关系的类时,触发父类初始化
4、包含main方法的类优先初始化
5、JDK7动态语言支持
6、JDK8默认方法
tip:虚拟机严格规定六种情况必须立即对类进行初始化
tip:上述六种行为称为主动引用
通过子类引用父类的静态字段,不会导致子类初始化
通过数组定义来引用类、不会触发此类的初始化
常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
被动引用
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有真正使用到父类接口的时候才会初始化
接口与类初始化直接的区别
加载时机
1、通过一个类的全限定名来获取定义此类的二进制字节流
2、将这个字节流所代表的静态结构转化为方法区的运行时数据结构
3、在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口
加载
文件格式验证
元数据验证
字节码验证
符号引用验证
验证、
为类中定义的变量(静态变量)分配内存空间并设置类变量初始值
准备
将常量池内的符号引用替换为直接引用
解析
这个方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并生成的,收集的顺序由语句在源文件中出现的顺序决定,不同于<init>()方法,它不需要显示调用父类的<cinit>()方法,虚拟机会自动保证子类<cinit>()方法执行前,父类的<cinit>()方法执行完毕,所以,父类中定义的静态语句块要优先于子类执行
执行执行类构造器<clinit>()方法
初始化
类加载的过程
比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来自同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等
加载存放在<JAVA_HOME>\\lib目录下的类库
启动类加载器
加载\\lib\\ext目录下的类库
拓展类加载器
加载classpath下的类库
应用程序加载器
自定义类加载器
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个类层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子类加载器才会尝试自己去完成加载
双亲委派模型
在这个环境下如果使用双亲委派模型会导致什么结果?
因为一个服务器下面可能会有多个war包在运行
TomCat的应用
破坏双亲委派
双亲委派模型的好处是确保类在虚拟机中的唯一性,保证Java程序运行的稳定性
类加载器
类加载机制
解释执行(通过解释器执行)
编译执行(通过即时编译器产生本地代码执行)
存放方法参数和方法内部定义的局部变量
这里就是引申出了this和super的面试题,this本质是一个指针
PS:实例方法,局部变量表中第0为索引的变量槽默认是用于传递方法所属对象实例的引用,就是我们所说的this关键字
局部变量表
操作数栈
动态连接:每一个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
动态连接
方法返回地址
栈帧结构详解(栈帧需要的内存大小在编译源码的阶段就确定了,不受运行时影响)
将符号引用转化为直接引用,前提是程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可以改变的
案例:静态方法、私有方法
静态方法、私有方法、实例构造器、父类方法、final修饰的方法
非虚方法(类加载的时候就会把符号引用解析为方法的直接引用)
非虚方法以外的方法
虚方法
典型案例:重载
虚拟机在重载时,是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译器可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本。
总之:所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
优先级:基本数据类型转换>包装类>可变参数
tip:虽然能确定方法的重载版本,单很多情况下重载版本并不是唯一的,往往只能确定一个相对合适的版本。
静态分派
典型案例:重写
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
2、如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,不通过则抛出异常
3、否则,继续按照继承关系从下往上依次都C的各个父类进行第二步的搜索和验证过程
4、如果始终没找到,直接抛出异常
动态分派的关键在于invokevirtual指令
tip:字段永远不会参与多态
虚拟机动态分派的实现:为类型在方法区中建立虚方法表,使用虚方法表索引来代替元数据查找以提高性能
动态分派
分派
方法调用(不是我们第一反应的那个调用的意思,而是确定被调用方法的版本,还没涉及到方法的具体运行)
虚拟机字节码执行引擎
Java内存模型(Java Memory Model,JMM)是Java语言规范中定义的一种规范,用于描述在多线程并发执行时,不同线程之间如何与共享内存交互。它确保了不同线程对共享变量的读写操作能够按照一定的规则进行,从而保证多线程程序的正确性和可预测性。
定义
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值的底层细节
Java内存模型规定,所有变量都存储在主内存中。每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也不能直接访问对方工作内存中的变量,线程之间传递变量值需要通过主内存完成
Java内存模型
volatile是Java虚拟机提供的最轻量级的同步机制。
强刷缓存实现
可见性
禁止指令重排序实现
有序性
比如自增自减
Java运算操作符并非是原子操作,导致volatile变量的运算在并发下是不安全的
不完全保证原子性
特性:
底层:带lock前缀的指令(内存屏障)→处理器嗅探机制,将工作线程的缓存写入内存,同时使其他线程的缓存失效,保证线程读取到的是最新值,保证可见性;插入内存屏障(lock前缀指令),禁止指令的重排序
针对浮点型的变量,除非明确可知变量存在竞争,否则不要刻意的声明为volatile
volatile关键字
原子性
它本身并不会禁止指令重排序,但是他符合了“as-if-serial”原则,在但线程中的执行结果不会改变,因为它恁重,他都直接阻塞其他线程了,其他线程在它释放之前根本就不可能去干扰他,所以它在单线程里怎么执行都是它的事。
某种意义上的保证有序性
代码块同步,字节码文件中会多出monitorenter和monitorexit指令
方法同步,字节码文件中的方法信息中会多一个同步标识
JVM基于进入和退出Monitor对象来实现方法同步和代码同步,再深入一点就是每一个Java对象都持有一个对应的Monitor对象,这个Monitor对象是底层C++实现的(在Java虚拟机(HotSpot)中,monitor是由OnjectMonitor实现的,其主要的数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现)),Java对象通过对象头中的指针与它关联,再再深入一点,就是操作系统里说的管程(尚硅谷阳哥著名言论:天生飞的理念都有落地的实现,这个也是基于操作系统理念进行落地的实现)
底层
特性
synchronized关键字
天生不可变,天生线程安全,实现原原理是内存屏障
final
是判断数据是否存在竞争,线程是否安全的非常有用的手段
程序按照控制流的顺序执行
程序次序规则
对一个锁的释放先发生于对一个锁的获取
管程锁定规则
对一个volatile变量的写操作先发生于对一个volatile变量的读操作
volatile变量规则
start方法先发生于线程的内容执行
线程启动规则
对线程的操作先发生于线程的终止检测
线程终止规则
对线程的interrupt方法调用先发生于被中断线程的代码检测到中断事件的发生
线程中断规则
对象的初始化完成先发生于finalize方法的开始
对象终结规则
A先于B,B先于C,则A先于C
传递性
Happens-Befor原则(这些原则无需任何同步手段保障就能成立)
线程是比进程更轻量级的调度执行单位,线程的引入可以把一个进程的资源分配和执行调度分开,提高执行效率
内核级线程1:1
用户级线程1:N
混合实现N:M
实现
协同
抢占(默认)
线程调度方式
NEW
syn
BLOCKED
start()
RUNNABLE
Object.wait()
Thread.join()
LockSupport.park()
WAITING
Thread.sleep()
Object.wait(time)
Thread.join(time)
LockSupport.parkNanos()
LockSupport.ParkUntil()
TIMED WAITING
TERMINATED
线程状态
线程
当多个线程同时访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
线程安全的定义
安全性最直接、最纯粹,例如:final、数值包装类型、大数据类型
不可变
JavaAPI中标注自己是线程安全的类,大多数都不是绝对的线程安全。
绝对线程安全
Vector、HashTable
相对线程安全
ArrayList、HashMap
线程兼容(我们通常说的线程不安全)
Java环境下,这种代码通常是有害的,尽量避免,例如:Thread类的弃用方法就是因为这个,会导致死锁
线程对立
线程安全分类
tip:互斥是方法,同步是目的
可重入
无条件阻塞后面其他线程进入
synchronize
等待可中断
可实现公平锁
可以绑定多个条件
相较synchronize的优点
ReentrantLock
Lock接口
互斥同步
内部就是Unsafe类,本质就是CAS
JUC包里提供的原子类
内存位置V
旧预期值A
准备设置的新值B
三个操作数
当且仅当V符合A时,处理器才会用B更新V的值
版本号
其实大部分情况下ABA问题不会影响程序并发的正确性
tip:CAS的ABA问题
CAS
非阻塞同步(无锁编程)
可重入代码
ThreadLocalMap(空间换时间,每个线程持有一份副本)
线程本地存储
tip:如果一个代码块本身就不涉及共享数据,那这个代码天生就是线程安全的
无锁同步(天生线程安全)
线程安全的实现方法
适应性自旋
检测到需要同步的代码段根本不存在竞争,进行优化
锁消除
扩大加锁范围(如:在循环体中使用,导致每次循环都要加锁释放锁,性能开销太大,不如直接扩大范围)
锁粗化
轻量级锁
偏向锁
synchronize锁优化
线程安全与锁优化
Java内存模型与线程
JVM
0 条评论
回复 删除
下一页