JVM虚拟机
-
-
- JVM虚拟机规范与实现
- =========== JVM虚拟机规范 =========================
- =========== JVM虚拟机实现 =========================
- =========== JVM的常见实现 =========================
- ========== JVM虚拟机物理架构 ======================
- ========== JVM虚拟机的运转流程 =====================
- ========== JVM类加载过程 =========================
- ========== JVM类加载器及类加载器类型 ===============
- ========== JVM类加载器双亲委派机制 ===============
- ========== JVM运行时数据区的内存模型 ===============
- ========== JVM运行时数据区的内存模型:程序计数器 ===============
- ========== JVM运行时数据区的内存模型:虚拟机栈 ===============
- ========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-局部变量表
- ========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-操作数栈
- 1. 求和操作在方法区中的执行流程
- ========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-动态链接
- ========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-方法返回地址
- ========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-附加信息
- ========== JVM运行时数据区的内存模型:本地方法栈 ===============
- ========== JVM运行时数据区的内存模型:方法区 ===============
- ========== JVM运行时数据区的内存模型:运行时常量池 ==========
- ========== JVM运行时数据区的内存模型:堆 ===============
- ========== JVM运行时数据区的内存模型:堆-垃圾回收思想下的堆内存模型
- ========== JVM运行时数据区的内存模型:新生代为什么需要Survivor空间?
- ========== JVM运行时数据区的内存模型:新生代为什么需要两个Survivor区?
- ========== JVM运行时数据区的内存模型:JVM在分代垃圾回收思想下的内存分配流程?
- ========== JVM运行时数据区的内存模型:对象从新生代晋升到老年代有哪些方式?
- ========== JVM运行时数据区的内存模型:老年代的空间分配担保机制?
- ========== JVM虚拟机执行引擎 ==============
- ========== JVM虚拟机执行引擎:解释器 ==============
- ========== JVM虚拟机执行引擎:JIT编译器 ==============
- ========== JVM虚拟机执行引擎:JIT编译器分类&区别 ==============
- ========== JVM虚拟机执行引擎:解释器还是JIT编译器? ==============
- ========== JVM虚拟机执行引擎:垃圾收集器-对象是否可以被回收 ==============
- ========== JVM虚拟机执行引擎:垃圾收集器-垃圾收集算法及实现原理
- ========== JVM虚拟机执行引擎:垃圾收集器-分类及实现原理 ==============
-
JVM虚拟机规范与实现
=========== JVM虚拟机规范 =========================
JVM规范是由Oracle指定并发布的,它定义了JVM的行为、功能和要求,提供了JVM的公共视图。规范描述了JVM的类文件格式、字节码指令集、运行时数据区域、垃圾回收、异常处理、线程管理以及其他与Java程序执行相关的细节,这些组件对于JVM的硬件、操作系统和实现的独立性非常重要。JVM规范的目的是确保不同的JVM实现在语义上和行为上保持一致,从而实现Java程序的可移植性。JVM的实现者可以将JVM规范是为一种安全地在实现了JavaSE平台的主机之间传递程序片段的方式,而不仅仅是要严格遵循的蓝图。
=========== JVM虚拟机实现 =========================
JVM的实现是根据JVM规范构建和开发的软件实体。不同的厂商和组织可以根据JVM规范自行开发符合规范要求的
JVM实现。常见的JVM实现包括Oracle JDK和OpenJDK。每个JVM实现都会根据规范来解释和执行Java程序的字节码,提供必要的运行时环境和支持库。
JVM的规范和实现之间的关系可以用以下方式描述:
- 规范是对JVM的抽象描述,它定义了JVM如何运行和处理Java程序。规范提供了一种标准,以确保不同的JVM实现在语义上保持一致性。
- 实现是基于规范的具体软件实体,它是将规范化可执行代码的具体实现。实现者需要确保其JVM实现能够遵循规范的要求,并提供符合规范的功能和行为。
- 规范和实现之间存在一定的灵活性。实现者可以根据规范的约束进行优化和修改,以提供更好的性能、内存利用率或其他特定目标的JVM实现。只要实现能够正确解释和执行Java程序的字节码,并保持与外部接口的一致性,实现者可以在实现内部进行适当的修改。
=========== JVM的常见实现 =========================
HotSpot JVM(Oracle):HotSpot是Oracle官方提供得JVM实现,广泛用于生产环境,它具有优化得即时编译器和垃圾回收器,提供了良好得性能和稳定性。
OpenJ9 JVM(Eclipse):OpenJ9是Eclipse基金会得一个开源项目,提供了一个高度可扩展和高性能得JVM实现。它具有低内存小号和快速启动时间的特点,适用于云环境和嵌入式设备。
Zing JVM(Azul System):Zing是由Azul Systems开发的商业级JVM。它专注于提供可预测的低延迟和高吞吐量,适用于需要高度可伸缩性和高性能的大型企业应用程序。
JRockit JVM(Oracle):JRockit 是原始由BEA System开发的JVM,在被Oracle收购后成为Oracle收购后成为Oracle的产品之一。它专注于提供高吞吐量和低垃圾收回停顿时间,适用于需要高性能的企业应用程序。
IBM JVM(IBM):IBM提供了自己的JVM实现,用于其Java开发工具和应用服务器。它具有高度可扩展和优化的垃圾回收器,并提供了一些针对企业应用的增强功能。
========== JVM虚拟机物理架构 ======================
JVM的物理架构主要包括以下几个部分:
- 类加载器:加载字节码到内存中。
- 执行引擎:执行字节码指令。
- 运行时数据区:存储运行时数据,如对象实例、线程栈等。
- 本地库接口:调用本地库中的函数。
========== JVM虚拟机的运转流程 =====================
首先通过类加载器将Java代码转换成字节码文件,运行时数据区将字节码加载到内存,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎,将字节码翻译成底层系统指令,再交由CPU去执行,而在这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。
========== JVM类加载过程 =========================
- 加载:类加载是一个过程,在加载阶段JVM虚拟机需要完成以下三件事:
1.1 通过全限定类名获取此类的二进制字节流。
1.2 将整个字节流代表的静态结构转化为方法区的运行时数据结构。
1.3 在内存中生成代表此类的java.lang.Class对象,作为方法区这个类各种数据的访问对象。- 验证:这个阶段时为了确保class文件的字节流包含的信息符合当前虚拟机的需求,并且不会危害到虚拟机自身安全,验证阶段大致分为下面四个阶段的验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
- 准备:主要是为类的静态变量分配内存并将其初始化为默认值,这些内存都在方法区中分配。若类变量为常量(被final修饰),则直接赋开发者定义的值。
- 解析:是虚拟机将常量池内的符号引用替换为直接引用的过程,包括:类或接口的解析、字段的解析、类方法的解析。
- 初始化:负责执行类中的所有静态变量、静态初始化代码、类或接口初始化方法以及构造器代码的初始化。
- 使用:
- 卸载:
========== JVM类加载器及类加载器类型 ===============
虚拟机将类加载阶段中“通过全限定类名获取此类的二进制字节流”这个动作放到了Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器”;从Java虚拟机的角度来看,只有两种不同的类加载器,一种是启动类加载器,这个类加载器由C++语言实现,是虚拟机自身的一部分,另外一种就是所有其他的类加载器,这些类加载器由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader:
- 启动类加载器:负责将存放在JAVA_HOME\lib目录中的并且是虚拟机识别的(rt.jar)类库加载到内存中。
- 扩展类加载器:负责将存放在JAVA_HOME\ext目录中所有类库加载到内存中,开发者可以直接使用扩展类加载器。
- 应用程序类加载器:负责加载用户类路径上所指定的类库加载到内存中,开发者可以直接使用这个类加载器。
========== JVM类加载器双亲委派机制 ===============
类加载器的双亲委派机制是在JDK1.2中引入的,它的工作机制是:如果一个类加载器收到类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都应该传送到顶层的启动类加载器中,只有父类加载器反馈无法完成加载请求时(它搜索范围内没有找到所需要的类),子类加载器回尝试自己去加载。
使用双亲委派机制组织类加载之间的关系,有一个好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,如果没有使用双亲委派机制,有各个类加载器自行去加载的话,那系统可能出现多个相同全限定类名的类,Java类型体系中基础的行为也无法保证,应用程序也将变得一片混乱。
JVM类加载器双亲委派机制可以防止核心API库被随意篡改,同时保证被加载类只会被加载一次,保证类的唯一性。
========== JVM运行时数据区的内存模型 ===============
========== JVM运行时数据区的内存模型:程序计数器 ===============
程序计数器可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的模型概念里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常、线程切换等都需要依赖计数器来完成;为了线程切换后能恢复到正确的执行位置,每个线程有独立的程序计数器;如果线程执行的是一个Java方法,这个计数器记录的时正在执行的虚拟机字节码指令的地址;如果正在执行的时native方法,这个计数器的值为空(undefined)。程序计数器是线程私有的,此内存区域时唯一一个在Java虚拟机规范中没有规定任何内存异常的区域。
========== JVM运行时数据区的内存模型:虚拟机栈 ===============
虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,虚拟机栈是线程私有的,它的生命周期和线程相同;每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程,而栈帧是方法调用和方法执行的基本结构,在活动线程中,只有栈顶的栈帧才有效,该方法为当前方法。在虚拟机规范中,这个区域规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常。
- 如果虚拟机栈可动态扩展,扩展时无法申请到足够多的内存,就会抛出OutOfMemoryError异常。
========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-局部变量表
局部变量表:定义为一个数字数组,主要存储方法参数和定义在方法体内的局部变量,这些数据包括基本数据类型、对象引用以及returnAddress类型;局部变量表所需的容量大小在编译期确定下来的,在方法运行期间不会改变局部变量表的大小的。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。由于局部变量表时建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
局部变量表容量单位为Slot(32位物理内存),JVM会为局部变量表中的每一个Slot都会分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值;如果当前栈帧是由构造方法或者实例方法创建,那么改对象引用this将会存放在index位0的Slot处,其余的参数按照参数表顺序继续排列。
局部变量表不存在系统初始化过程,这意味着一旦定义了局部变量表必须人为初始化,否则无法使用;在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量中直接或间接引用的对象都不会被回收。
========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-操作数栈
每一个栈帧都包含一个后入先出的栈,称为操作数栈。栈帧的操作数栈的最大深度在编译期时确定,并于栈帧相关的方法代码一起提供。执行方法时字节码是在操作数栈中完成内容写入和提取的,也就是出栈和入栈;
1. 求和操作在方法区中的执行流程
- 程序计数器:0,执行指令“bipush 15”将“int-15”压入操作数栈;
- 修改程序计数器:2,执行指令“istore 0”,将“int-15”出栈并放入“局部变量表”索引为“0”的位置;
- 修改程序计数器:3,执行指令“bipush 16”将“int-16”压入操作数栈;
- 修改程序计数器:5,执行指令“istore 1”,将“int-16”出栈并放入“局部变量表”索引为“1”的位置;
- 修改程序计数器:6,执行指令“iload 0”,将“int-15”从“局部变量表”索引为“0”的位置压入“操作数栈”;
- 修改程序计数器:7,执行指令“iload 1”,将“int-16”从“局部变量表”索引为“1”的位置压入“操作数栈”;
- 修改程序计数器:8,执行指令“iadd”,计算“15 + 16”并将结果“int-31”压入“操作数栈”;
- 修改程序计数器:9,执行指令“istore 2”,将结果“int-31”出栈并放入“局部变量包”索引为“2”的位置;
- 修改程序计数器:10,执行指令“iload 2”,将结果“int-31”从“局部变量表”索引为“2”的位置压入“操作数栈”;
========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-动态链接
每个栈帧包含对当前方法类型的运行时常量池的引用,运行时常量池的符号引用转化为直接引用称为动态链接。如果被调用的方法在编译器无法被确定下来,只能够在程序运行期将调用的方法的符号引用转换为直接引用,这种引用转换过程具备动态性,因此称为动态链接。对应的方法的绑定机制为:早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换成直接引用。
- 晚期绑定:如果被调用的方法在编译期无法被确定下来,只能在程序运行期根据实际类型绑定相关的方法,这种绑定方式称为晚期绑定。
随着高级语言的横空出世,类似于Java一样的基于面向对象的变成语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。Java中任何一个普通方法其实具备虚函数的特征,它们相当于C语言中的虚函数(C中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可使用关键字final来标记这个方法。
========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-方法返回地址
返回方法调用的位置,存放调用该方法的程序计数器/异常处理表。一个方法的结束,有两种方式:正常执行完成,出现未处理的异常,非正常退出。无论通过那种方式退出,在方法处处后都返回该方法被调用的位置。方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址;而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
- 执行引擎遇到一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;一个方法在正常调用完成之后,究竟需要使用哪个返回指令,和需要根据方法返回值的实际数据类型而定。
- 在方法执行过程遇到异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。方法执行过程中,抛出异常的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置程序计数器值等,让调用方继续执行下去。正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。
========== JVM运行时数据区的内存模型:虚拟机栈-栈帧-附加信息
========== JVM运行时数据区的内存模型:本地方法栈 ===============
除了由Java虚拟机定义并且在前面描述的所有运行时数据区域之外,正在运行的Java应用程序还可以使用由本机方法创建或用于本机方法的其他数据区域。当线程调用本机方法时,Java虚拟机的结构和安全限制不再阻碍其自由。本机方法可能会访问虚拟机的运行时数据区,但也可以执行任何其他操作。它可以使用本机处理器内的寄存器,在任意数量的本机堆上分配内存,或者使用任何类型的堆栈。本地方法本质上依赖于实现,实现设计者可以自由决定使用什么机制来使在其实现上运行的Java应用程序能够调用本机方法。
HotSpot直接将本地方法和虚拟机栈合二为一了,JVM规范允许本地方法栈具有固定大小或根据计算需要动态扩展和收缩,线程私有,如果线程中的计算需要比允许的更大的本机方法堆,Java虚拟机将抛出StackOverflowError;如果本机方法栈可以动态扩展,并且尝试了本机方法栈扩展,但内存不足,或者内存不足,无法为新线程创建初始本机方法堆叠,则Java虚拟机将抛出OutOfMemoryError。
========== JVM运行时数据区的内存模型:方法区 ===============
Java虚拟机有一个在所有Java虚拟机线程之间共享的方法区域,方法区是在虚拟机启动时创建的,尽管方法区在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾手机或压缩。方法区的内存不需要是连续的。在虚拟机实例中,有关加载类型的信息存储在称为方法区的内存逻辑区域中,其中包括:
- 类的完全限定名称
- 类的直接父类的完全限定名称
- 类是否是类或接口
- 类的修饰符
- 任何直接超级接口的完全限定名称的有序列表
========== JVM运行时数据区的内存模型:运行时常量池 ==========
运行时常量池是类文件中constant_pool表的每个类或每个接口的运行时表示。它包含多种常量,从编译时已知的数字文本到必须在运行时解析的方法和字段引用,在创建类或接口,如果构建运行时常量池所需的内存超过了Java虚拟机的方法区中可用的内存,则Java虚拟机将抛出OutOfMemoryError。
========== JVM运行时数据区的内存模型:堆 ===============
Java虚拟机有一个在所有Java虚拟机线程之间共享的堆。堆是运行时数据区,从中分配所有类实例和数组的内存。堆是在虚拟机启动时创建的。对象的堆存储有自动存储管理系统(垃圾收集器)回收;对象永远不会显式释放。堆内存可以是固定大小的,或者可以根据计算的需要进行扩展。堆内存不要是连续的。由于Java虚拟机实例中只有一个堆,所以所有线程都共享它,因此它不是线程安全的。
========== JVM运行时数据区的内存模型:堆-垃圾回收思想下的堆内存模型
为了提高内存分配效率和垃圾回收效率,JVM虚拟机采用分代思想对堆进行垃圾回收。所以根据对象的存活周期不同,JVM虚拟机将内存分为:
- 新生代/Eden区/Survivior区:新创建对象在新生代分配内存,大部分对象“朝生夕死”,存活时间较短,垃圾回收效率高,新生代采用“复制算法”对垃圾进行回收,所以新生代又分为Eden区和Survivior区,默认比例为8:1:1(可通过SurvivorRatio参数进行调节)。
- 老年代/Old区:新生代多次垃圾回收后存活下来的对象,生命周期较长,垃圾回收效率低,且回收速度比较慢,老年代采用“标记-整理”算法进行垃圾回收。
- 永久代/元空间/方法区:存放类信息、静态变量、常量、字面值常量、即时编译器编译后的代码和符号引用。可不进行垃圾回收。
- 新生代和老年代内存空间比例:1:2。
========== JVM运行时数据区的内存模型:新生代为什么需要Survivor空间?
如果没有Survivor空间的话,Eden空间进行一次GC后,就会将所有存活的对象全部晋升到老年代,即便在接下来几次GC过程极有可能被新生代垃圾回收器收集掉。这样老年代很快被填满,FullGC的频率大大增加,而老年代的空间要比新生代大很多,对它进行垃圾收集会消耗更长时间;如果老年代垃圾收集的频率很高的话,就会严重影响性能,基于这种考虑,虚拟机引进了Survivor空间。设置Survivor空间的目的是让哪些中等寿命的对象尽量在MinorGC是被收集屌,最终在整体上减少虚拟机垃圾收集过程堆用户的影响。
========== JVM运行时数据区的内存模型:新生代为什么需要两个Survivor区?
新生代一般采用“复制算法”进行垃圾收集,原始的复制算法是把内存一份为二,垃圾收集时把存活的对象从一块空间(From space)复制到另一块空间(To space),再把原先的那块内存(From space)清理干净,最后调换From space和To space的逻辑角色。
根据“复制算法”的特性,如果只设置一个Survivor区的话,所有“存活对象”会频繁的在这个Survivor区分配空间,这个Survivor区很快被填满,使得新生代不得不进行一次MinorGC,导致新生代垃圾收集频率升高,导致严重影响性能;而两个Survivor区,只会在Eden区块满的时候才触发MinorGC,而Eden区占新生代空间的绝大部分,所以MinorGC的频率得以降低。当然,使用2个Survivor区的方式也付出了一定的代价,如10%的空间浪费、复制对象的开销等。
========== JVM运行时数据区的内存模型:JVM在分代垃圾回收思想下的内存分配流程?
- JDK1.6之后,编译器通过逃逸分析确定对象是在栈上分配还是堆上分配,如果在堆上分配内存再执行第2步。
- 在Eden区加锁,如果eden_top + size(对象大小) <= eden_end,则将对象存放在Eden区,并增加eden_top,如果Eden区不足以存放该对象,则执行一次MinorGC。
- 经过多次MinorGC后,如果Eden区仍不足以存放该对象,则直接分配到老年代。
- 如果老年代不足以存放该对象,则执行FullGC。
- 如果执行完FullGC仍不足存放该对象,则抛出内存泄露异常。
========== JVM运行时数据区的内存模型:对象从新生代晋升到老年代有哪些方式?
- 对象年龄判断:每发生一次MinorGC,这个时候会把Eden区还存活的对象复制到S1区,这个时候从Eden区到S1区的对象,它们的年龄就开始计数了,等下次发生MinorGC的时候S1区存活的对象会复制到S2区,这个时候存活的对象年龄就会加1,虚拟机会将年龄为15的对象晋升到老年代。可以使用-XX:MaxTenuringThershold老设置多少次之后会进入老年代,默认是15次,且最大值也是15次。
- 动态年龄判断:当MinorGC之后,Survivor区存活的对象大小大于50%的时候会把部分对象直接复制到老年代;年龄1+年龄2+年龄N的对象年龄的总和超出S1区的50%,这个时候就会把大于等于年龄N的对象都放入老年代。
- 大对象直接进入老年代:在JVM参数里,-XX:PretenureSizeThreshold就是,当一个对象的大小超过多少的时候,就可以直接把该对象直接放入老年代,不用经过Survivor区。
- Eden区直接进入老年代:当MinorGC之后发现存活的对象没有办法放入Survivor区,JVM会把这些对象直接放入到老年代。
========== JVM运行时数据区的内存模型:老年代的空间分配担保机制?
在执行任何一次MinorGC之前,JVM会先检查老年代可用的内存空间,是否大于新生代所有对象的总和,因为再最极端的情况下,Minor GC过后,所有对象都存活下来,新生代所有对象会全部进入老年代,如果老年代的内存大小是大于新生代所有对象的内存大小的,此时据可以放心大胆的对新生代发起一次Minor GC了,因为即使Minor GC之后所有对象都存活了,Survivor区放不下了,也可以转移到老年代;如果老年代的内存大小小于新生代所有对象的内存,需要在MinorGC之前,需要结合参数“-XX:HandlePromotionFailure(是否允许担保失败)”来判断,这个参数,就是看看老年代的内存大小,是否大于之前每次Minor GC后进入老年代的对象的平均大小。
========== JVM虚拟机执行引擎 ==============
执行引擎是Java虚拟机核心组成部分之一,包括解释器、即时编译器、垃圾回收器,虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别于物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不收物理条件制约地定制指令集与执行引擎地结构体系,能够执行哪些不被硬件直接支持地指令集格式,JVM地主要任务是负责装在字节码到其内部,但字节码不能直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含地仅仅只是一些能够被JVM所识别的字节码指令、符号、以及其他辅助信息,那么,如果想让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。
========== JVM虚拟机执行引擎:解释器 ==============
当JVM启动的时候会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。解释器是真正意义上所承担的角色就是一个运行时“翻译者”,当一条字节码指令被翻译执行完成后,接着再根据PC寄存器中记录的下一条需要被执行字节码指令进行解释操作,JVM解释器一共有两套:
- 字节码解释器:字节码解释器在执行过程中通过纯软件代码模拟字节码执行,效率非常低。
- 模板解释器:将每一条字节码和一个模板函数关联,模板函数直接产生这条字节码指令执行时机器码,从而提高了解释器的性能。在常用的HotSpot VM中,解释器主要由Interpreter code模板构成,Interpreter模板实现了解释器的核心功能,code模板用于管理HotSpot VM在运行时生成的本地机器码指令。
由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言也是基于解释器执行的,比如Python、Perl、Ruby等,但是在今天,基于解释器执行已经沦为低效的代名词,为了解决这个问题,JVM平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将这个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以是执行效率大幅提升。
========== JVM虚拟机执行引擎:JIT编译器 ==============
JIT编译器是一种将源代码转换为本地机器代码并执行的程序。JIT编译器通过将一部分源代码编译成机器代码并缓存,从而提高程序的执行效率。它通常将程序分为多个编译单元,只编译被调用的部分,JIT技术常用于Java和.NET等平台,以提高程序的执行速度和性能。
========== JVM虚拟机执行引擎:JIT编译器分类&区别 ==============
JIT编译器可分为两种,分为C1和C2,在HotSpot JVM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下简称为C1编译器和C2编译器,可以通过以下命令显示执行Java虚拟机在运行时到底使用哪种即时编译器:
- -client:指定Java虚拟机运行在Client模式下,并使用C1编译器;C1编译器会对最解码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
- -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。
C1和C2编译器不同的优化策略,在不同的编译器上有不同的优化策略:
- C1编译器上主要有方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程;去虚拟化:对唯一的实现类进行内联;冗余消除:在运行期间把一些不会执行的代码折叠掉。
- C2编译器的优化主要在全局层面,主要有标记替换:用标量值代替聚合对象的属性;栈上分配:对于未逃逸的对象分配对象在栈而不是堆;同步消除:清除同步操作,通常指synchronized。
========== JVM虚拟机执行引擎:解释器还是JIT编译器? ==============
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与JIT编译器并存的架构。在Java虚拟机运行时,解释器和JIT编译器能够相互协作,各自取长补短,尽力选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待JIT编译器全部编译完在执行,这样可以省去许多不必要的编译时间。并且随着时间的推移,JIT编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
========== JVM虚拟机执行引擎:垃圾收集器-对象是否可以被回收 ==============
在堆中存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象中哪些还“存活”着,哪些已经“死去”。主要有以下两种方式:
- 引用计数器:给对象添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能在被使用的,这种算法的实现简单,判断效率高,但Java虚拟机并没有选用引用计数算法,其中最主要的原因是它无法解决对象之间循环引用的问题。
- 可达性分析:在主流的商用程序语言的主流实现都是通过可达性分析来判断对象是否存活。这个算法的基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用。可作为GC Roots对象包括:虚拟机中的引用对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象;但是就算在可达性分析算法中的不可达对象,也并非是“非死不可”,要真正宣布一个对象的死亡,至少要经历两次标记过程:如果一个对象在进行可达性分析后发现没有与“GC Roots”相连接的引用链,那么它将被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalizer() 方法,当对象没有覆盖finalizer() 方法,或者finalizer() 方法已经被虚拟机调用过,虚拟机将这两种情况是为“没有必要执行”。finalizer() 方法是对象逃脱死亡命运的最后机会,稍后GC将对F-Queue中对象进行第二次小规模标记,如果对象想在finalizer() 方法中成功拯救自己——只要重新与引用链上的任何一个对象建立关系即可,譬如把自己赋值给某个类变量或者对象的成员变量。那么第二次标记时它将会被移除“即将回收”的集合;如果对象没有逃脱,那么基本上它就真的被回收了。
========== JVM虚拟机执行引擎:垃圾收集器-垃圾收集算法及实现原理
- 标记-清除算法:该算法分为标记阶段和清除阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象,该算法有两个缺点:一个是效率问题,标记和清除两个过程效率不高;另一个是空间问题,标记清除之后,会产生大量的不连续的内存空间,内存空间碎片过多,可能导致在以后的程序运行过程中需要分配大对象时,无法找到足够的连续内存,而不得不提前触发一次垃圾回收动作。
- 复制算法:为了解决效率问题,一种称为“复制”的回收算法出现了,它将可用的内存按照容量划分为大小相等的两块,每次只使用其中一块,当被使用的内存用完了,就将仍存活的对象复制到另外一块未使用的内存上面,然后再把已使用的内存空间一次性清理掉,这样使得每次都对整个半区进行垃圾收集,内存分配时不需要考虑内存碎片问题,只要移动堆顶的指针,按顺序分配内存即可,但这种算法的代价是将内存缩小为原来的一半,代价太高了一点。该算法一般适用于“新生代”内存的垃圾收集。
- 标记-整理:复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用内存中所有对象都100%存活的极端情况,所以在老年代一不安不能直接选用这种算法;根据老年代的特点,提出了另外一种“标记-整理”算法,标记过程和“标记-清除”算法一样,但是“清除”过程不是直接回收对象进行清理,而是让所有存活对象都想一端移动,然后直接对边界以外的内存进行垃圾回收,该算法一般适用于“老年代”的垃圾收集。
========== JVM虚拟机执行引擎:垃圾收集器-分类及实现原理 ==============
- 新生代垃圾收集器:
1.1 Serial垃圾收集器:采用“复制”算法,曾经是“新生代”垃圾收集器的唯一选择,这个垃圾收集器是一个单线程收集器,单线程不仅意味着它只会使用一个一个CPU或一个线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有工作线程,直到垃圾收集结束;虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,没有线程交互开销,可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器。
1.2 ParNew垃圾收集器:采用“复制”算法,其实就是Serial垃圾收集器的多线程版本,它是许多运行在Server模式下的虚拟机首选的新生代垃圾收集器,其中一个与性能无关的重要原因是,除了Serial垃圾收集器之外,目前只有它能与CMS垃圾回收器配合工作。
1.3 Parallel Scavenge垃圾收集器:采用“复制”算法,并行的多线程垃圾收集器,该收集器的特点是:可以形成一个可控的吞吐量(吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)),吞吐量越高,停顿时间越短,越适合交互性强的程序,良好的响应速度能提高用户体验,同时高吞吐量可以高效的利用CPU时间,尽快完成程序的运行任务,所以Parallel Scavenge垃圾收集器经常称为“吞吐量优先”的垃圾收集器。- 老年代垃圾收集器:
2.1 Serial Old垃圾收集器:采用“标记-整理”算法,Serial垃圾收集器的老年代版本,主要意义在于给Client模式下的虚拟器使用,进行老年代的垃圾收集,它主要有两大用途:一个是在JDK1.5之前与Parallel Scavenge垃圾收集器搭配使用,另一个用途是CMS垃圾收集器并发收集失败时,提供后备预案。
2.2 Parallel Old垃圾收集器:采用“标记-整理”算法,Parallel Scavenge垃圾收集器的老年代版本。
2.3 CMS垃圾收集器:采用“标记-整理”算法,是一种以活动最短停顿时间为目标的收集器,目前很大一部分Java应用集中在互联网上或者B/S架构的服务器端,这类应用尤其终时服务器的响应速度,希望系统停顿时间最短,以给用户较好的体验。- G1垃圾收集器:在G1垃圾收集器之前的其他垃圾收集器的垃圾收集范围时整个新生代或老年代,而G1垃圾收集器不再是这样,使用G1收集器时,Java堆的内存布局和其他垃圾收集器有很大差别,它整个堆分为多个大小相等的独立区域,虽然还保存者新生代和老年代的概念,但新生代和老年代不再是物理隔离了,它们都是Region的集合,所以G1垃圾收集器对Region采用的是“复制”算法,而对于整个堆内存采用的是“标记-整理”算法。