公司进门形象墙图片/苏州seo整站优化
文章目录
- 一、类文件结构
- 1.1 Class文件格式
- 1.2 Class文件内容
- 1.2.1 魔数与Class文件的版本
- 1.2.2 常量池
- 1.2.3 访问标志
- 1.2.4 类索引、父类索引与接口索引集合
- 1.2.5 字段表集合
- 1.2.6 方法表集合
- 1.2.7 属性表集合
- 二、类加载机制
- 2.1 初识类加载
- 2.1.1 类加载的生命周期
- 2.1.2 主动引用和被动引用
- 2.2 类加载详细过程
- 2.2.1 加载
- 2.2.2 验证
- 2.2.3 准备
- 2.2.4 解析
- 2.2.5 初始化
- 2.3 类加载器
- 2.3.1 类与类加载器
- 2.3.2 四种类加载器
- 2.3.3 双亲委派模型
- 2.3.4 类加载器的特点
- 2.3.5 类加载器的隔离问题
- 2.3.6 自定义类加载器
- 2.4 隐式装载和显式装载
- 2.5 对象初始化的先后顺序
- 2.5.1 不同对象初始化的先后顺序
- 2.5.2 对象初始化的例子
- 2.6 类加载的相关问题
- 2.6.1 Java虚拟机是如何判定两个Java类是相同的?
- 2.6.2 有哪些打破了双亲委托机制的案例?
- 三、字节码执行引擎
- 3.1 栈帧结构
- 3.2 方法调用
- 3.2.1 解析
- 3.2.2 分派
- 3.3 基于栈的字节码解释执行引擎
- 3.3.1 解释执行
- 3.3.2 基于栈的指令集和基于寄存器的指令
- 四、程序编译与代码优化
- 4.1 字节码的编译过程(前端编译器)
- 4.2 字节码的编译过程(前端编译器)
- 4.2.1 编译器与解释器
- 4.2.2 编译对象与触发条件
- 4.2.3 Client Compiler(编译速度快)
- 4.2.4 Server Compiler(编译质量高)
- 4.2.5 逃逸分析
本系列文章:
JVM(一)Java运行时区域、对象的使用
JVM(二)垃圾回收
JVM(三)类文件结构、类加载机制
JVM(四)JVM调试命令、JVM参数
JVM(五)JVM调优
一、类文件结构
1.1 Class文件格式
目前如Kotlin、Groovy、Jython、JRuby等一大批语言都能够在Java虚拟机上运行。它们和Java语言一样都会被编译器编译成字节码文件,然后由虚拟机来执行。所以说类文件(字节码文件)具有语言无关性。
Class文件并不一定定义在文件里,也可以通过类加载器直接生成。
Class文件是一组以8位字节为基础单位的二进制流,各个数据严格按照顺序紧凑的排列在Class文件中
,中间无任何分隔符,这使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,会按照高位在前的方式分割成若干个8位字节进行存储。
Java虚拟机规范规定Class文件格式采用一种类似与C语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表。
- 无符号数
属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数
,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码结构构成的字符串值。 - 表
由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个Class文件就是一张表,它由下表中所示的数据项构成。
1.2 Class文件内容
Class文件具体由以下几个部分构成:魔数、版本信息、常量池、访问标志、类索引、父类索引、接口索引集合、字段表集合、方法表集合、属性表集合
。
Class文件中存储的字节严格按照上表中的顺序紧凑的排列在一起。哪个字节代表什么含义,长度是多少,先后顺序如何都是被严格限制的,不允许有任何改变。示例:
javap -v
可以查看一个.class文件的详细信息。示例:
1.2.1 魔数与Class文件的版本
每个Class文件的头4个字节称为魔数
(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的Calss文件。之所以使用魔数而不是文件后缀名来进行识别主要是基于安全性的考虑,因为文件后缀名是可以随意更改的。Class文件的魔数值为"0xCAFEBABE"
。
魔数相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在Class文件中标识文件类型比较合适。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6两个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。高版本的JDK能够向下兼容低版本的Class文件,虚拟机会拒绝执行超过其版本号的Class文件。
1.2.2 常量池
主版本号之后是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同是它还是Class文件中第一个出现的表类型数据项目。
因为常量池中常量的数量是不固定的,所以在常量池入口需要放置一个u2类型的数据来表示常量池的容量"constant_pool_count",和计算机科学中计数的方法不一样,这个容量是从1开始而不是从0开始计数。之所以将第0项常量空出来是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的含义,这种情况可以把索引值置为0来表示。
Class文件结构中只有常量池的容量计数是从1开始的,其它集合类型,包括接口索引集合、字段表集合、方法表集合等容量计数都是从0开始。
常量池中主要存放两大类常量:
- 字面量
比较接近Java语言层面的常量概念,如字符串、声明为final的常量值等。 - 符号引用
属于编译原理方面的概念,包括了以下三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
1.2.3 访问标志
紧接着常量池之后的两个字节代表访问标志(access_flag),这个标志用于识别一些类或者接口层次的访问信息,包括这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被申明为final等。具体的标志位以及标志的含义:
access_flags中一共有16个标志位可以使用,当前只定义了其中的8个,没有使用到的标志位要求一律为0。
1.2.4 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据集合,Class文件中由这三项数据来确定这个类的继承关系:
- 1、类索引用于确定这个类的全限定名;
- 2、父类索引用于确定这个类的父类的全限定名;
- 3、接口索引集合用于描述这个类实现了哪些接口。
1.2.5 字段表集合
字段表集合(field_info)用于描述接口或者类中声明的变量。字段(field)包括类变量和实例变量,但不包括方法内部声明的局部变量。字段表的结构:
字段修饰符放在access_flags中,它与类中的access_flag非常相似,都是一个u2的数据类型。
1.2.6 方法表集合
Class文件中对方法的描述和对字段的描述是完全一致的,方法表中的结构和字段表的结构一样。
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有ACC_VOLATILE和ACC_TRANSIENT。与之相对的,synchronizes、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。
对于方法里的代码,经过编译器编译成字节码指令后,存放在方法属性表中一个名为"Code"的属性里面
。
1.2.7 属性表集合
在Class文件、字段表、方法表中都可以携带自己的属性表(attribute_info)集合,用于描述某些场景专有的信息。
属性表集合不像Class文件中的其它数据项要求这么严格,不强制要求各属性表的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机在运行时会略掉它不认识的属性。
二、类加载机制
源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
。
Java语言中类的加载、连接和初始化都是在程序运行期间完成的
,这种策略虽然会让类加载时增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可动态扩展的语言特性就是依赖运行期间动态加载和动态连接的特点实现的。
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到JVM中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
2.1 初识类加载
2.1.1 类加载的生命周期
类从被虚拟机从加载到卸载,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7个阶段
。其中验证、准备、解析3个部分统称为连接(Linking)
。这7个阶段的发生顺序如下图:
上图中加载、验证、准备、初始化和卸载5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始(这里说的是按部就班的开始,并不要求前一阶段执行完才能进入下一阶段),而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的动态绑定。
2.1.2 主动引用和被动引用
虚拟机规范中对于什么时候开始类加载过程的第一节点"加载"并没有强制约束。但是对于"初始化"阶段,虚拟机则是严格规定了有且只有以下5种情况,如果类没有进行初始化,则必须立即对类进行"初始化"(加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令;
- 使用java.lang.reflect包的方法对类进行反射调用的时候;
- 当初始化一个类的时候,发现其父类还没有进行初始化的时候,需要先触发其父类的初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类;
- 如果一个java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有初始化,则需要先触发其初始化。
有且只有
以上5种场景会触发类的初始化,这5种场景中的行为称为对一个类的主动引用
。除此之外,所有引用类的方式都不会触发初始化,称为被动引用
。比如如下几种场景就是被动引用:
- 通过子类引用父类的静态字段,不会导致子类的初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
2.2 类加载详细过程
2.2.1 加载
这里的"加载"是指"类加载"过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事:
- 1、通过一个
类的全限定名来获取定义此类的二进制字节流
; - 2、
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
; - 3、
在内存中生成一个代表这个类的java.lang.Class对象
,作为方法区这个类的各种数据的访问入口。
加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。
对于Class文件,虚拟机没有指明要从哪里获取、怎样获取。除了直接从编译好的.class文件中读取,还有以下几种方式:
- 从zip包中读取,如jar、war等;
- 从网络中获取,如Applect;
- 通过动态代理计数生成代理类的二进制字节流;
- 由JSP文件生成对应的Class类;
- 从数据库中读取,如有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
2.2.2 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求
,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面4个阶段的检验动作:
- 1、文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,并且能够被当前版本的虚拟机处理。验证点主要包括:是否以魔数0xCAFEBABE开头;主、次版本号是否在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型;Class文件中各个部分及文件本身是否有被删除的或者附加的其它信息等等。 - 2、元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段的验证点包括:
- 这个类是否有父类;
- 这个类的父类是否继承了不允许被继承的类;
- 如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;
- 类中的字段、方法是否与父类产生矛盾等等。
- 3、字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的事件。
如果一个方法体通过了字节码校验,也不能说明其一定就是安全的,这里涉及一个停机问题,通过程序去校验程序逻辑是无法做到绝对准确的-----不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。 - 4、符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的形象进行匹配性校验。
2.2.3 准备
准备阶段是正式为类变量分配内存并设置类变量默认值的阶段
,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要强调:
- 首先,
这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量
,实例变量将会在对象实例化时随着对象一起分配在Java堆中; - 其次这里所说的初始值通常情况下是数据类型的零值。
假设一个类变量的定义为public static int value = 123; 那么变量value在准备阶段过后的初始值为0而不是123
,因为这个时候尚未执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译之后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
这里提到,在通常情况下初始值是零值,那相对的会有一些"特殊情况":如果类字段的字段属性表中存在ConstantsValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指的值。假设上面的类变量value的定义变为public static final int value = 123;,编译时JavaC将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
2.2.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用
的过程。
- 符号引用
符号引用以一组符号来描述所引用的目标,符号可以上任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定都已经加载到内存中。 - 直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必须已经在内存中存在。
对同一个符号引用进行多次解析请求是很常见的事情,除了invokedynamic指令外,虚拟机实现可以对第一次解析的结果进行缓存,从而避免解析动作重复进行。动态的含义是必须等到程序实际运行到这条指令的时候,解析动作才能进行。
2.2.5 初始化
类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的Java程序代码。初始阶段是执行类构造器()方法的过程。
- 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
- 如果类中存在初始化语句,就依次执行这些初始化语句。
在此阶段静态变量才被赋予初始值
。
2.3 类加载器
虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流
这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器。
类加载器:类加载器负责加载程序中的类型(类和接口),并赋予唯一的名字予以标识。
2.3.1 类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中的作用不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机的唯一性,每个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否"相等",只要在这两个类是由同一个类加载器加载的前提下才有意义
,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的相等,包括类的Class对象的equals方法等的返回结果,也包括instance of的返回结果。
2.3.2 四种类加载器
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java来实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
从Java开发者的角度来看,类加载器可以划分为:
-
1、启动类加载器(Bootstrap ClassLoader)
这个类加载器负责将存放在< JAVA_HOME >\lib目录中的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用null代替即可;
Bootstrap ClassLoader是最顶层的加载类,主要加载核心类库,包括:%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。 -
2、扩展类加载器(Extension ClassLoader)
这个类加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载 < JAVA_HOME >\lib\ext 目录中,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 -
3、应用程序类加载器(Application ClassLoader)
这个类加载器由sun.misc.Launcher$App-ClassLoader实现。getSystemClassLoader()方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 -
4、用户自定义类加载器
通过继承 java.lang.ClassLoader类的方式实现的类加载器。 -
四种类加载器的启动顺序
Bootstrap Classloader是在Java虚拟机启动后初始化的。
Bootstrap Classloader负责加载ExtClassLoader,并且将ExtClassLoader的父加载器设置为Bootstrap Classloader
Bootstrap Classloader加载完ExtClassLoader后,就会加载AppClassLoader,并且将AppClassLoader的父加载器指定为ExtClassLoader。
2.3.3 双亲委派模型
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在JVM中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将class文件加载到JVM内存,然后再转化为class对象。
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。
我们的应用程序都是由这3种类加载器(启动类加载器、扩展类加载器、应用程序类加载器)互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:
上面图中的"classNotFound"代表ClassNotFoundException。
- 双亲委派机制的作用
1)防止重复加载同一个.class
。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全
。
2)保证核心.class不能被篡改
。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。
2.3.4 类加载器的特点
- 1、层级结构
Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是所有装载器的父亲
。 - 2、代理模式
基于层级结构,类的代理可以在装载器之间进行代理。当装载器装载一个类时,首先会检查它在父装载器中是否进行了装载。如果上层装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类。 - 3、可见性限制
一个子装载器可以查找父装载器中的类,但是一个父装载器不能查找子装载器里的类。 - 4、不允许卸载
类装载器可以装载一个类但是不可以卸载它,不过可以删除当前的类装载器,然后创建一个新的类装载器装载。
2.3.5 类加载器的隔离问题
每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名(Fully Qualified Class Name) 进行搜索来检测这个类是否已经被加载了。
JVM及Dalvik对类唯一的识别是ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的
。并且如果这两个类不是由一个ClassLoader加载,是无法将一个类的实例强转为另外一个类的,这就是ClassLoader隔离性。
为了解决类加载器的隔离问题,JVM引入了双亲委托机制。这样做的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object,它放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基本的行为也就无法保证了。
双亲委派模型对于保证Java程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在java.lang.ClassLoader的loadClass()方法中,逻辑很清晰:先检查是否已经被加载过,若没有则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
- 双亲委派模型的关键代码
protected 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);}}if (resolve) {resolveClass(c);}return c;
}
2.3.6 自定义类加载器
在很多容器设计中,如Spring、Tomcat,都实现了自定义类加载器,所以我们可以尝试实现一下。【如果要破坏双亲委派机制,需要重写loadClass方法,当然一般不会这样做】。只需extends ClassLoader
然后重写findClass方法即可。假设有这样一个类:
package Basic;public class MyTest {public void show() {System.out.println("show test!");}
}
在Eclipse写了后,会自动生成一个MyTest.class文件。然后我们自定义自己的类加载器:
package Basic;import java.lang.reflect.Method;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;public class MyClassLoader extends ClassLoader {@Overrideprotected Class<?> findClass(String name) {String myPath = "file:///E:/Test/" + name.replace(".","/") + ".class";System.out.println(myPath);byte[] cLassBytes = null;Path path = null;try {path = Paths.get(new URI(myPath));cLassBytes = Files.readAllBytes(path);} catch (Exception e) {e.printStackTrace();}Class clazz = defineClass(name, cLassBytes, 0, cLassBytes.length);return clazz;}public static void main(String[] args) throws ClassNotFoundException {MyClassLoader loader = new MyClassLoader();Class<?> aClass = loader.findClass("Basic.MyTest2");try {Object obj = aClass.newInstance();Method method = aClass.getMethod("show");method.invoke(obj);} catch (Exception e) {e.printStackTrace();}}
}
在E盘创建个Test目录,将自动编译的Mytest2.class文件放进去,运行上述代码就可以看到正确答案:
file:///E:/Test/Basic/MyTest2.class
show test!
2.4 隐式装载和显式装载
Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
类装载方式,有两种 :
- 1、隐式装载
程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到JVM中。 - 2、显式装载
通过class.forname()等方法,显式加载需要的类。
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到JVM,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
2.5 对象初始化的先后顺序
2.5.1 不同对象初始化的先后顺序
- 单个类
1)类的静态变量赋值0;
2)静态变量赋值,静态代码块(按照编写顺序调用);
3)成员变量清赋值0;
4)成员变量赋值,非静态代码块(按照编写顺序调用);
5)构造方法。
1、2 统称为类的初始化
4、5 统称为对象初始化
- 带有继承的类
1)父类类初始化;
2)子类类初始化;
3)成员变量赋值0,包括父类和子类;
4)父类对象初始化;
5)子类对象初始化。
2.5.2 对象初始化的例子
public class StaticTest {public static void main(String[] args) {staticFunction();}static StaticTest st = new StaticTest();//静态代码块static { System.out.println("1");}//实例代码块{ System.out.println("2");}//实例构造器StaticTest() { System.out.println("3");System.out.println("a=" + a + ",b=" + b);}//静态方法public static void staticFunction() { System.out.println("4");}int a = 110; //实例变量static int b = 112; //静态变量
}
结果:
2
3
a=110,b=0
1
4
分析:main方法属于静态方法,主动引用,开始执行类的初始化:按照编写顺序进行静态变量赋值与静态代码块执行。因此顺序为:
- 先初始化StaticTest ,对象实例化时,因为类已经被加载,所以执行对象初始化,先对成员变量进行初始化(a赋值为0),然后按照编写顺序进行非静态变量赋值与非静态代码块执行(打印2 , a赋值为110 ),再调用构造方法(打印3 ,打印a=110,b=0 );
- 再执行静态代码块,打印1;
- 再赋值b为112;
- 至此类加载完毕,执行main方法,打印4。
2.6 类加载的相关问题
2.6.1 Java虚拟机是如何判定两个Java类是相同的?
Java虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。两者都相同的情况,才认为两个类是相同的。
即便是同样的字节代码,被不同的类加载器加载后所得到的类,也是不同的。比如一个Java类com.example.Sample,编译后生成了字节代码文件Sample.class。两个不同的类加载器ClassLoaderA和ClassLoaderB分别读取了这个Sample.class文件,并定义出两个java.lang.Class类的实例来表示这个类。这两个实例是不相同的。对于Java虚拟机来说,它们是不同的类。如果对这两个类的对象进行相互赋值,会抛出运行时异常ClassCastException。
2.6.2 有哪些打破了双亲委托机制的案例?
1)Tomcat可以加载自己目录下的class文件,并不会传递给父类的加载器。
12)Java的SPI,发起者是BootstrapClassLoader,BootstrapClassLoader已经是最上层的了。它直接获取了AppClassLoader进行驱动加载,和双亲委派是相反的。
三、字节码执行引擎
虚拟机的执行引擎不是直接建立在处理器、硬件、指令集和操作系统层面的,而是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并能够执行哪些不被硬件直接支持的指令集格式。
在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行和编译执行(通过即时编译器产生本地代码)两种选择,也可能两者兼备。但从外观上看起来,所有的Java虚拟机都是一样的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行过程。
3.1 栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接、返回地址等信息。每一个方法从调用开始至执行完成过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
。
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
- 1、局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Slot)为最小单位,每个Slot都应该能存放一个boolean、byte、char、short、Reference等类型的数据,允许Slot的长度可以随着处理器、操作系统或虚拟机的实现不同而发生变化。
一个Slot可以存放一个对象实例的引用,虚拟机能够通过这个引用做到两点:一是从此引用中直接或间接地查找对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
局部变量表是线程私有的数据,无论读写两个连续的Slot(long、double)是否为原子操作,都不会引起线程安全问题。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的
引用,在方法中可以通过关键字this来访问到这个隐含的参数。其他参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那整个变量对应的Slot就可以交给其他变量使用。Slot的复用会直接影响到系统的垃圾收集行为。 - 2、操作数栈
操作数栈(Operand Stack)是一个后进先出栈。操作数栈的最大深度也是在编译的时候就写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深入都不会超过max_stacks。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
Java 虚拟机的解释执行引擎称为基于栈的执行引擎,其中的栈就是操作数栈
。
两个栈帧之间的数据共享:
- 3、动态连接
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。 - 4、方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出可能的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
3.2 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时不涉及方法内部的具体执行过程。Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性使得Java方法调用过程变得复杂,需要在类加载器件,甚至到运行期间才能确定目标方法的直接引用。
3.2.1 解析
符号引用能转为直接引用成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法,前者和类型直接关联,后者在外部不可被访问,这两种方法的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机中提供了5条方法调用字节码指令:
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>
方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用到限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这个方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final)。
非虚方法也包含被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法调用者进行多态选择,又或者说多态选择的结果肯定是位移的。
解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的组合就构成了静态单分派、静态多分派、动态单分派、动态多分派这4种分派组合情况。
3.2.2 分派
分派调用过程将会揭示多态性的一些最基本体现,如重载和重写。
- 1、静态分派
示例:
public class StaticDispatch {static abstract class Human{ }static class Man extends Human{}static class Woman extends Human{}public void sayHello(Human gay) {System.out.println("hello,gay");}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 staticDispatch = new StaticDispatch();staticDispatch.sayHello(man);staticDispatch.sayHello(woman);}
}
结果:
hello,gay
hello,gay
上面代码中的Human称为变量的静态类型(Static Type),或者叫做外观类型,后面的Man称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会发生改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
代码中刻意定义了两个静态类型相同但是实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译器可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作被称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更加合适的版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式地静态类型,它的静态类型只能通过语言上的规则去理解和推断。
- 2、动态分派
典型场景为重写,示例:
public class DynamicDispatch {static abstract class Human{ protected abstract void sayHello();}static class Man extends Human{@Overrideprotected void sayHello() {System.out.println("man say hello");}}static class Woman extends Human{@Overrideprotected 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
invokevirtual指令的运行时解析步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C种找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回IllegalAccessError异常。
- 否则,按照继承过膝从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没找到合适的方法,则抛出AbstractMethodError异常。
由于invokevitual指令执行的第一步就是在运行期确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号解析到了不同的直接引用上,这个过程就
Java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
- 3、单分派与多分派
方法的接收者和方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
示例:
public class Dispatch {static class QQ{}static class _360{}public static class Father {public void hardChoice(QQ arg) {System.out.println("father choose qq");}public void hardChoice(_360 arg) {System.out.println("father choose 360");}}public static class Son extends Father {public void hardChoice(QQ arg) {System.out.println("son choose qq");}public void hardChoice(_360 arg) {System.out.println("son choose 360");}}public static void main(String[] args) {Father father = new Father();Father son = new Son();father.hardChoice(new _360());son.hardChoice(new QQ());}
}
结果:
father choose 360
son choose qq
Java语言是一门静态多分派、动态单分派的语言。
- 4、虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都
不会真正地进行如何频繁的搜索。最常用的稳定优化的方法就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
方法表结构:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引编号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和基于类型继承关系分析技术的守护内联两种非稳定的激进优化手段来获得更高的性能。
3.3 基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行和编译执行两种选择。
3.3.1 解释执行
Java语言经常被人们定位为解释执行的语言,但当主流的虚拟机都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
3.3.2 基于栈的指令集和基于寄存器的指令
Java编译器输出的指 令流, 基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集。
基于栈的指令集主要的优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑、编译器实现更加简单(不需要考虑空间分配,都在栈上操作)等。栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。
四、程序编译与代码优化
4.1 字节码的编译过程(前端编译器)
Java语言的编译期是一段不确定的操作过程。
1)编译器前端/前端编译器:把java文件转为class文件,比如Sun的Javac。
2)编译器后端/后端运行时编译器(JIT Just In Time 编译器):把字节码转为机器码,比如HotSpot VM的C1、C2编译器。
3)静态提前编译器(AOT Ahead Of Time 编译器):直接把java文件编译为本地机器代码,比如 GNU Compiler for the Java(GCJ)。
通常意义上的编译器就是前端编译器,这类编译器对代码的运行效率几乎没有任何优化,把对性能的优化集中到了后端编译器,这样可以使其他语言的class文件也同样能享受到编译器优化所带来的好处。
但是Javac做了很多针对Java语言编码过程中的优化措施来改善程序员的编码风格和提高编码效率,相当多的新的语法特性都是靠前端编译器的语法糖实现的,而非依赖虚拟机的底层改进来实现。
Javac的编译过程大致可以分为三个阶段:
1)解析和填充符号表。
2)插入式注解处理器的注解处理。
3)语义分析与字节码生成。
- 1、解析与填充符号表
1)解析包括了词法分析和语法分析两个过程。词法分析是将源代码的字符流变为Token序列;语法分析是根据Token序列构造抽象语法树AST的过程。
2)填充符号表。符号表是由一组符号地址和符号信息构成的表格。符号表中所登记的信息在编译的不同阶段都要用到。 - 2、插入式注解处理器的注解处理
插入式注解处理器可以视为一组编译器的插件,可以读取、修改、添加AST中的任意元素。如果在处理注解期间对AST进行了修改,那么编译器将回到解析与填充符号表的过程重新处理,每一次循环称为一个Record。 - 3、语义分析与字节码生成
语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查。语义分析的过程分为Token检查和数据及控制流分析两个阶段。
1)Token检查的内容包括变量使用前是否声明、变量和赋值之间的数据类型能否匹配,还有常量折叠等。
2)数据及控制流分析是对程序上下文逻辑进行更进一步的验证,它可以检查出如程序员局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理等。
3)解语法糖:比如泛型、变长参数、自动装箱/拆箱等
4)字节码生成:不仅仅是把签个各个步骤所生成的信息转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作,比如添加实例构造器<init>
()和类构造器<client>
()。
4.2 字节码的编译过程(前端编译器)
解决以下几个问题:
1)为何HotSpot虚拟机要使用解释器和编译器并存的架构;
2)为何HotSpot虚拟机要实现两个不同的JIT;
3)程序何时使用解释器执行,何时使用编译器执行;
4)哪些程序代码会被编译为本地代码,如何编译为本地代码;
5)如何从外部观察JIT的编译过程和编译结果。
4.2.1 编译器与解释器
解释器与编译器各有优势,前者节省内存,后者提高效率。在整个虚拟机执行架构中,解释器与编译器经常配合工作。
解释器与编译器的交互:
HotSpot虚拟机中内置了两个JIT,分别称为Client Compiler和Server Compiler。在虚拟机中习惯将Client Compiler称为C1,将Server Complier称为C2。目前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与机器硬件性能自动选择运行模式,用户也可以使用-client或者-server参数去强制指定虚拟机运行的模式。
无论采用哪一种编译器,解释器与编译器搭配使用的方式在虚拟机中称为混合模式,用户可以使用参数-Xint强制虚拟机运行于解释模式,这时编译器完全不介入工作;也可以使用参数-Xcomp强制虚拟机运行于编译模式,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机会逐渐启用分层编译的策略。
第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译;
第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必须将
加入性能监控的逻辑;
第2层(或2层以上):也称为C2编译,也是将字节码编译为本地代码,但是会启动一些
编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实时分层编译后,Client Compiler和Server Compiler将会同时工作,很多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compile获取更好的编译质量,在解释执行的时候也无需再承担收集性能监控信息的任务。
4.2.2 编译对象与触发条件
在运行过程中被 JIT 编译的热点代码有两类:被多次调用的方法、被多次执行的循环体。
- 1、热点探测
编译器都会以整个方法作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地成为栈上替换(On Stack Replacement OSR)。
判断一段代码是不是热点代码,是不是需要触发JIT,这样的行为称为热点探测。目前主流的热点探测判定方式有两种:
1)基于采样的热点探测
:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这些方法就是热点方法。好处是简单高效,还可以很容易得获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
2)基于计数器的热点探测
:采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。好处是更加精确演进,缺点是实现较为麻烦。
HotSpot采用的第二种方法,因为它为每个方法准备了两类计数器:方法调用计数器和回边计数器。这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。 - 2、方法调用计数器
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器加一,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果已超过阈值,那么会向JIT提交一个该方法的代码编译请求。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的总次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给JIT编译,则这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器的衰减,而这段时间就称为此方法统计的半衰周期。
方法调用计数器触发即时编译:
- 3、回边计数器
回边计数器的作用是统计一个方法中循环体的代码执行次数,在字节码中遇到控制流向后调换的指令称为回边,回边计数器统计的目的就是为了触发OSR编译。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有这已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器之和是否超过回边计数器的阈值。当超过阈值时,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
回边计数器触发即时编译:
4.2.3 Client Compiler(编译速度快)
是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。三段式:
1)第一个阶段,一个平台独立的前端会将字节码构造成一种高级中间代码表示(HIR High-Level Intermediate Representation)。HIR使用静态单分配的形式来表示代码值,这使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等。
2)第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR Low-Level Intermediate Representation),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等。
3)第三个阶段,一个平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码。
Client Compiler架构:
4.2.4 Server Compiler(编译质量高)
是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的编译器。它会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除、空值检查消除。另外还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分值预测检测等。
4.2.5 逃逸分析
语言无关的经典优化技术之一:公共子表达式消除;
语言相关的经典优化技术之一:数组范围检查消除;
最重要的优化技术之一:方法内联;
最前沿的优化技术之一:逃逸分析。
分析指针动态范围的方法称之为逃逸分析(通俗点讲,当一个对象的指针被多个方法或线程引用时们称这个指针发生了逃逸)。
逃逸分析并不是直接的优化手段,而是一个代码分析,通过动态分析对象的作用域,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。
如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。
- 1、同步消除
线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks 可以开启同步消除。 - 2、标量替换
1、标量是指不可分割的量,如java中基本数据类型和reference类型,相对的一个数据可以继续分解,称为聚合量;
2、如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换;
3、如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量;通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况。 - 3、栈上分配
故名思议就是在栈上分配对象,其实目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。