- 运行时栈帧结构
- 方法调用
- 字节码解释执行引擎
- 虚拟机是如何执行方法里面的字节码指令的
- 基于栈的指令集和基于寄存器的指令集
- 编译优化技术
- Javac编译过程分为哪些步骤
- 即时编译器JIT
- 热点代码
- 优化技术类型
- 代表性优化技术
- 公共子表达式消除
- 数组边界检查消除
- 方法内联
- 逃逸分析
- Java与CC编译器
Execution Engine是虚拟机的核心组成之一
虚拟机和物理机的区别是什么?
这两种机器都有代码执行的能力,但是:
- 物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面的。
- 虚拟机的执行引擎是自己实现的,因此可以自行制定指令集和执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
Java虚拟机规范指定了虚拟机字节码执行引擎的概念模型,提供了统一Facade。不同的虚拟机实现里,执行的时候,可能会有解释执行、编译执行两种,或者两者兼备
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构, 存储了方法的
- 局部变量表
- 操作数栈
- 动态连接
- 每个栈帧都包含一个指向运行时常量池中,该帧所属方法的引用,以支持动态连接
- 方法返回地址
每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
![JVM之执行引擎_第1张图片](http://img.e-com-net.com/image/info8/ed97882ec2f945a5befb895ed65e5ce2.jpg)
方法调用
方法调用唯一的任务是确定被调用方法的版本(调用哪个方法),暂时还不涉及方法内部的具体运行过程。
Java的方法调用,有什么特殊之处?
Class文件的编译过程不包含传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这使得Java有强大的动态扩展能力,但使Java方法的调用过程变得相对复杂,需要在类加载期间甚至到运行时才能确定目标方法的直接引用。
解析
“编译期可知,运行期不可变”,这类方法的调用叫“解析”
分派
-
静态分派
-
依赖静态类型(编译时确定)来定位方法的执行版本的分派动作,叫做静态分派
-
动态分派
-
在运行期根据实际类型确定方法执行版本的分派,叫做动态分派
- 方法重写(invokevirutal把常量池中的方法的符号引用解析到不同的直接引用上)
单分派和多分派
Java虚拟机调用字节码指令有哪些?
- invokestatic:调用静态方法
- invokespecial:调用实例构造器方法、私有方法和父类方法
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法
动态语言支持
JDK 1.7增加的指令 invokedynamic
字节码解释执行引擎
虚拟机是如何执行方法里面的字节码指令的?
- 解释执行(通过解释器执行)
- 编译执行(通过即时编译器JIT产生本地代码)
当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,只有虚拟机自己才能准确判断。
Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译是半独立的实现。
基于栈的指令集和基于寄存器的指令集
-
基于栈的指令集
-
Java编译器输出的指令流,里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
计算“1+1=2”,基于栈的指令集是这样的
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续地把两个常量1压入栈中,iadd指令把栈顶的两个值出栈相加,把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
-
基于寄存器的指令集
-
最典型的是x86的地址指令集,依赖寄存器工作
计算“1+1=2”,基于寄存器的指令集是这样的
mov eax,
add eax,
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器
里。
- 基于栈的指令集的优缺点
- 优点
- 可移植性好:用户程序不会直接用到这些寄存器,由虚拟机自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存)放到寄存器以获取更好的性能。
- 代码相对紧凑:字节码中每个字节就对应一条指令
- 编译器实现简单:不需要考虑空间分配问题,所需空间都在栈上操作
- 缺点
- 执行速度稍慢
- 完成相同功能所需的指令多,出栈入栈
- 频繁的访问栈,意味着频繁的访问内存,相对于处理器,内存才是执行速度的瓶颈
编译优化技术
Javac编译过程分为哪些步骤
- 解析与填充符号表(Parse and Enter)
- 词法语法分析(com.sun.tools.javac.parser.Scanner类实现)
- 填充符号(com.sun.tools.javac.comp.Enter类实现)
- 插入式注解处理器的注解处理(Annotation Processing)
- 语义分析与字节码生成(Analyse and Generate)
即时编译器JIT
Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code)
为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器(Just In Time
Compiler,JIT编译器)
- 当程序需要快速启动和执行时,解释器首先发挥作用,省去编译的时间,立即执行
- 当程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,可以提高执行效率
如果内存资源限制较大(部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时编译器的代码还能退回成解释器的代码
热点代码
判断是否为热点代码
要知道一段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测。主要有两种方法
- 基于采样的热点探测,虚拟机周期性检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。实现简单高效,但是很难精确确认一个方法的热度
- 基于计数器的热点探测,虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值,就认为它是热点方法
HotSpot虚拟机使用第二种,有两个计数器
- 方法调用计数器
- 统计的是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器的热度衰减,这个时间就被称为半衰周期
- 回边计数器(判断循环代码)
优化技术类型
- 编译器策略 Complier Tactics
- 基于性能监控的优化技术 Profile-based Techniques
- 基于证据的优化技术 Proof-based Techniques
- 数据流敏感重写 Flow-sensitive Rewrites
- 语言相关的优化技术 Language-specific Techniques
- 内存及代码位置变换 Memeory and Placement Transforamtion
- 循环变换 Loop Transformations
- 全局代码调整 Global Code Shapping
- 控制流图变换 Control Flow Graph Transformation
代表性优化技术
- 语言无关:公共子表达式消除
- 語言相关:数组范围检查消除
- 最重要:方法内联
- 最前沿:逃逸分析
公共子表达式消除
如果一个表达式E已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E
的这次出现就成了公共子表达式。没有必要重新计算,直接用结果代替E就可以了
若优化仅限程序基本块内,叫局部公共子表达式消除Local Common Subexpression Elimination
若涵盖多个基本块,叫全局公共子表达式消除Global Common Subexpression Elimination
数组边界检查消除
Array Bounds Checking Elimination
因为Java会自动检查数组越界,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。
如果数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器,通过数据流分析,就可以判定循环变量的取值范围,永远在数组区间内,那么整个循环中就可以把数组的上下界检查消除掉,可以节省很多次的条件判断操作。
方法内联
内联消除了方法调用的成本,还为其他优化手段建立良好的基础
编译器在进行内联时,如果是非虚方法(私有方法、实例构造器,父类方法和invokestatic调用的方法),那么直接内联。
如果遇到虚方法,则会向CHA(Class Hierachy Analysis)查询当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那么也可以内联,不过这种内联属于激进优化,需要预留一个逃生门(Guard条件不成立时的SlowPath),称为守护内联(Guarded Inlining)。
如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接受者的继承关系发现变化的类,那么内联优化的代码可以一直使用。否则需要抛弃掉已经编译的代码,退回到解释状态执行,或者重新进行编译
如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接受者的继承关系发现变化的类,那么内联优化的代码可以一直使用。否则需要抛弃掉已经编译的代码,退回到解释状态执行,或者重新进行编译。
逃逸分析
方法逃逸与线程逃逸
逃逸分析的基本行为–>分析对象动态作用域
当一个对象在方法里面被定义后,它可能被外部方法所引用,这种行为被称为方法逃逸。被外部线程访问到,被称为线程逃逸
如果对象不会逃逸到方法或线程外,可以做什么优化
- 栈上分配(Stack Allocation)
- 一般对象都是分配在Java堆中的,对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。但是筛选可回收对象,回收和整理内存都会耗时
- 如果一个对象不会逃逸出方法,可以让这个对象在栈上分配内存,对象所占用的内存空间就可以随着栈帧出栈而销毁。如果能使用栈上分配,那大量的对象会随着方法的结束而自动销毁,垃圾回收的压力会小很多
- 同步消除(Synchronization Elimination)
- 线程同步本身就是很耗时的过程。如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,同步措施就可以消除掉
- 标量替换(Scalar Replacement)
- 标量Scalar是无法再分解的数据,如基本数据类型;若可继续分解,叫聚合量Aggregate,如Java对象
- 如果逃逸分析确定一个对象不会被外部访问,且对象可分解,那么,不创建这个对象,直接创建它的若干个被这个方法使用到的成员变量的原始类型
- 除了分配到栈上,很大机率会被虚拟机分配到物理机的高速寄存器上存儲
-XX:+DoEscapeAnalysis: 开启逃逸分析
-XX:+PrintEscapeAnalysis: 查看逃逸分析结果
Java与C/C++编译器
Java编译器较C/C++编译器的一些劣势,但这些劣势换取了开发效率上的优势
二者的比较,很大程度上代表了经典的即时编译器与静态编译器的对比
- 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力
- Java语言是动态的类型安全语言,意味着,需要虚拟机来确保程序不会违反语言语义,或访问非结构化内存。就是说,必须频繁地动态检查,尽管有优化,但仍耗费不少运行时间
- Java语言虽然没有virtual关键字,但是使用虚方法的频率远大于C++,所以即时编译器进行优化时难度要远远大于C++的静态优化编译器
- Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,使得全局的优化难以进行,因为编译器无法看见程序的全貌,编译器不得不时刻注意并随着类型的变化,而在运行时撤销或重新进行一些优化
- Java语言对象的内存分配是在堆上,只有方法的局部变量才能在栈上分配。C++的对象有多种内存分配方式