Java虚拟机基本原理
Java虚拟机基本原理
1. Java 代码运行方式
1.1 为什么需要虚拟机
- 设计一个面向Java语言特性的虚拟机
- 做这个的原因是为了能够在各种机器上来实现对Java的支持
- 通过编译器将Java程序转换成该虚拟机所能识别的指令序列,又被称为Java字节码
- 叫做Java字节码的原因是字节码指令的操作码opcode被定义成了一个字节
- Java的虚拟机是可以由硬件实现的,也可以在各个平台上(Windows/ Linux) 提供软件的实现
- 好处1: 一旦一个程序被转换成了Java字节码,那么便可以在不同平台上的虚拟机里来运行
- 好处2: 带来了托管环境,这个托管环境能够代替我们处理一些代码当中冗长而且容易出错的部分
- 自动内存管理
- 垃圾回收
- 诸如数组越界,动态类型,安全权限等等的动态检测功能
1.2 如何运行Java字节码的?
从虚拟机视角来看
首先将其编译成的class文件加载到Java虚拟机当中
加载后的Java类会被存放于方法区里 Method Area
实际执行的时候,执行方法区的代码
空间分配
线程共享的
方法区
- 用来存放类似于元数据信息方面的数据
- 类信息
- 常量
- 静态变量
- 编译后代码
- JDK 1.8之后,不再有方法区了,元数据会放到本地内存的Metaspace 即元空间里面
- 使用Metaspace的优势
- 元空间大小默认为Unlimited 即只受到系统内存的限制
- 因为元数据大小不再由MaxPermSize控制,而由实际的可用空间控制,这样能加载的类就更多了
- 使用Metaspace的优势
- 用来存放类似于元数据信息方面的数据
堆
放置对象实例,数组等
和方法区同属于线程共享区域,是线程不安全的
堆内存的划分
- 年轻代
- Eden 8
- 当我们new一个对象以后,会放到Eden划分出来的一块作为存储空间的内存当中
- 每个线程都会预先申请一块连续空间的内存空间并且规定了对象存放的位置,如果空间不足就再多申请内存空间
- Survivor
- FromPlace 1
- ToPlace 1
- Eden 8
- 老年代
- 年轻代
GC的运行逻辑
Eden空间满了以后,会触发Minor GC
存活下来的对象移动到Survivor 0区
Survivor 0区满后触发Minor GC,将存活对象移动到Survivor 1区,此时还会将from和to指针交换,保证了一段时间内总有一个survivor区为空,且to所指向的survivor区为空
经过多次Minor GC仍存活的对象移动到老年代,一般是15次,因为Hotpot给记录年龄分配到的空间只有4位
老年代用来存储长期存活的对象,占满了就会触发Full GC,期间会停止所有线程等待GC的完成 —→ 需要尽量避免这种情况的发生
当老年区执行了full GC 还是无法对对象保存,就会产生OOM, 这意味着虚拟机中的堆内存不足
- 可能原因
- 设置的堆内存过小
- 代码中创建的对象大且多,一直被引用导致GC无法收集他们
- 可能原因
线程私有
- 程序计数器
- 完成加载工作
- 本身是一个指针,指向程序当中下一句需要执行的命令
- 分支,循环,跳转,异常处理,线程恢复等功能都依赖于这个计数器来完成
- 占用空间非常非常小
- 这个内存仅仅代表了当前线程所执行的字节码的行号指示器
- 字节码解析器通过改变这个计数器的值来选取下一条需要执行的字节码指令
- VM 栈
- 当调用一个Java方法的时候,会在当前线程生成一个栈帧,用来存放局部变量以及字节码的操作数
- 栈帧大小是已经计算好了的,栈帧不需要连续分布
- 方法执行的内存模型
- 对局部变量,动态链表,方法出口,栈的操作,对象引用进行存储,并且线程独享
- 如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报StackOverflowError
- JVM是可以动态扩展的,但随着扩展会不断申请内存,当无法申请足够内存的时候就会报OutOfMemoryError
- 栈并不存在垃圾回收,因为只要程序运行结束,栈的空间自然会释放的。— 栈的生命周期和所处的线程是一致的
- 本地方法栈
- 由native修饰的方法
- 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务
- 程序计数器
直接内存
- 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机定义的内存区域
- 是通过基于通道和缓存区的NIO,直接使用Native函数库来分配堆外内存,然后通过一个存储在Java堆当中的DirectByteBuffer对象作为这块内存的引用,进而进行操作
从硬件视角来看
- 需要虚拟机将字节码翻译成机器码
- 翻译方式
- 解释执行
- 逐条将字节码翻译成机器码并且执行
- 优势
- 无需等待编译
- 即时编译 Just In Time Compilation
- 将一个方法中包含的所有字节码编译成机器码以后再执行
- 优势
- 实际执行速度会更快
- 解释执行
- hotpot的翻译方式
- 先解释执行字节码
- 而后将反复执行的热点代码按照方法来作为基本单元进行JIT 即时编译
走一个代码例子
@RequireAllArgConstructor
public class People {
public String name;
public void sayName() {
System.out.println("People's name is: " + name);
}
}
public class App {
public static void main(String[] args) {
People people = new People("test");
people.sayName();
}
}
- 执行main方法的步骤如下
- 编译好App.java之后得到App.class,执行App.class
- 系统会启动一个JVM进程
- 从classpath路径当中找到一个名为App.class的二进制文件,将App的类信息加载到运行时数据区的方法区内
- JVM找到App主程序入口,执行main方法
- 当要执行new People的时候,发现方法区当中还没有People类的信息,所以JVM马上加载,并将其类的信息放到方法区里面
- JVM在堆当中为一个新的People实例分配内存,然后调用构造函数初始化People实例,这个实例持有指向方法区中的People类的类型信息的引用
- 执行people.sayName();时,JVM 根据 people 的引用找到 people 对象,然后根据 people 对象持有的引用定位到方法区中 people 类的类型信息的方法表,获得 sayName() 的字节码地址
- 执行sayName()
1.3 Java虚拟机执行效率
- 优化方式
- 即时编译
- 底层逻辑 — 二八定律
- 认为20%代码会占据80%的计算资源
- 编译器类别 — tradeoff 编译时间 vs 执行效率
- C1
- Client编译器
- 面向对启动性能有要求的客户端GUI程序
- C2
- Server编译器
- 面向对峰值性能有要求的服务器端程序
- 采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的效率比较高
- Graal
- C1
- 底层逻辑 — 二八定律
- 即时编译
- Hotpot对于各种编译器的采用方式
- 分层编译
- 热点方法先被C1 编译
- 热点方法里的热点会进一步被C2 编译器编译
- 会影响应用的正常进行么?
- 即时编译是在额外的编译线程当中进行的
- 会根据CPU的数量设置编译线程的数目,并且按照1:2的比例配置给C1 及C2 编译器
- 分层编译
2. 基本类型在虚拟机当中的实现
为什么要引入基本类型而不是全都使用对象呢?
- 基本类型更靠近底层,在执行效率和内存使用方面都能够提升软件的性能
boolean 类型
- 映射成int类型
- true被映射为整数1
- false被映射为整数0
- 映射成int类型
Java虚拟机在调用Java方法的时候,会创建出一个栈帧,对于其中的解释栈帧来说,有两个主要组成部分
- 局部变量区
- 局部变量
- this指针
- 方法接收的参数
- 各个基本类型在局部变量区的表现
- 局部变量区等价于一个数组
- long double需要两个数组单元存储
- 其他基本类型和引用类型的值均占用一个数组单元
- 局部变量区等价于一个数组
- 字节码的操作数栈
- 局部变量区
存储操作
- 如果我们将一个int类型的值放到char short 等里面,相当于做了一次掩码 只会保留低位了
加载
- 算数运算完全依赖于操作数栈
- 堆当中的boolean, byte, char, short 加载到操作数栈当中,而后将栈上的值当成int类型来运算
3. 类的加载,链接,初始化过程
- 首先.java文件会被编译成.class文件,而后我们需要类加载器来处理.class文件,让JVM对其进行处理
- 从class文件到内存当中的类,需要经过:
- 加载
- 查找字节流,并且根据此来创建类的过程
- 将class类加载到内存当中
- 将静态数据结构转化为方法区当中运行时的数据结构
- 在堆当中生成一个代表这个类的java.lang.class对象作为数据访问的入口
- 借助类加载器来完成查找字节流的过程
- 启动类加载器 — bootstrap class loader
- 负责加载最基础最重要的类,譬如JRE lib目录下Jar包中的类
- 由虚拟机参数 -Xbootclasspath指定的类
- 其他类加载器 — 都是java.lang.ClassLoader的子类
- 需要先由启动类加载器,将其加载至Java虚拟机当中,方能执行类的加载
- E.G
- 扩展类加载器 — 父类是启动类加载器
- 负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)
- 应用类加载器 — 父类是扩展类加载器
- 负责加载应用程序路径下的类
- 例如虚拟机参数 -cp/-classpath, 系统变量java.class.path或者环境变量CLASSPATH所指定的路径
- 默认应用程序里包含的类应该由应用类加载器来进行加载
- 负责加载应用程序路径下的类
- 扩展类加载器 — 父类是启动类加载器
- 启动类加载器 — bootstrap class loader
- 双亲委派模型
- 当一个类加载器接收到加载请求时,会先将请求转发给父类加载器
- 在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载
- 类加载器 — 命名空间的作用
- 类的唯一性由类加载器实例和类的全名共同确定
- 即使同一串字节流,经由不同的类加载器加载,也会得到不同的类
- 查找字节流,并且根据此来创建类的过程
- 链接
- 将创建的类合并到Java虚拟机当中,并且使其能够执行的过程
- 过程
- 验证
- 确保被加载类能够满足Java虚拟机的约束条件
- 安全检查
- 准备
- 为被加载类的静态字段分配内存
- 就是为static变量在方法区当中分配内存空间,设置变量的初始值
- 也会来构造和其他类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表
- 为被加载类的静态字段分配内存
- 解析 — 对于字节码符号引用的解析
- 在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。
- 因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
- 举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
- 解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
- 验证
- 初始化
- 内容
- 为标记为常量值的字段赋值
- 执行clinit方法 — Java虚拟机通过加锁确定clinit方法仅仅会被执行一次
- 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。
- 除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。
- 触发情况
- 当虚拟机启动时,初始化用户指定的主类;
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 子类的初始化会触发父类的初始化;
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 使用反射 API 对某个类进行反射调用时,初始化这个类;
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
- 内容
- 卸载
- GC将无用对象从内存当中卸载掉
- 加载
- 基本类型是Java虚拟机已经设置好的,而另一大类引用类型,Java将其细分为四种
- 类
- 有对应的字节流 — class文件
- 接口
- 有对应的字节流 - class文件
- 数组类
- 由Java虚拟机直接生成
- 泛型参数
- 类
4. JVM执行方法调用
4.1 重载和重写
- 重载
- 同一个类当中方法名称相同,但是方法的参数不相同的情况
- 在编译过程当中就可以完成识别,Java编译器根据传入参数的声明类型,来选取重载方法
- 三个阶段
- 不考虑基本类型的自动拆箱装箱,还有可变长参数的情况下选择重载方法
- 1阶段没有找到适配的方法,那么就在允许自动拆装箱,但不允许可变长参数的情况下选取重载方法
- 2阶段没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法
- 如果Java编译器在同一个阶段找到了多个适配方法,那么就会选择一个最为贴切的,决定贴切程度的一个关键就是形式参数类型的继承关系
- 会选择那个范围更小的,比如某某的子类这样子
- 三个阶段
- 重写
- 子类定义了和父类非私有方法同名的方法,而且这两个方法的参数类型相同
- 如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法
- 如果都不是静态,也不是私有的,那么子类的方法重写了父类当中的方法
- 方法重写 — 允许子类在继承父类部分功能的同时,拥有自己独特的行为
4.2 JVM 静态和动态绑定
- Java虚拟机识别方法
- 类名
- 方法名
- 方法描述符 — method descriptor
- 由方法的参数类型以及返回类型所构成的
- JVM和Java语言在这里不太一样,同一个类下同样方法名,同样参数,但是不同返回值从JVM角度来说是可以被认为是不同的方法,是可以通过的
- 静态绑定
- 在解析的时候便能够识别目标方法的情况
- 重载 — 是在编译阶段就完成了的,也可以成为static binding
- 动态绑定
- 在运行过程当中根据调用者的动态类型来识别目标方法的情况
- 重写 — 在JVM当中来做识别,dynamic binding
- Java字节码当中和调用相关的指令
- invokestatic - 用于调用静态方法
- invokespecial - 用于调用私有实例方法,构造器 以及使用super关键字调用父类的实例方法或者构造器
- invokevirtual - 用于调用非私有实例方法
- invokeinterface - 用于调用接口方法
- invokedynamic - 用于调用动态方法
4.3 调用指令的符号引用
- 在编译过程当中,我们并不知道目标方法的具体内存地址
- Java编译器会暂时用符号引用来表示该目标方法
- 这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
5. 垃圾回收
5.1 如何判断需要清理一个对象
- 绿色部分是线程拥有的,会随着线程的结束而自动被回收 这里不需要考虑垃圾回收的问题
- 橙色部分是共享的,内存分配和回收都是动态的,因此垃圾收集器所关注的都是堆和方法这部分内存
- 判断对象存活的方法
- 引用计数器计算
- 给对象添加一个引用计数器,每次引用这个对象的时候计数器加一,引用失效则减一
- 计数器等于零的时候就不会再次试用了
- 可达性分析计算
- 将一系列GC ROOTS作为起始的存活对象集,从这个节点往下搜索
- 搜索所走过的路径成为引用链,将能被该集合引用的对象加入到集合当中
- 当搜索到一个对象到GC roots没有使用任何引用链的时候,就说明这个对象是不可用的
- 引用计数器计算
5.2 如何宣告一个对象的结束/ 死亡
- finalize()是Object类的一个方法,一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象,二次标记的时候会给移出
- 整个流程如下
- 如果对象进行可达性分析之后没发现与GC Roots相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行finalize()方法。如果对象有必要执行finalize()方法,则被放入F-Queue队列中。
- GC对F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。
5.3 垃圾回收算法
5.3.1 标记清除算法
阶段
- 标记
- 标记处所有需要回收的对象
- 清除
- 标记结束以后进行统一的回收
- 标记
原理
- 将已死亡的对象标记为空闲内存,记录在一个空闲列表当中
- 当我们需要new一个对象的时候,内存管理模块会从空闲列表当中寻找空闲的内存来分给新的对象
缺陷
会使得内存当中的碎片非常多
容易导致当我们需要使用大块的内存的时候,无法分配足够的连续内存
5.3.2 复制算法
在标记清除算法的基础上做的优化
- 将可用内存按照容量划分成两等分,每次只使用其中一块
- 当一块存满了 就将存活的对象复制到另一块上,然后交换指针的内容
- 以此解决碎片化的问题
缺陷
可用内存减少了!
5.3.3 标记整理算法
- 标记了以后会做整理,将所有存活的对象都向内存块一端移动,然后直接清理掉边界以外的内存
5.3.4 分代收集算法
- 根据对象存活周期的不同将内存划分为几块
- 新生代
- 每次垃圾收集都有大批对象死去,少量存活
- 所以可以选用复制算法
- 老年代
- 对象存活率高,没有额外空间对其进行分配和担保
- 需要使用标记-清理或者标记整理算法来进行回收
5.4 垃圾收集器
- jdk8 默认收集器是Parallel Scavenge 和 Parallel Old
- jdk9开始,G1收集器成为默认的垃圾收集器
Reference
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 stone2paul@gmail.com
文章标题:Java虚拟机基本原理
文章字数:5.4k
本文作者:Leilei Chen
发布时间:2021-06-23, 12:21:43
最后更新:2021-06-28, 08:53:37
原始链接:https://www.llchen60.com/Java%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%9F%BA%E6%9C%AC%E5%8E%9F%E7%90%86/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。