1.JVM架构
简介
java平台可分为两部分,既java虚拟机(JVM) 和JavaAPI类库。
JVM是Java Virtual Machine(java虚拟机)的缩写,JVM使得Java实现了跨平台。
引入了JVM后,JAVA语言再不同平台上运行就不需要重新编译(生成class文件,字节码编译部分),Java语言编译程序只需生成在Java虚拟机上运行的字节码,就可以在多平台不加修改地运行。
JVM架构图
Java虚拟机主要分为5大模块,类装载器子系统,运行时数据区,执行引擎,本地方法接口,垃圾收集模块。
JVM架构图
JVM结构
各部分主要功能:
类加载器:JVM启动,程序开始运行,负责将class字节码加载到JVM内存区域中。
执行引擎:负责执行文件中包含的字节码指令(解释执行,即时编译,OSR)
运行时数据区:
- 方法区(元空间):用于存储类结构信息的地方,包括常量池、静态变量、构造函数等
- Java堆(Heap):存储java实例的地方。这块是GC的主要区域。方法区和堆是被线程共享的。
- Java栈(Stack):java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈。每运行一个方法就创建一个栈贞,用于存储局部变量表、操作栈,方法返回等。
- 程序计数器(PC Register):一块较小的内存空间,可以看做是当前线程所执行的字节码行号指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要一来这个计数器来完成。
- 本地方法栈(Native Method Stack):和java栈作用差不多,只不过是为JVM使用到的本地方法服务。
本地方法接口:主要提供调用C或C++实现的本地方法。
垃圾回收模块:主要负责方法区和堆的垃圾回收。
JVM内部模块间的关系
模块关系垃圾回收系统:方法区,Java堆。
类加载器:方法区,Java堆。
执行引擎:方法区,Java堆,Java栈,程序计数器
2.编译过程
java语言最显著的两个特点:
差异屏蔽
- 1.一次编译,到处运行:通过将java程序编译成标准字节码,而后通过JVM转为对应平台的机器码来屏蔽底层差异。
- 2.自动垃圾收集功能:通过java垃圾收集(Garbage Collector)回收分配内存使得开发人员不需要操心内存的分配和回收。
先编译后解释
1.Java的编译和执行
以Oracle 提供的HotSpot虚拟机为例,在HotSpot虚拟机中,提供了两种编译模式
- 1.解释执行:逐条翻译字节码为可运行的机器码
2.即时编译:以方法为单位将字节码翻译成机器码
解释器编译器配合工作
编译包括两种情况
- 1.源码编译成字节码
- 2.字节码编译成本地机器码
解释执行也包括两种情况
- 1.源码解释执行
- 2.字节码解释执行
我们先要清楚解释执行和编译执行的概念
解释执行:一边对程序翻译成机器码,一边交给计算机执行。翻译一句执行一句。执行速度慢,效率低,跨平台性好。
编译执行: 对整个程序先翻译成机器码,然后计算机可以直接执行。全部翻译完再执行。执行速度快,效率高,跨平台性差。
2.编译原理
在执行前先对程序源码进行词法解析和语法解析处理,把源码转化为抽象语法树
解释执行和编译执行前操作
其中绿色的模块可以选择性实现。
上图中间那条分支是解释执行过程,而下面的那条分支就是传统编译原理中从源代码到目标机器代码的生成过程。
3.三种编译器
JVM的编译器可以分为三个类型
- 前端编译器:把java转变为.class文件的过程。如Sun的Javac Eclipse JDT中的增量式编译器。
- 后端编译器:它在程序运行期间将字节码转变为机器码。如(JIT Compiler)
- AOT编译器:静态提前编译器(Ahead Of Time Compiler),直接把.java文件部编译成本地机器代码,如JDK9中 实验性的IAOT。
4.编译器-Javac编译过程
.java文件是由Java源码编译器(javac)来完成,流程如图:
javac编译过程
默认情况下,无论是方法即时编译还是OSR,编译未完成之前,都扔按照解释方式执行。而编译动作在后台编译线程中进行,用户通过-XX:-BackgroundCompilation来禁止后台编译,这样执行线程提交了编译请求后会等待。编译完成后继续执行。
5运行期-JIT编译
目前主流的JVM都是混合模式(mixed)即解释运行和编译运行配合使用。流程如下
- 1.java代码经过javac编译成class文件(字节码)
- 2.class文件(字节码)经过jvm变异成机器码进行解释执行
- 3.对于热点代码,JIT(JustInTime)编译器会在运行时将其编译为机器码执行。
HotSpot JVm中有两个JIT compiler,一个是C1,对应的模式是client,一个是C2,对应模式是server。
C1:即Client编译器,面向对启动性有要求的客户端GUI程序,采用的优化手段比较简单,因此编译的时间短。
C2: 即Server编译器,面向对性能峰值有要求的服务端程序,采用的优化手段较复杂,因此编译时间长,但是在运行过程中性能更好。
OSR编译(On Stack Replace):只替换循环代码体的入口,C1、C2替换的是方法调用的入口。因此OSR编译后会出现的现象是方法的整段代码被编译了,但是只有循环体部分才执行编译后的机器码,其他部分仍是解释执行。
可以根据场景在虚拟机启动的时候定制不同的运行模式
- 1.mixed模式:用-Xmixed开启,即混合模式,也是HotSpot默认模式
- 2.int模式:用-Xint开启,即解释模式,在这种模式下全部才去解释模式运行
- 3.comp模式:用-Xcomp开启,这种模式下通知jvm关闭解释模式,采用编译模式来运行,但往往导致无法得到良好的自动优化。
在JDK9中 提供了AOT(Ahead-of-Time Compilation) 编译器允许将代码编译成机器码交给JVM运行。
在JDK10中提供了Graal即时编译器(实验性的)。
从Java7开始,HotSpot虚拟机默认多采用分层编译的方式:
1.热点方法首先被C1编译器编译。
2.热点方法中的热点方法再进一步被C2编译器编译。
3.为了不干扰程序正常运行,JIT编译时在额外的线程中执行。HotSpot根据实际CPU资源,以1:2的比例分配给C1和C2线程数。在计算机资源充足的情况,字节码的解释运行和编译运行可以同时进行。编译执行完后的机器码会在下次调用该方法时启动,以替换原本的解释执行。
C1 编译器编译过程
它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局化手段。
- 1.在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码(High-Level Intermediate Representaion,HIR)。HIR使用静态单分配(Static single Assignment,SSA)的形式来代码代码值,这可以使得一些在HIR的构造过程之中和之后进行优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如:方法内联、常量传播等优化将会在字节码被构造成HIR之前完成
- 2.在第二阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low_level Intermediate Representation,LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
流程图
- 3.最后阶段是在平台相关的后端使用线性扫描算法(Lineal Scan Register Allocation)在LIR上做窥孔(Peephole)优化,然后产生机器代码。
C2编译器编译过程
Server Compoler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器。
- 1.会执行所有经典的优化动作,如:无用代码消除,循环展开,循环表达式外提,消除公共子表达式,常量传播,基本快重排序等。
- 2.还会实施一项与java语言特性密切相关的优化技术,如范围消除,控制检查消除。
- 3.Server Compiler的寄存器分配器是全局图着色分配器,从即时编译器的角度来看无疑是比较缓慢的。但是编译速度仍然远远超过传统静态优化编译器,而相对于Client Compiler编译,输出的代码质量有所提高,可减少本地代码的执行时间。
在运行过程中会被即时编译的热点代码有两类:
- 1.被多次调用的方法体:编译器会将整个方法作为编译对象,这也是标准JIT编译方式。
- 2.被多次执行的循环体:是由循环体发出的,但是编译器依然以整个方作为编译对象,因为发生在方法执行过程中,被称为栈上替换。
判断一段代码是否是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection
)探测方法有两种:
1.基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机周期的堆各个系成功的栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是热点方法。好处是简单高效,缺点是很难确认方法是不是热点,容易受到线程阻塞或其他外因的干扰。
2.基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法建立计数器,执行次数超过阈值就认为是热点方法。优点是精确严谨,缺点是实现麻烦,不能直接获取方法调用关系。
HotSpot使用的是第二种,基于计数器的热点探测。并且有两类计数器:
1.方法调用计数器(Invocation Counter):Client模式下默认阈值是1500次,在Server模式下是10000次,这个阈值可以通过-XX:CompileThreadhold来人为设定。如果不做任何设置,方法调用计数器并不是方法被调用的绝对次数。当超过一定时间限度,方法的调用次数仍然不足以让它交给即时编译器编译,那么这个方法的调用技术就会被减少一半(热度衰减),这段时间就成为此方法统计的半衰周期。进行热度衰减的动作是在垃圾回收时顺便进行的,可以使用虚拟机参数-XX:CounterHalfLifeTime参数设置半衰周期 单位是秒。
2.回边调用计数器(Back Edge Counter):作用是统计一个方法中循环体代码的执行次数。在字节码中遇到控制流向跳转的指令称为“回边”(Back Edge)。默认设置下虚拟机回边计数器阈值为 10700。
6.编译优化
6.1语法糖(早期Javac阶段)
语法糖可以看做是编译器实现的一些小把戏,这些小把戏可能会使效率大提升。
Java中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱,内部类,遍历循环,枚举类,断言语句,字符串的switch,try等。虚拟机并不支持这些语法,他们在编译阶段就被还原回了简单的基础语法结构,这个过程称为解语法糖。
6.1.1 泛型与类擦除
Java语言在JDK1.5之后引入的泛型实际上只在程序源码中存在,在编译后的字节码文件中,泛型会被替换为原来的原生类型(Raw Type,也称为裸类型)。这个过程被称为泛型擦除。并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList<String>和ArrayList<Integer>就是同一个类。所以泛型技术实际上是java语言的一棵语法糖,基于这种方法实现的泛型被称为伪泛型。
Map<Integer,String> map = new HashMap<Integer,String>();
map.put(1,"No.1");
map.put(2,"No.2");
System.out.println(map.get(1));
System.out.println(map.get(2));
//将这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都变回了原生类型,如下面的代码所示:
Map map = new HashMap();
map.put(1,"No.1");
map.put(2,"No.2");
System.out.println((String)map.get(1));
System.out.println((String)map.get(2));
//为了更详细地说明类型擦除,再看如下代码:
import java.util.List;
public class FanxingTest{
public void method(List<String> list){
System.out.println("List String");
}
public void method(List<Integer> list){
System.out.println("List Int");
}
}
//用Javac编译器编译这段代码时,报出了如下错误:
FanxingTest.java:3: 名称冲突:method(java.util.List<java.lang.String>) 和 method
(java.util.List<java.lang.Integer>) 具有相同疑符
public void method(List<String> list){
^
FanxingTest.java:6: 名称冲突:method(java.util.List<java.lang.Integer>) 和 method
d(java.util.List<java.lang.String>) 具有相同疑符
public void method(List<Integer> list){
^
//这是因为泛型List<String>和List<Integer>编译后都被擦除了,变成了一样的原生类型List
泛型的功能使用Object完全能够替代。
有了泛型这颗语法糖后:
- 1.代码更加简洁 【不需要强制转换】。
- 2.程序更加健壮 【只要编译器没有警告,那么运行时期就不会出现ClassCastException】
- 3.可读性和稳定性【在编写的时候就限定了类型】
6.1.2条件编译
java编译器并非一个个的编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息。
java语言使用条件为常量的if语句时,它在编译阶段就会被运行,生成的字节码之中只包含条件正确的部分。编译器会把分支不成立的代码擦除掉,这一工作在编译器解除语法糖阶段完成。
6.1.3自动拆箱装箱与遍历循环
自动装箱、拆箱是在编译之后就被转换成了想用的包装个还原方法,如Integer.valueOf()与Integer.intValue()方法。
遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历类实现Iterable接口的原因。
包装类的“==”运算在不遇到算数运算的情况下不会自动拆箱,它们的equals()方法不处理数据转型的关系。
6.2即时编译器JIT 优化技术
首先需要明确的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在Java源码之上的。
优化技术
优化技术
对代码的所有优化措施都集中在即时编译器中(JIT)。
常用优化技术
- 1.方法内联(Method Inlining)
- 2.冗余访问消除(Redundant Loads Elimination)
- 3.复写传播(Copy Propagation)
- 4.无用代码消除(Dead Code Elimination)
- 5.公共子表达式消除
- 6.数组边界检查消除(Array Bounds Checking Elimination)
- 7.逃逸分析
1.方法内联
去除方法调用的成本(如建立栈帧等),并为其他优化建立良好基础。
方法内联就是把被调用函数代码“复制”到调用方函数中,来减少函数调用开销。(栈帧)
函数调用的压栈和出栈过程,需要有一定的时间开销和空间开销。
当一个方法体不大,但又频繁被调用时,时间和空间开销会变得很大,非常不划算,降低程序性能。
private int add(int x,int y,int z,int k) {
return add1(x,y) + add1(z,k);
}
private int add1(int x , int y) {
return x + y;
}
内联后
private int add(int x,int y,int z, int k) {
return x + y + z +k;
}
内联触发条件
JVM会自动识别热点方法并对它们使用方法内联优化
- 1.client编译器 默认为1500
- 2.server编译器 默认为10000
通常这个值由-XX:CompileThreshold
参数设置。
但是一个方法就算被JVM标记为热点方法,JVM也不一定会对他最方法内联优化。其中有个比较常见的原因就是方法体太大了。 - 1.如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(
-XX:MaxFreqInineSize
参数设置) - 2.如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(
-XX:MaxInLineSize
参数设置)
2.冗余访问消除
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
//do something
z = b.get();
sum = y + z;
}
如果代码中 do something 所代码的操作不会改变b.value的值,那就可以把`z = b.get()` 替换为`z = y`
因为上一句`y = b.get()`已经保证了变量y 与`b.get()`是一致的,这样就可以不再去访问对象b的局部变量了。
3.复写传播
在上面的程序的逻辑中并没有必要使用一个额外的变量z
,它与变量y
是完全相等的,因此可以用y
来代替z
4.无用代码消除
无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地被成为Dead Code
5.公共子表达式消除
如果一个表达式E已经计算过了,并且从先前到现在E中所有变量的值没有发生变化,那E的这次出现就成为了公共子表达式。
对这种表达式没必要再花时间进行计算,只需直接用前面几岁安国的表达式结构结果代替即可。
如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果覆盖了多个基本块则成为全局公共子表达式消除。
6.数组边界检查消除
略
7.逃逸分析
- 1.分析对象动态作用域,当一个方法被定以后,它可能被外部方法所引用,称为方法逃逸,甚至还有可能被外部线程访问到,称为线程逃逸。
- 2.若能证明一个对象不会逃逸到方法或线程之外,那么就可以通过栈上分配、同步消除、标量替换来进行优化。
- 3.栈上分配:在一般应用中不会逃逸的局部对象所占比例很大,若能在栈上分配内存随着方法结束而销毁,垃圾回收系统压力将会小很多。
- 4.标量替换:标量,指的是JVM中描述数据最基本的单位。例如原始数据类型等。当确定一个对象不会逃逸后,那么就要分配它到栈上空间,然后栈空间是有限的,为了进一步节省栈空间,就需要将对象(聚合量)拆散为标量。这样在JVM里不会在栈中创建对象,而是仅仅创建对象的成员变量,这样就节省了空间,而且没有对象头以及对齐填充的空间浪费。
- 5 同步消除:同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的,那么完全没有必要加锁,JIT编译时期就可以将同步锁去掉,以减少加锁解锁造成的资源开销。
网友评论