一文汇总JVM所有知识点(一)
平时看博客或者学知识,学到的东西比较零散,没有独立的知识模块概念,而且学了之后很容易忘。于是我建立了一个自己的笔记仓库 (一个我长期维护的笔记仓库,感兴趣的可以点个star~你的star是我写作的巨大大大大的动力),将平时学到的东西都归类然后放里面,需要的时候呢也方便复习。
1. JVM内存数据区域
1.1 内存数据区域
下面这些都是Java虚拟机规范,不是虚拟机具体实现
JVM内存可以划分为若干个不同的数据区域: 程序计数器,虚拟机栈,本地方法栈,堆,方法区
1.1.1 程序计数器
程序计数器占用的内存空间比较小,可以看做是当前线程所指向的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支操作、循环操作、跳转、异常处理等也都需要依赖程序计数器。
- 在Java虚拟机规范中,程序计数器没有规定OutOfMemoryError的情况;
- 程序计数器是线程私有的,每个线程内部都有一个私有的程序计数器。它的生命周期是和线程的生命周期是同步的;
- 当一个线程正在执行一个Java方法的时候,这个程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法,则计数器值为空(Undefined)
1.1.2 虚拟机栈
虚拟机栈也是线程私有,生命周期与线程同步。在Java虚拟机规范中,虚拟机栈有两种异常情况:
- StackOverflowError 当线程请求栈深度超出虚拟机栈所允许的深度时抛出
- OutOfMemoryError 当Java虚拟机动态扩展到无法申请足够内存时抛出
JVM是基于栈的解释器执行的,DVM是基于寄存器解释器执行的。
上面这个栈就是指的虚拟机栈,虚拟机栈的初衷是用来描述Java方法执行的内存模型,每个方法被执行的时候,JVM都会在虚拟机栈中创建一个栈帧。
1.1.2.1 栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,每个线程在执行某个方法时,都会为这个方法创建一个栈帧。 一个线程包含多个栈帧,而每个栈帧内部包含:局部变量表、操作数栈、动态链接、返回地址等。
局部变量表
局部变量表是变量值的存储空间,调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在Java编译成class文件的时候,就会在方法的Code属性表中的max_locals
数据项中,确定该方法需要分配的最大局部变量表的容量。
系统不会为局部变量赋予初始值
操作数栈
操作数栈也常称为操作栈,它是一个后入先出栈(LIFO)。
同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的mac_stacks
数据项中。栈中的元素可以是任意Java数据类型,包括long和double。
方法执行过程中,会将各种字节码指令压入和弹出操作数栈。
动态链接
动态链接的主要目的是为了支持方法调用过程中的动态链接。
返回地址
一个方法开始,只有两种方式可以退出这个方法:
- 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常
- 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法提出。
不管是什么方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。虚拟机栈中的返回地址就是用来帮助当前方法恢复它的上层方法执行状态。
1.1.3 本地方法栈
本地方法栈和虚拟机栈基本是相同的,只不过是针对native方法。在有些虚拟机中已经将两个合二为一了(如HotSpot)。
1.1.4 堆
Java堆是JVM所管理的内存中最大的一块,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是Java垃圾收集器(GC)管理的主要区域,有时也叫GC堆。同时它也是所有线程共享的内存区域,因此被动态分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。
按照对象存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为Eden和Survivor区。不同区域的对象生命周期不同,这样可以使用不同的垃圾回收算法,具有针对性,垃圾回收效率更高。
1.1.5 方法区
方法区:主要存储已经被JVM加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域和堆一样,也是被各个线程共享的内存区域。
运行时常量池也是方法区中的,它用于存放编译期生成的各种字面量与符号引用。
2. HotSpot虚拟机对象
对象的创建、如何布局以及如何访问这种细节问题,必须把讨论范围限定在具体虚拟机才有意义。下面以最常用的HotSpot虚拟机和最常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
下文中讨论的对象不包括数组和Class对象
2.1 对象的创建
当Java虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都放在一边,空闲的内存被放在另一边,中间防着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。 但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。Java堆是否规则是由所采用的垃圾收集器是否带有空间压缩整理的能力决定。
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有2种方案:
- 对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。
内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。接下来,虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的GC分代年龄信息等。
上面的工作完成之后,从虚拟机视角来看,一个新的对象已经产生了。但从Java程序的视角来看,对象创建还需要经历构造函数(即Class文件中的<init>()方法
)的流程。
2.2 对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充。
对象头:包含两类信息,第一类:用于存储自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。官方称它为Mark Word;第二类:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。此外,如果对象是Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据:对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充:并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,意思是任何对象的大小都必须是8字节的整数倍。
2.3 对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式取决于虚拟机实现而定。目前主流的访问方式有使用句柄和直接指针两种。
使用句柄:Java堆中将会划分出一块内容作为句柄池,reference中存储的就是对象的句柄池地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针:reference中存储的直接就是对象地址。
两种方式各有优势,使用句柄的最大好处就是reference中存储的就是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。使用直接指针的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot也是使用的直接指针的方式进行对象访问。但是整个生态来看使用句柄来访问的情况也十分常见。
2.4 Interview
2.4.1 描述new一个对象的过程
Java中对象的创建过程包括 类初始化 和 类实例化 两个阶段。而new只是创建对象的一种方式和时机。当执行到new的字节码指令的时候,会先判断这个类是否已经初始化,如果没有初始化就要进行类的初始化。
- 类的初始化:是类的生命周期的一个阶段,会为类中各个类成员赋初始值
- 类的实例化:是指创建一个类的实例的过程
但是在类的初始化之前,JVM会保证类的装载,链接(验证、准备、解析)四个阶段都已经完成。
- 装载是指Java虚拟机查找
.class
文件并生成字节流,然后根据字节流创建java.lang.Class对象的过程 - 链接:验证创建的类,并将其解析到JVM中使之能够被JVM执行
那类加载的时机是什么时候?JVM没有规范何时具体执行,不同虚拟机实现有点不同,常见情况如下:
- 隐式装载:在程序运行过程中,当碰到通过new等方式生成对象时,系统会隐式调用ClassLoader去装载对应的class到内存中
- 显示装载:在编写代码时,主动调用Class.forName()等方法也会进行class装载操作,这种方式称为显示装载
到这里大的流程框架就搞清楚了:
- 当JVM碰到new字节码的时候,会先判断类是否已经初始化,如果没有初始化(有可能类还没有加载,如果是隐式装载,此时应该还没有类加载,就会先进行装载、验证、准备、解析四个阶段),然后进行类初始化
- 如果已经初始化过了,就直接开始类对象的实例化工作,这时候会调用类对象的<init>方法
初始化的执行流程:
- 父类静态变量和静态代码块
- 子类静态变量和静态代码块
- 父类普通成员变量和普通代码块
- 父类的构造函数
- 子类普通成员变量和普通代码块
- 子类的构造函数
2.4.2 类初始化的触发时机
在同一个类加载器下,一个类型只会被初始化一次,刚才说到new对象是类初始化的一个判断时机,其实一共有六种能够触发类初始化的时机:
- 虚拟机启动时,初始化包含main方法的主类
- 遇到new等指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作
- 当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作
- 子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 使用反射API进行反射调用时,如果类没有进行过初始化则需要先触发其初始化
- 第一次调用
java.lang.MethodHandle
实例时,需要初始化MethodHandle指向方法所在的类
2.4.3 多线程进行类的初始化会出问题吗?
不会,<clinit>()
方法是阻塞的,在多线程环境下,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
,其他线程都会被阻塞。
2.4.4 类的实例化触发时机
- 使用new关键字创建对象
- 使用Class类的newInstance方法,Constructor类的newInstance方法
- 使用clone方法创建对象
- 使用(反)序列化机制创建对象
2.4.5 <clinit>()
方法和<init>()
方法区别
-
<clinit>()
方法发生在类初始化阶段,会执行类中的静态类变量的初始化和静态代码块中的逻辑,执行顺序就是语句在源文件中出现的顺序 -
<init>()
方法发生在类实例化阶段,是默认的构造函数,会执行普通成员变量的初始化和普通代码块的逻辑,执行顺序就是语句在源文件中出现的顺序
2.4.6 类没有初始化完毕之前,能直接进行实例化相应的对象吗?
是可以的,类静态变量是自己的一个实例的情况。
public class Run {
public static void main(String[] args) {
new Person2();
}
}
public class Person2 {
public static int value1 = 100;
public static final int value2 = 200;
public static Person2 p = new Person2();
public int value4 = 400;
static{
value1 = 101;
System.out.println("1");
}
{
value1 = 102;
System.out.println("2");
}
public Person2(){
value1 = 103;
System.out.println("3");
}
}
先初始化静态变量,然后初始化普通成员变量和普通代码块,最后是构造函数。 所以这里在初始化过程中就进行了实例化。
所以,实例化不一定要在初始化结束之后才开始初始化,有可能在初始化过程中就进行了实例化。
2.4.7 类的初始化过程与类的实例化过程的异同
- 类的初始化,是指在类装载,链接之后的一个阶段,会执行
<clinit>()
方法,初始化静态变量,执行静态代码块等 - 类的实例化,是指在类完全加载到内存中后创建对象的过程,会执行
<init>()
方法,初始化普通变量,调用普通代码块
2.4.8 一个实例变量在对象初始化的过程中最多可以被赋值几次
- 对象被创建的时候,分配内存会把实例变量赋予默认值,这是肯定会发生的
- 实例变量本身初始化的时候,就给它赋值一次
- 初始化代码块的时候,也赋值一次
- 构造函数中,再赋值一次
可以是四次,看下示例代码
public class Person3 {
public int value1 = 100;
{
value1 = 102;
System.out.println("2");
}
public Person3(){
value1 = 103;
System.out.println("3");
}
}
3. 垃圾收集器与内存分配策略
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。-《深入理解Java虚拟机》
3.1 什么是垃圾
程序计数器、虚拟机栈、本地方法栈这3个区域生命周期是和线程同步的,所以不用过多考虑回收问题。
而Java堆和方法区则有着明显的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所指向的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
垃圾就是内存中已经没用的对象。Java虚拟机使用可达性分析算法来决定哪些对象是垃圾,是否可以被回收。
3.2 对象是否已经死了?
垃圾收集器在堆进行回收前,需要判断对象是否不被使用了。有以下2种方式:
1.引用计数法
给对象添加一个引用计数器,每当有一个地方引用时,计数器值加一。当引用失效时,计数器值减一;任何时刻计数器为零的对象就是不可能再被使用的。 引用计数法实现简单,判断效率高,但是Java虚拟机里面没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
2.可达性分析算法
可达性分析算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当有一个对象到GC Roots没有任何引用链相连,即不可达,则证明此对象是不可用的。
可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
- 方法区中常量引用的对象,比如字符串常量池(String Table)里的引用
- 本地方法栈JNI(Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NPE,OOM)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
3.3 什么时候回收垃圾
不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。
- Allocation Failure : 在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次GC
- System.gc(): 在应用层,可以主动调用此API来建议虚拟机执行一次GC。
3.4 再谈引用
3.4.1 强引用
如果一个对象具有强引用,那垃圾收集器不会回收它。指在程序代码之中普遍存在的引用赋值,即类似“Objectobj=new Object()”这种引用关系。
3.4.2 软引用
在内存实在不足时,会对软引用进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用
3.4.3 弱引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一个垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
3.4.4 虚引用
一个对象是否有虚引用的存在,完全不会对齐生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
3.5 垃圾收集算法
3.5.1 标记-清除算法
标记之后原地清除。
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
- 效率问题:标记和清除两个过程的效率都不高
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致无法分配较大对象而不得不提前触发另一次垃圾收集动作
3.5.2 标记-复制算法
平时只用一半空间,需要回收时,将存活的全部复制到另一半空间,将之前的一半空间全部清除。
标记-复制算法也称为复制算法。
为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象复制到另外一块,然后将已使用那块内存空间一次清理掉。实现简单,运行高效。但是这种算法的代价是可使用内存缩小为原来的一半。
现在虚拟机都采用复制算法来回收新生代。按照历史经验,新生代的对象98%的对象都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden区和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当然,如果Survivor空间装不下时,需要依赖其他内存(一般是老年代)进行分配担保。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是只有10%的内存会浪费。
复制算法在对象存活率比较高的时候是非常低效的,更关键的是,如果不想浪费50%的内存空间,就要有额外的空间进行分配担保,所以老年代一般不会选用复制算法。
3.5.3 标记-整理算法
标记之后,将对象全部复制到空间的一边,将复制之后占用内存的边界之外的空间全部清理。
和标记清除算法的标记过程一直,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
3.6 HotSpot的算法实现细节
3.6.1 枚举根节点
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。
确保一致性的快照:这项分析工作必须在一个能确保一致性的快照中进行-在整个分析期间整个指向系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。
使用OopMap标记对象引用:在HotSpot中,使用一组OopMap的数据结构来标记对象引用的位置。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。
3.6.2 安全点(Safepoint)
什么是安全点:导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。实际上,HotSpot也的确没有为每条指令都生成OopMap,指数在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增加运行时的负荷。
如何选择安全点:安全点的选定是以“是否具有让程序长时间执行的特性”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
在安全点暂停的方式:抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
3.6.3 安全区 (Safe Region)
当程序不执行的时候(比如sleep状态)就不能到达安全点,对于这种情况就需要安全区域来解决。安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的安全点。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
3.6.4 记忆集与卡表
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
卡表:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。以这种方式实现记忆集,这也是目前最常用的一种记忆集实现形式。
3.7 垃圾收集器
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。为什么有那么多的垃圾收集器:因为场景不同。
3.7.1 Serial收集器
Serial收集器是一个单线程工作的收集器,它进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束。迄今为止,使用非常广泛(客户端模式默认新生代的收集器),它简单而高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
3.7.2 ParNew收集器
ParNew收集器是Serial收集器的多线程版本,它是运行在不少服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器。除了Serial收集器收集器外,目前只有它能与CMS收集器配合工作。
CMS收集器是JDK 5发布时推出的,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
3.7.3 Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。
3.7.4 Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
3.7.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
3.7.6 CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。关注服务的响应速度,则CMS刚好。CMS是基于标记-清除算法实现的。CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿。
3.7.7 Garbage First收集器
Garbage First收集器,简称G1,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。JDK 9发布之日,G1宣告取代Parallel Scavenge加ParallelOld组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。G1基于Region堆内存布局,虽然G1也仍是遵循分代收集理论设计的,但其对内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代。收集器根据Region的不同角色采用不同的策略去处理。G1会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些Region区,也就是垃圾最大的Region区,这就是Garbage First名字的由来。
G1收集器的运作过程可划分为以下四个步骤:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。需停顿线程,但耗时很短。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期待的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。
3.8 内存分配与回收策略
对于内存分配,大方向上就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲区,将按线程优先在TLAB上分配。少数情况下也可以直接分配在老年代。
Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是JVM的内存分代策略。在HotSpot中除了新生代和老年代,还有永久代。
分代回收的中心思想:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下下来,则将它们转移到老年代中。
3.8.1 年轻代(Young Generation)
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法就是复制算法。
新生代又可以继续细分为3部分:Eden、Survivor0、Survivor1。这3部分按照8:1:1的比例来划分新生代。
image大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
- 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快
- 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现Major GC,经常会伴随至少一次Minor GC,Major GC的速度一般会比Minor GC慢10倍以上
3.8.2 老年代(Old Generation)
一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。
如果对象比较大(比如字符串或者大数组),并且新生代的剩余空间不足,则这个大对象直接被分配到老年代上。我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记整理的回收算法。
长期存活的对象将进入老年代:既然虚拟机采用了分代收集的思想来管理内存,那么内存回收就必须能识别哪些对象应该放在新生代还是老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区每熬过一次Minor GC,年龄就会增加一岁。当它的年龄增加到一定程度,默认是15,就将会被晋升到老年代中。
对于老年代可能存在一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代维护了一个512byte的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发送GC时,只需要检查这个card table即可,大大提高了性能。
4. Java字节码(class文件)解读
以前写过一篇Java字节码解读
5. 字节码指令简介
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(操作数)构成。
5.1 字节码与数据类型
在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。比如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。
编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据,将boolean和char类型数据零位扩展为相应的int类型数据。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型来进行的。
5.2 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。这些指令如下:
- 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>
- 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
- 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
- 扩充局部变量表的访问索引的指令:wide
5.3 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor ·按位与指令:iand、land
- 按位异或指令:ixor、lxor ·局部变量自增指令:iinc
- 比较指令:dcmp g、dcmp l、fcmp g、fcmp l、lcmp
5.4 类型转换指令
类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码的显示类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java虚拟机直接支持以下数值类型的宽化类型转换:
- int类型到long、float或者double类型
- long类型到float、double类型
- float类型到double类型
相对的,处理窄化类型转换时,就必须显示地使用转换指令来完成:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f
在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单丢弃除最低位N字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号(把前面的符号位给舍去了)。
5.5 对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素。
- 创建类实例的指令:new
- 创建数组的指令:new array 、anew array 、mult ianew array
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的 指令:getfield、putfield、getstatic、putstatic
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、 daload、aaload
- 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、 dastore、aastore
- 取数组长度的指令:array lengt h - 检查类实例类型的指令:inst anceof、checkcast
5.6 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令:
- 将操作数栈的栈顶一个或两个元素出栈:p op 、p op 2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup 、dup 2、dup _x1、 dup 2_x1、dup _x2、dup 2_x2
- 将栈最顶端的两个数值互换:swap
5.7 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret
与前面的算术运算的规则一致,对于boolean、byte、char和short类型的条件分支比较操作,都使用int类型的比较指令完成,而对于long、float和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,预算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。因此,各种类型的比较最终都会转换为int类型的比较操作。
5.8 方法调用和返回指令
方法调用:分派、执行过程。
- invokevirt ual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派), 这也是Java语言中最常见的方法分派方式。
- invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找 出适合的方法进行调用。
- invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和 父类方法。
- invokestatic指令:用于调用类静态方法(static方法)。
- invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面 四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedy namic指令的分派逻辑 是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
5.9 异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常的情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状态时自动抛出。例如整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成。
5.10 同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来实现的。
方法级的同步是隐式的,无法通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED
访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要javac编译器与Java虚拟机两者共同协作支持。
6. 虚拟机类加载机制
Java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
6.1 类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接。
类的生命周期关于在什么情况下需要开始类加载过程的第一个阶段“加载”,Java虚拟机规范中并没有进行强制约束。 但是对于初始化阶段,严格规范了只有下面六种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能生成这四条指令的典型Java代码场景:
- 使用new关键字实例化对象的时候
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入调用处的那个类的常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为
REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化 - 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
6.2 类加载的过程
Java虚拟机中类加载的全过程:加载、验证、准备、解析和初始化。
6.2.1 加载
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
6.2.2 验证
验证是连接阶段的第一步,目的是确保class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会抛出异常、拒绝编译。但是,这些无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。
验证阶段大致会完成四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
6.2.2.1 1.文件格式验证
第一阶段验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理。 该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个Java类型信息的要求。通过这个阶段验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
- 是否以魔数0xCAFEBABE开头
- 主、次版本号是否在当前Java虚拟机接受范围之内
- 常量池的常量中是否有不被支持的常量类型(检查常量t ag标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
- ......
6.2.2.2 2. 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范。 主要目的是对类的元数据信息进行语义校验。
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方 法重载,例如方法参数都一致,但返回值类型却不同等)
- ......
6.2.2.3 3. 字节码验证
第三阶段主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体(class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。 但即使一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。
在JDK6之后的javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名为StackMapTable的新属性,这项属性描述了方法体所有的基本块(指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序退到这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作 栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- ......
6.2.2.4 4. 符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作将在连接的第三阶段-解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError
的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchM ethodError等。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当 前类访问
- ......
6.2.3 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
public static int value = 123;
变量value在准备阶段过后的初始值是0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器<clinit>()
方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。
如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:
public static final int value = 123;
编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
6.2.4 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在class文件中它以CONSTANT_Class_info
、 CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
等类型的常量出现。
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能被无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的class文件格式中
- 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符”,这里的动态的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是静态的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
6.2.5 初始化
类的初始化是类加载过程的最后一个步骤,Java虚拟机开始执行类中编写的Java代码,将主导权移交给应用程序。
进行准备阶段时,变量移交赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器<clinit>()
方法的过程。<clinit>()
并不是程序员在Java代码中直接编写的,它是javac编译器的自动生成物。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
代码块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
<clinit>()
方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()
方法)不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的<clinit>()
方法执行前父类的<clinit>()
方法以及执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object。
由于父类的<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
<clinit>()
方法对于类或接口来说并不是必需的,一个类中没有静态语句块,也就没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。
Java虚拟机必须保证一个类的<clinit>()
方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()
方法。可以利用这条特性搞单例。如果一个类的<clinit>()
方法中有耗时很长的操作,那就可能造成多个线程阻塞。
6.3 类加载器
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何获取所需的类。实现这个动作的代码称为“类加载器”。
同一个Java虚拟机,用不同的类加载器加载同一个class文件,那加载出来的这2个类必定是不相等(包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括instanceof。)的。
6.3.1 双亲委派模型
本节内容针对的是JDK 8及之前版本的Java来介绍的三层类加载器和双亲委派模型
站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器是虚拟机的一部分;另外一种就是其他所有的类加载器,这些类加载器独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。
- 启动类加载器(Bootstrap Class Loader): 这个类加载器负责加载存放在
<JAVA HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径所指定的路径中存放的,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中 - 扩展类加载器(Extension Class Loader):是在类
sun.misc。Launcher$ExtClassLoader
中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库 - 应用程序类加载器(Application Class Loader):由
sun.misc.Launcher$AppClassLoader
来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
类加载器双亲委派模型:
类加载器双亲委派模型双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会去尝试自己去完成加载。
为什么需要双亲委派模型?它的好处是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object
,它存放在rt.jar之中,无论哪一个类要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object
的类,并放在程序的CLassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序将会变得一片混乱。
双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现非常简单。用以实现双亲委派模型的代码只有10行左右,全部在java.lang.ClassLoader
的loadClass()方法之中。
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
//首先,检查请求的类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//如果父类加载器抛出ClassNotFoundException说明父类加载器无法完成加载请求
}
if (c == null) {
//在父类加载器无法加载时,再调用本身的findClass()方法来进行类加载
c = findClass(name);
}
}
return c;
}
核心逻辑:先检查请求加载的类型是否已经被加载过,如果没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。
7. 虚拟机字节码执行引擎
7.1 概述
执行引擎是Java虚拟机核心的组成部分之一,虚拟机是一个相对于物理机的概念,这两种机器都有代码执行的能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集合操作系统层面上的,而虚拟机的执行引擎是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件支持的指令集格式。
在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。
7.2 运行时栈帧结构
Java虚拟机以方法作为基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,与这个栈帧所关联的方法被称为当前方法。执行引擎所允许的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结果如图:
image7.2.1 局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为class文件时,就在方法的Code属性的max_locals
数据项中确定了该方法所需分配的局部变量表的最大容量。 局部变量表的容量以变量槽为最小单位。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言中明确的64位的数据类型只有long和double两种。
如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
7.2.2 操作数栈
操作数栈也常被称为操作栈,它是一个后入先出(LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks
数据项之中。javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks
数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
Java虚拟机的解释执行引擎被称为基于栈的执行引擎,里面的栈就是操作数栈。
7.2.3 动态连接
符合引用和直接引用在运行时进行解析和连接的过程,叫动态连接。一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,需要知道其名字。符号引用就相当于名字,这些被调用者的名字就存放在java字节码文件里。名字知道了之后,Java程序运行起来的时候,就得靠这个名字(符号引用)找到相应的类和方法。这时就需要解析成相应的直接引用,利用直接引用来准确地找到。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转换被称为静态解析。另外一部分将在每一次运行期间都转换为直接引用,这部分就称为动态连接。
7.2.4 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成”。
无论采用何种方式退出,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
7.3 方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一。但之前说过,class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
7.3.1 解析
所有方法调用的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都是适合在类加载阶段进行解析。
调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令:
- invokestatic:用于调用静态方法
- invokespecial:调用实例构造器
<init>()
方法、私有方法和父类中的方法 - invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时再确定一个实现该接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分配逻辑是由用户设定的引导方法来决定的
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法,与之相反,其他方法被称为虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。
7.3.2 分派
本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如重载和重写在Java虚拟机之中是如何实现的?这里的实现当然不是语法上该如何写,而是虚拟机是如何正确确定目标方法的。
7.3.2.1 静态分派
先来看一段代码:
/**
* 方法静态分派演示
*/
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
//idea 在还没运行的时候就看出这个方法会被调用 而下面两个方法则没人使用,建议我安全删除
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
上面代码中的Human是静态类型(或者叫外观类型),后面的Man则被称为变量的实际类型(或者叫运行时类型)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
//实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
//静态类型变化 在编译期完全可以明确转型的是Man还是Woman
sr.sayHello((Man)human)
sr.sayHello((Woman)human)
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
重载方法匹配优先级:
public class OverLoad {
public static void main(String[] args) {
sayHello('c');
}
public static void sayHello(char c) {
System.out.println("hello char");
}
public static void sayHello(int i) {
System.out.println("hello int");
}
public static void sayHello(long l) {
System.out.println("hello long");
}
public static void sayHello(float f) {
System.out.println("hello float");
}
public static void sayHello(double d) {
System.out.println("hello double");
}
public static void sayHello(Serializable s) {
System.out.println("hello serializable");
}
public static void sayHello(Object o) {
System.out.println("hello object");
}
public static void sayHello(char... chars) {
System.out.println("hello chars");
}
}
上面这些方法都能匹配上,但是是有优先级的,依次是char > int > long > float > double > Serializable > Object > 可变长参数
char不会去匹配byte和short类型的重载,因为char转型到byte或short是不安全的,其次,可变长参数的重载优先级是最低的。
7.3.2.2 动态分派
动态分派与多态性的另外一个重要体现--重写有着很密切的关联。
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
//运行结果
man say hello
woman say hello
woman say hello
Java虚拟机是如何判断应该调用哪个方法的: 对应于invokevirtual指令的多态查找过程,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈栈顶的第一个元素所指向的对象的实际类型,记作C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回
java.lang.IllegalAccessError
异常 - 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常
看下上面代码的main()方法字节码
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
7: astore_1
8: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
24: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
36: return
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令会把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
字段则永远不参与多态,当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
7.3.2.3 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
Java语言是一门静态多分派、动态单分派的语言。
7.3.2.4 虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而常见的优化手段是为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
7.4 动态类型语言支持
JDK7新增了一条字节码指令:invokedynamic。这条增加的指令是为了实现动态类型语言支持而进行的改进之一。
7.4.1 动态类型语言
动态类型语言是什么?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的。
在编译期就进行类型检查过程的语言,如C++、Java就是最常用的静态类型语言。
静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易到达更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,意味着开发效率的提升。
7.4.2 Java与动态类型
JDK7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用方法的符号引用。但方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。 这可咋整?
于是invokedynamic指令以及java.lang.invoke
包出现了。
7.4.3 java.lang.invoke包
JDK 7新加入的java.lang.invoke包提供了一种新的动态确定目标方法的机制,称为方法句柄。有点像C++的函数指针。
在拥有方法句柄之后,Java语言也可以拥有类似于函数指针的方法别名这样的工具了。
在Java代码里面是使用MethodHandle来实现。MethodHandle在使用方法和效果上与Reflection有点像。但也有点区别:
- Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用
- Reflection中的
java.lang.reflect.Method
对象远比MethodHandle机制中的java.lang.invoke.MethodHandle
对象所包含的信息来的多 - 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似的思路去支持(还在完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施
7.4.4 invokedynamic指令
某种意义上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户有更高的自由度。
每一处含有invokedynamic指令的位置都被称作动态调用点,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info
常量,而是变为JDK 7时新加入的CONSTANT_InvokeDynamic_info
常量,从这个新常量中可以得到3项信息:引导方法、方法类型和名称。
7.5 基于栈的字节码解释执行引擎
虚拟机是如何执行方法里面的字节码指令的?
许多虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译器执行(通过即时编译器产生本地代码执行)两种选择。 本节中,分析的是概念模型下的Java虚拟机解释执行字节码时,其执行引擎是如何工作的。
7.5.1 解释执行
不论是解释还是编译,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤,中间那条分支,就是解释执行的过程,而最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程。
imageJava大体上会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。
在Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
7.5.2 基于栈的指令集与基于寄存器的指令集
javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构。字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。 与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的是x86的二地址指令集,现在主流PC机中物理硬件直接支持的指令集就是x86,这些指令依赖寄存器进行工作。
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果
放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。
java字节码指令流的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。
基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接用到这些寄存器,那就可以由虚拟机实现来自行决定吧一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更简单一些。 栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。
在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了大量的指令。更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢上一点。
资料
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
- 一网打尽“类”的初始化实例化知识点
- Android 工程师进阶 34 讲
- Java中的语法糖
- JVM相关知识