字节码是指平常所了解的 .class 文件,Java 代码通过 javac 命令编译成字节码
机器码和本地代码都是指机器可以直接识别运行的代码,也就是机器指令
字节码是不能直接运行的,需要经过 JVM 解释或编译成机器码才能运行
此时你要问了,为什么 Java 不直接编译成机器码,这样不是更快吗?
1. 机器码是与平台相关的,也就是操作系统相关,不同操作系统能识别的机器码不同,如果编译成机器码那岂不是和 C、C++差不多了,不能跨平台,Java 就没有那响亮的口号 “一次编译,到处运行”;
2.之所以不一次性全部编译,是因为有一些代码只运行一次,没必要编译,直接解释运行就可以。而那些“热点”代码,反复解释执行肯定很慢,JVM 在运行程序的过程中不断优化,用JIT编译器编译那些热点代码,让他们不用每次都逐句解释执行;
3.还有一方面的原因是后文讲解的解释器与编译器共存的原因。
java源代码(符合语言规范)-->javac-->.class(二进制文件)-->jvm-->机器语言(不同平台不同种类)
C语言和C++语言的编译过程是把源代码编译生成机器语言。这样机器可以直接执行,所有没有“一次编译,到处执行”特点,而Java是通过JVM把字节码翻译成机器语言,而不同平台安装不同版本的JVM即可编译成对应具有对应平台特性的机器语言。
JAVA编译步骤:
根据完成任务不同,可以将编译器的组成部分划分为前端(Front End)与后端(Back End)。
前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。
javac
的编译就是前端编译()。除了这种以外,我们使用的很多IDE,如eclipse,idea等,都内置了前端编译器。主要功能就是把.java
代码转换成.class
代码。读取源代码,一个字节一个字节的读取,找出其中我们定义好的关键字(如java中的if else for等关键字,识别哪些if是合法的关键字,哪些不是),这就是词法分析器进行词法分析的过程,其结果是从源代码中找出规范化的Token流。
通过语法分析器对词法分析后Token流进行语法分析,这一步检查这些关键字组合再一次是否符合java语言规范(如在if后面是不是紧跟着一个布尔判断表达式),词法分析的结果是形成一个符合java语言规范的抽象语法树。
(1)定义:符号表(Symbol Table)是一组符号地址与符号信息构成的表格。
(2)符号表中记录的信息在编译的不同阶段都要用到,如:语义分析时,符号表中的内容用于语义检查(名字与原先的说明是否一致)与生成中间代码;在目标代码生成阶段,对地址名进行地址分配就是根据符号表的记录。
(3)解析类:com.sun.tools.javac.comp.Enter
(1)JDK1.5,java语言提供了注解的支持,但当时只能在运行期发挥作用。
(2)JDK 1.6,提供了插入式注解处理器的标准API在编译期间对注解进行处理。这些注解处理器能在处理注解期
间对语法树进行修改,所以需要回到解析以及填充符号表的过程,这称为一个Round。
(3)处理类:com.sun.tools.javac.processing.JavacProcessingEnvironment
通过语义分析器进行语义分析。语音分析主要是将一些难懂的、复杂的语法转化成更加简单的语法,结果形成最简单的语法(如将foreach转换成for循环、注解等),最后形成一个注解过后的抽象语法树,这个语法树更为接近目标语言的语法规则。
(1)标注检查
检查内容:变量使用前是否被声明、变量与赋值之间的数据类型是否能匹配等
常量折叠:常量相加变为一个常量
例子:int a = 1 + 2; => int a = 3;
解析类:com.sun.tools.javac.comp.Attr、com.sun.tools.javac.comp.Check
(2)数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证
验证内容:局部变量在使用前是否赋值、方法的每条路径是否有返回值、是否所有的受检异常被正确处理。
例子:final 只在编译期间保证变量的不变性
解析类:com.sun.tools.javac.comp.Flow
(3)解语法糖
语法糖:JVM不支持的语法,但为了让程序员编程简单而添加的高级语法,所以编译过程需要将高级语法还原
为简单的基础语法结构。
例子:增强for循环 => 迭代器循环
解析类:com.sun.tools.javac.comp.TransTypes 、com.sun.tools.javac.comp.Lower
通过字节码生产器将经过注解的抽象语法树转化成符合jvm规范的字节码。
该中间表示有两个重要的性质:
在Java中,javac
执行的结果就是得到一个字节码,而这个字节码其实就是一种中间代码。
著名的解语法糖操作,也是在javac中完成的。
(1)泛型与类型擦除
Java的泛型基于类型擦除,在编译期间就把泛型变为原来的裸类型。
List list = new ArrayList<>();
list.add("hello");
list.add("world");
System.out.println(list.get(0));
============================>
List list = new ArrayList();
list.add("hello");
list.add("world");
System.out.println((String)list.get(0));
(2)自动装箱、拆箱与遍历循环
List list2 = Arrays.asList(1,2,3,4,5,6);
int sum = 0;
for (int i : list2)
sum += i;
============================>
List list2 = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2),
Integer.valueOf(3), Integer.valueOf(4), Integer.valueOf(5), Integer.valueOf(6) });
int sum = 0;
for (Iterator localIterator = list2.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
// 自动装箱 int -> Integer
1 -> Integer.valueOf(1)
// 自动拆箱 Integer -> int
Integer::intValue()
// 增强for循环
for(int i : list)
->
for(Iterator localIterator = list.iterator(); localIterator.hasNext(); ){
int i = localIterator.next();
}
// 自动装箱以及自动拆箱的陷阱
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true
System.out.println(e == f); // false,==不遇到算术运算符不自动拆箱(即两个Integer比较)
System.out.println(c == (a + b)); // true
System.out.println(c.equals(a + b)); // true
System.out.println(g == (a + b)); // true
System.out.println(g.equals(a + b)); // false
注意:equals方法不处理数据转换,==方法不遇到算术运算符不会自动拆箱。
Integer a = Integer.valueOf(1);
Integer b = Integer.valueOf(2);
Integer c = Integer.valueOf(3);
Integer d = Integer.valueOf(3);
Integer e = Integer.valueOf(321);
Integer f = Integer.valueOf(321);
Long g = Long.valueOf(3L);
System.out.println(c == d);
System.out.println(e == f);
System.out.println(c.intValue() == a.intValue() + b.intValue());
System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue())));
System.out.println(g.longValue() == a.intValue() + b.intValue());
System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue()))); // Integer 与 Long比较
(3)条件编译:com.sun.tools.javac.comp.Lower完成
if (true){
System.out.println("true");
}else{
System.out.println("false");
}
===============================>
System.out.println("true");
后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。
这部分编译主要是将.class
文件翻译成机器指令的编译过程。
JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。这是传统的JVM的解释器(Interpreter)的功能。为解决效率问题,引入了 JIT 技术。
JAVA程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
HotSpot虚拟机中内置了两个JIT编译器:Client Complier(编译速度)和Server Complier(编译质量),分别用在客户端和服务端,目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。
当 JVM 执行代码时,它并不立即开始编译代码。首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。
OpenJDK HotSpot VM有两个不同的编译器,每个都有它自己的编译临界值:
在机器上,执行java -version
命令就可以看到自己安装的JDK中JIT是哪种模式:
无论是Client Complier还是Server Complier,解释器与编译器的搭配使用方式都是混合模式,即上图中的mixed mode
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中_内存资源限制较大_(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用_编译执行来提升效率_。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。
解释器的执行,抽象的看是这样的:输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
而要JIT编译然后再执行的话,抽象的看则是:输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。
JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。
所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。
怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
只被调用一次,例如类的构造器(class initializer,())
没有循环
对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。
这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。
循环体编译优化发生在方法执行过程中,称为栈上替换(On Stack Replacement,简称OSR编译,机方法栈帧还在栈上,方法就被替换了)
在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。
执行过程如下:判断是否已存在编译版本,如已存在,则执行编译版本;否则,方法计数器+1,判断两个计数器之和(注意:是方法计数器和回边计数器的和)是否超过方法计数器阈值,超过则向编译器提交编译请求,然后和不超过阈值情况下的处理方式一样,仍旧解释执行该方法。
PS:该计数器并不是绝对次数,而是相对的执行次数,即在一段时间内的执行次数,当超过一定的时间限度,若还是没有达到阈值,那么它的计数器会减少一半,此过程被称为热度衰减。
和方法计数器执行过程不同的是:当两个计数器之和超过阈值的时候,它向编译器提交OSR编译,并且调整回边计数器值,然后仍旧以解释方式执行下去。
PS:该计数器是绝对次数,没有热度衰减。
编译器:把源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,速度很快;
解释器:只在执行程序时,才一条一条的解释成机器语言给计算机来执行,所以运行速度是不如编译后的程序运行的快的;
通过javac命令将 Java 程序的源代码编译成 Java 字节码,即我们常说的 class 文件。这是我们通常意义上理解的编译。
字节码并不是机器语言,要想让机器能够执行,还需要把字节码翻译成机器指令。这个过程是Java 虚拟机做的,这个过程也叫编译。是更深层次的编译。(实际上就是解释,引入 JIT 之后也存在编译)
此时又有疑惑了,Java 不是解释执行的吗?
没错,Java 需要将字节码逐条翻译成对应的机器指令并且执行,这就是传统的 JVM 的解释器的功能,正是由于解释器逐条翻译并执行这个过程的效率低,引入了 JIT 即时编译技术。
必须指出的是,不管是解释执行,还是编译执行,最终执行的代码单元都是可直接在真实机器上运行的机器码,或称为本地代码
分层编译
Hot Spot 编译
后台执行编译优化过程(-XX:BackgroudCompilation设置来禁止后台编译)
(1)一个平台独立的前端将字节码构造成一种高级中间代码(HIR,High Level Intermediate Representation)表示。HIR使用静态单分配的形式代表代码值,使得HIR的构造过程中和之后进行优化动作更容易实现。在此之前会进行基础优化,如:方法内联、常量传播等。
(2)一个平台相关的后端从HIR中产生低级中间代码(LIR)表示,而在此之前进行HIR上的优化,如:空值检查、范围检查消除等
(3)平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,产生机器代码。
初级调优:客户模式或服务器模式
中级编译器调优
进程详情
%
jstat
-compiler 6006
TimeFailedTypeFailedMethod206 0 0 1
.97 0
高级编译器调优
当一个方法拥有编译资格时,它就会排队并等待编译。这个队列是由一个或很多个后台线程组成。编译是一个异步的过程。并且这些队列并不会严格的遵守先进先出原则:哪一个方法的调用计数器计数更高,哪一个就拥有优先权,这种优先权顺序保证最重要的代码被优先编译。
JIT除了具有缓存的功能外,还会对代码做各种优化,例如:逃逸分析、锁消除、锁膨胀、方法内联、数据边界检查、空值检查消除、类型检测消除、公共子表达式消除等。
// 优化前
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y = b.get();
// ...do stuff
z = b.get();
sum = y + z;
}
// 内联优化后
public void foo(){
y = b.value;
// ...do stuff
z = b.value;
sum = y + z;
}
// 冗余访问消除或公共子表达式消除后
public void foo(){
y = b.value;
// ...do stuff
z = y;
sum = y + z;
}
// 复写传播后
public void foo(){
y = b.value;
// ...do stuff
y = y;
sum = y + y;
}
// 无用代码消除后
public void foo(){
y = b.value;
// ...do stuff
sum = y + y;
}
基本行为:分析对象的作用域
根据逃逸分析证明一个对象不会逃逸到方法或线程中,则进行高效的优化
逃逸分析(Escape Analysis)是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,这种行为被称为方法逃逸。甚至还可能被外部线程访问,这种行为被称为线程逃逸。
public static StringBuffer craeteStringBuffer(String s1, String
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部
上述代码如果想要StringBuffer sb不逃出方法,可以这样写:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
使用逃逸分析,编译器可以对代码做如下优化:
同步省略。
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
将堆分配转化为栈分配。
如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
分离对象或标量替换。
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis
: 表示开启逃逸分析 -XX:-DoEscapeAnalysis
: 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis
在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
public static void main(String[] args) {
long a1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
// 查看执行时间
long a2 = System.currentTimeMillis();
System.out.println("cost " + (a2 - a1) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
其实代码内容很简单,就是使用for循环,在代码中创建100万个User对象。
我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。
我们指定以下JVM参数并运行:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
在程序打印出 cost XX ms
后,代码运行结束之前,我们使用[jmap]
命令,来查看下当前堆内存中有多少个User对象:
➜ ~ jps
2809 StackAllocTest
2810 Jps
➜ ~ jmap -histo 2809
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
从上面的jmap执行结果中我们可以看到,堆中共创建了100万个StackAllocTest$User
实例。
在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中。也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的。即所有对象都分配到堆内存中。
接下来,我们开启逃逸分析,再来执行下以上代码。再来看看堆内存中有多少个User对象
➜ ~ jps
709
2858 Launcher
2859 StackAllocTest
2860 Jps
➜ ~ jmap -histo 2859
num #instances #bytes class name
----------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
从以上打印结果中可以发现,开启了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTest$User
对象。也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了8万。
除了以上通过jmap验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。
通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
package com.winwill.lock;
public class TestLockEliminate {
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public static void main(String[] args) {
long tsStart = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
getString("TestLockEliminate ", "Suffix");
}
System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");
}
}
getString()方法中的StringBuffer数以函数内部的局部变量,作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。使用如下参数运行上面的程序:
-XX:+DoEscapeAnalysis -XX:-EliminateLocks
结果:
一共耗费:233 ms
使用如下命令运行程序:
-XX:+DoEscapeAnalysis -XX:+EliminateLocks
结果:
一共耗费:192 ms
由上面的例子可以看出,关闭了锁消除之后,StringBuffer每次append都会进行锁的申请,浪费了不必要的时间,开启锁消除之后性能得到了提高。
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。 对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
// 未优化前
int d = (c * b) * 12 + a + (a + b + c);
// 公共子表达式消除后
int E = c * b;
int d = E * 12 + a + (E + a);
// 代数化简后
int d = 13 * E + 2 * a;
该方法是针对Client而言的,方法调用本身是有代价的,要从常量池找到方法地址,然后保存当前栈帧状态,压入新栈帧启动调用过程,调用完弹出,并恢复调用者栈帧。而在运行期,如果方法很频繁的执行,就会运行期把方法内联到调用者方法内部,减少频繁调用的开销。-XX:+PringInlining
来查看方法内联信息,-XX:MaxInlineSize=35
控制编译后文件大小。
// 优化前
public static void foo(Object obj){
if(obj != null){
Sout("do something");
}
}
public static void testInline(String[] args){
Object obj = null;
foo(obj);
}
// 优化后
public static void testInline(String[] args){
Object obj = null;
if(obj != null){
Sout("do something");
}
}
jvm在数组访问过程中会检查访问的下标是否越界,这本来是一个为了安全性提供的功能,但是像下边这段代码,在数组访问过程中,每次都去校验,性能损耗也是很大的。jvm在运行期做了优化,只用校验用来访问数组的起始下标在0到数组最大长度-1之内那么整个循环中就可以把数组的上下界检查消除掉,这可以节省很多次的条件判断操作。通过数据流分析实现。
int [] arrs = new int[10000];
for(int i = 0;i < 10000;i++){
arrs[i] = i;
}
参考资料:
链接:深入浅出 JIT 编译器
链接:什么是即时编译(JIT)!?OpenJDK HotSpot VM剖析
链接:深入分析Java的编译原理-HollisChuang's Blog
链接:对象和数组并不是都在堆上分配内存的。-HollisChuang's Blog
扩展阅读:http://www.cnblogs.com/wade-luffy/p/5925728.html