精华内容
下载资源
问答
  • 程序编译代码优化

    千次阅读 2016-05-31 15:20:23
    一早期(编译期)优化 1概述 Java语言的“编译期”是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的...

    一早期(编译期)优化

    1概述

    Java语言的“编译期”是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,just in time compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,ahead of time compiler)直接把*.java文件编译成本地机器代码的过程。下面列举了这三类编译过程中一些比较有代表性的编译器:                                                     Ø 前端编译器:sun的javac、eclipse JDT中的增量式编译器(ECJ)。

    Ø JIT编译器:HotSpot VM 的C1、C2编译器。

    Ø AOT编译器:GNU Compiler for the java、Excelsior JET。

    这三类过程中最符合大家对java程序编译认知的应该是第一类,在后面的讲解里,提到的“编译期”和“编译器”都仅限于第一类编译过程。限制了编译范围后,对于“优化”二字的定义就需要宽松一些,因为javac这类编译器对代码的运行效率几乎没有任何优化措施(在JDK1.3之后,javac的-O优化参数就不再有意义了)。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的class文件也同样能享受到编译器优化带来的好处。

    但是javac做了许多针对编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

    2.javac 编译器

    分析源码是了解一项技术实现内幕的最有效的手段,javac编译器不像HotSpot虚拟机那样使用c++语言(包含少量C语言)实现,它本身就是一个由java语言编写的程序,这为纯java的程序员了解它的编译过程带来了很大的便利。

    2.1 javac 的源码与调试

    虚拟机规范严格定义了Class文件的格式,但是对如何把java源码文件转变为class文件的编译过程未作任何定义,所以这部分内容是与具体JDK实现相关的。从sun javac的代码来看,编译过程大致可以分为三个过程,分别是:

    • 解析与填充符号表过程;
    •  插入式注解处理器的注解处理过程;
    • 分析与字节码生成过程。

    Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述三个过程的代码逻辑集中在这个类的compile()和compile2()方法里。整个编译最关键的处理是由8个方法来完成的,分别是:

    [JavaScript] view plaincopy

    1. initProcessAnnotations(processors);//准备过程:初始化插入式注解处理器  

    2. delegateCompiler =   

    3.     processAnootations(  //过程2:执行注解处理  

    4.     enterTrees(stopIfError(CompileState.PARSE,    //过程1.2:输入到符号表  

    5.     parseFiles(sourceFileObject))),      //过程1.1:词法分析、语法分析  

    6.     classnames);  

    7.       

    8. delegateCompiler.compile2();   //过程3:分析及字节码生成  

    9.     case BY_TODO:  

    10.     while(! todo.isEmpty())  

    11.     generate(desugar(flow(attribute(todo.remove()))));  

    12.     break;  

    13.     //generate,过程3.4:生成字节码  

    14.     //desugar,过程3.3解语法糖  

    15.     //flow,过程3.2:数据流分析  

    16.     //attribute,过程3.1:标注      

    语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J. Landin发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

    Java在现代编程语言之中属于“低糖语言”(相对于C#及许多其他jvm语言来说),尤其是JDK1.5之前的版本,“低糖”语法也是java语言被怀疑已经“落后”的一个表面理由。Java中最常用的语法糖主要是泛型、变长参数、自动装箱拆箱,等等,虚拟机运行时不支持这些语法,它们在编译阶段被还原回简单的基础语法结构,这个过程就被称为解语法糖。

    3.java语法糖的味道

    几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少代码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用含糖的语法容易让程序员产生依赖,无法看清语法糖的糖衣背后程序代码的真实面目。

    总而言之,语法糖可以看做是编译器实现的一些小把戏,这些小把戏可能会使得效率有一个大提升,但我们也应该去了解这些小把戏背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。

    3.1泛型与类型擦除

    泛型是JDK1.5的一项新特性,它的本质是参数化类型(Parameterized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

    泛型思想早在C++语言的模板(Template)中就开始生根发芽,在java语言还没有出现泛型时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如在哈希表的存取中,JDK1.5之前使用HashMap的get()方法,返回值就是一个Object对象,由于java语言里面所有的类型都继承于java.lang.Object,那Object转型成任何对象都是有可能的。但是也因为有无限的可能,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会被转嫁到程序运行期中。

    泛型技术在C#和java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面泛型无论在程序源码之中、编译后的IL中(Intermediate Language,中间语言,这时候泛型是一个占位符)还是在运行期的CLR(common language runtime)中都是切实存在的,List<int>与List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现成为类型膨胀,基于这种方法实现的泛型被称为真实泛型。

    Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经被替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此对于运行期的java语言来说,ArrayList<int>与ArrayList<String>就是同一个类。所以说泛型技术实际上是java语言的一颗语法糖,java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型被称为伪泛型。

    代码1是一段简单的java泛型例子,我们看一下它编译后的结果:

    1. <span style="font-size:18px;"></span><pre name="code" class="java">public static void main(String[] args){  

    2.         Map<String, String> map = new HashMap<String, String>();  

    3.         map.put("hello", "你好");  

    4.         map.put("how are you", "吃了没");  

    5.         System.out.println(map.get("hello"));  

    6.         System.out.println(map.get("how are you"));  

    7.     }  

    把这段代码编译成class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了java泛型出现之前的写法,泛型类型都变回了原生类型:

    1. <span style="font-size:16px;">public static void main(String[] args){  

    2.         Map map = new HashMap();  

    3.         map.put("hello", "你好");  

    4.         map.put("how are you", "吃了没");  

    5.         System.out.println((String)map.get("hello"));  

    6.         System.out.println((String)map.get("how are you"));  

    7.         }</span>  

    当初JDK设计团队为什么选择类型擦除的方式来实现java语言的泛型支持呢?是因为实现简单、兼容性考虑还是别的原因?我们不得而知,但确实有不少人对java语言提供的伪泛型颇有微词,当时甚至连《thinging in java》的作者Bruce Eckel也发表了一篇文章《这不是泛型!》来批评JDK1.5的泛型实现。

    1. public class GenericTypes {  

    2.   

    3.     public static void method(List<String> list){  

    4.         System.out.println("invoke method(List<String> list)");  

    5.     }  

    6.       

    7.     public static void method(List<Integer> list){  

    8.         System.out.println("invoke method(List<Integer> list");  

    9.     }  

    10.     }  

    请想一想,上面这段代码是否正确,能否编译执行?答案是不能被编译的,是因为参数List<Integer>和List<String>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两个方法的特征签名变的一模一样。初步来看,无法重载的原因已经找到了,但是真的如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的一部分原因,请再看一下下面的代码:

    1. public class GenericTypes {  

    2.   

    3.     public static String method(List<String> list){  

    4.         System.out.println("invoke method(List<String> list)");  

    5. return "";  

    6.     }  

    7.       

    8.     public static int method(List<Integer> list){  

    9.         System.out.println("invoke method(List<Integer> list");  

    10.     }  

    11.     return "1";  

    12.     }  

    这两段代码的差别,是两个method方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。重载的时候,方法名要一样,但是参数类型和个数不一样,返回值类型可以相同也可以不相同。无法以返回型别作为重载函数的区分标准。  

    上面代码中的重载当然不是根据返回值来确定的,之所以这次编译和执行能成功,是因为两个method()方法加入了不同的返回值后才能共存在一个class文件中。之前介绍过class文件内容中方法表(method_info),方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法的共存于一个class文件中。

    3.2自动装箱、拆箱与遍历循环

    自动装箱、拆箱与遍历循环是java语言里使用的最多的语法糖。如下代码所示:

    public static void main(String[] args){  

    1.         List<Integer> list = Arrays.asList(1,2,3,4);  

    2.           

    3.         int sum = 0;  

    4.         for(int i:list){  

    5.             sum += i;  

    6.         }  

    7.         System.out.println(sum);  

    8.         }  

    9.     //上述代码编译之后的变化:  

    10.     public static void main(String[] args){  

    11.         List list = Arrays.asList(new Integer[]{  

    12.                 Integer.valueOf(1),  

    13.                 Integer.valueOf(2),  

    14.                 Integer.valueOf(3),  

    15.                 Integer.valueOf(4),  

    16.         });  

    17.           

    18.         int sum = 0;  

    19.         for(Iterator localIterator = list.iterator(); localIterator.hasNext();){  

    20.             int i = ((Integer)localIterator.next()).intValue();  

    21.             sum += i;  

    22.         }  

    23.         System.out.println(sum);  

    24.         }  

    上面第一段代码中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数五种语法糖,上面第二段代码则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例子中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。

    这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,下面的代码演示了自动装箱的一些错误用法:

    1. public static void main(String[] args){  

    2.         Integer a = 1;  

    3.         Integer b = 2;  

    4.         Integer c = 3;  

    5.         Integer d = 4;  

    6.         Integer e = 321;  

    7.         Integer f = 321;  

    8.         Long g = 3L;  

    9.           

    10.         System.out.println(c == d);    //false 值不等  

    11.         System.out.println(e == f);    //false  堆位置不同  

    12.         System.out.println(c == (a + b)); //true   "+"运算符拆包  

    13.         System.out.println(c.equals(a + b));//true equals 值比较  

    14.         System.out.println(g == (a+b));//true   "+"运算符拆包  

    15.         System.out.println(g.equals(a+b));//false   类型不同  

    16.     }  

    请思考两个问题:一是代码中的6句打印语句输出时什么?二是6句打印语句中,解除语法糖后参数是什么样?鉴于包装类的“==”运算在没有遇到算数运算的情况下不会自动拆箱,而且它们的equals()方法不会处理数据转型的关系,我们建议在实际编码中应该尽量避免这样使用装箱与拆箱。integer 的范围:-128~127,在这个范围内的integer和int是一样的,还没有在堆中开辟空间。

    3.3条件编译

    许多程序设计语言都提供了条件编译的途径,如C、C++中使用预处理器指示符(#ifdef)来完成条件编译。C、C++的预处理器最初的任务是解决编译时的代码依赖关系(众所周知的#include预处理指令),而在java语言之中并没有使用预处理器,因为java语言天然的编译方式(编译器并非一个一个的编译java文件,而是将所有的编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无需使用预处理器。那java语言是否有办法实现条件编译呢?

    Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。如下代码所示,此代码中的if语句不同于其他java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括System.out.println("block 1");一条语句,并不会包含if语句及另外一个分支中的System.out.println("block 2");。

    1. <span style="font-family:'Microsoft YaHei';font-size:16px;">    

    2.     public static void main(String[] args){  

    3.         if(true){  

    4.             System.out.println("block 1");  

    5.         }else{  

    6.             System.out.println("block 2");  

    7.         }  

    8.         }  

    9.     //此代码编译后class文件的反编译结果:  

    10.     public static void main(String[] args){  

    11.             System.out.println("block 1");  

    12.     }</span>  

    只能使用条件为常量的if语句才能达到上述效果,如果使用常量和其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如下面代码所示,就会被编译器拒绝编译:

    1. <span style="font-family:'Microsoft YaHei';font-size:16px;">public static void main(String[] args){  

    2.         while(false){  

    3.             System.out.print("");  

    4.             }</span>  

    java语言中条件编译的实现,是java语言的一个语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖的阶段完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的java语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个java类的结构。

    除了我们介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的switch支持、在try语句中定义和关闭资源等,可以通过跟踪javac源码、反编译class文件等方式了解它们的本质实现。


     

    二.晚期(运行期)优化

    1.概述

    JAVA最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”而将它们编译成本地机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器(Just In Time Compiler)。

           Java虚拟机规范并没有规定虚拟机内必须要有即时编译器。但是,即时编译器性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心最能体现技术水平的部分。

    2.HotSpot虚拟机内的即使编译器

           HOTSPOT和J9都是解释器和编译器并存,保留解释器的原因是,加快启动时间,立即执行,当运行环境中内存资源限制较大时,解释器可以节约内存,解释器还可以作为激进优化的编译器的“逃生门”(称为逆优化Deoptimization),而编译器能把越来越多的代码编译成本地代码后,获取更高的执行效率。

           HOTSPOT内置了两个即时编译器,clientcompiler和servercompiler,称为C1,C2(clientcompiler获取更高的编译速度,servercompiler来获取更好的编译质量),默认是采用解释器与其中一个编译器直接配合的方式工作。HOTSPOT会根据自身版本和宿主机器的性能自动选择运行模式,用户也可以使用-client或-server来决这种解释器编译器搭配的方式成为混合模式,用户还可以使用-Xint强制虚拟机使用“解释模式”,也可以使用-Xcomp强制“编译模式”。

    被编译的触发条件:

    1.   被多次调用的方法

    2.   被多次执行的循环体(栈上替换)OSR On StackReplacement

    判断是否是热点代码的行为成为热点探测:hot spotdetection,主要的热点探测方式主要有两种:

    1.   基于采样的热点探测,JVM会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那就认定为热点方法。简单高效,精度不够。

    2.   基于计数器的热点探测,统计方法执行次数。(HOTSPOT使用这种方式)

    HOTSPOT有两个计数器:方法调用计数器和回边计数器

    方法调用计数器client默认1500次,server默认10000次,可以通过参数-XX:CompileThreshold来设定。调用方法时,会先判断是否存在编译过的版本,如果有则调用该版本,否则计数器加1,然后看方法调用计数器和回边计数器之和是否超过方法调用计数器的阈值。超过,则提交编译请求

    方法调用计数器并不是统计方法调用绝对次数,而是一个相对执行频率,超过一定时间,如果方法调用次数不足以让它提交给编译器,则计数器就会被减少一半,这种现象称为热度衰减(Counter Decay)进行热度衰减的动作是在垃圾回收时顺便进行的,而这段时间就被称为半衰周期(Counter Half Life Time可用-XX:-UseCounterDecay来关闭热度衰减,用-XX:CounterHalfLifeTime来设置半衰时间。

    回边计数器用于统计方法中循环体的执行次数。字节码遇到控制流向后跳转 的指令成为回边。建立回边计数器统计的目的就是为了触发OSR编译。回边的控制参数有:

    -XX:BackEdgeThreshold,-XX:OnStackReplacePercentage。

           1.在Client模式下,回边计数器阀值计算公式:方法调用计数器阀值乘以OSR比率,然后除以100.

           2.在server模式下,回边计数器阀值计算公式:方法调用计数器阀值乘以(OSR比率,然后减去解释器监控比率的差值)除以100。

           与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。

    编译过程

           对Client Compiler而言,是一个简单快速的三段式编译器,主要关注点在于局部性的优化,放弃了许多耗时较长的全局优化手段。

    1.   第一阶段,一个平台独立的前段将字节码构造成一种高级中间代码表示(HIR)。

    2.   第二阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR),而在此之前会在HIR上完成一些优化。

    3.   最后节点是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上座窥孔优化,然后产生极其代码。

           对Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器。它会执行所有的经典的优化动作,如:无用代码消除,循环展开,循环表达式外提,公共子表达式消除,常量传播,基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除,空值检查消除。

     

    3编译优化技术

           JDK设计团队几乎把代码的所有优化措施都集中在了即使编译器,所以一般来说即即时编译器产生的本地代码会比Javac产生的字节码更优秀。接下来介绍几种景点优化技术:

           1.公共子表达式消除:如果一个表达式E已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。若这种优化仅限于程序基本块内,称为局部公共子表达式消除;若这种优化的范围涵盖了多个基本块,就称为全局公共子表达式消除。

           2.数组边界检查消除:在Java语言中访问数组元素的时候系统将会自动进行上下界的范围检查,即检查i必须满足i>=0 && i<foo.length这个条件。

           3.方法内联:它除了消除方法调用的成本之外,更重要的意义是为其他优化手段建立良好的基础。由于Java语言中默认的实例方法就是虚方法,对于虚方法,编译器做内联的时候根本就无法确定应该使用哪个方法版本。为了解决虚方法的内联问题,引入了“类型继承关系分析”。编译器在进行内联时,如果是非虚方法,那么直接进行内联,如果是虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个逃生门,万一加载了导致继承关系发生变化的新类,那就需要退回到解释状态执行,或者重新编译。

           4.逃逸分析:它是为其他优化手段提供依据的分析技术。基本行为就是分析对象动态作用于,当一个对象在方法里面被顶以后,它可能被外部方法所引用,称为线程逃逸。若能证明一个对象不会逃逸到方法或线程之外,就可以进行一些高效的优化,如:栈上分配(对象所占用的内存空间可以随栈帧出栈而销毁,若在堆里分配的话,回收和整理内存都需要消耗时间),同步消除(线程同步本身就是一个相对耗时的过程,若确定不会逃逸出线程,对这个变量就不需要实施同步措施),标量替换(将Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问,若不会逃逸的话,执行程序的时候将可能不创建对象,而直接创建它的若干个被这个方法使用到的成员变量来代替)。

     

    4Java与c/c++编译器对比

           Java与c/c++的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比。Java可能会下列原因导致输出本地代码有一些劣势:

    1.   首先,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。

    2.   其次,Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言的语义或访问非结构化内存。

    3.   第三,Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于c/c++语言,这就意味着运行时对方法接受者进行多态选择的频率要远远大于c/c++语言,也以为即时编译器在进行一些优化时的难度远远大于c/c++的静态优化编译器。

    4.   第四,Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。

    5.   第五,Java语言中对象的内存分配都是在堆上进行的,只有方法中的局部变量才能在栈上分撇。

    Java语言的这些性能上的劣势都是为了换取开发效率上的优势,动态安全、动态扩展、垃圾回收这些特性都为Java语言的开发效率做出了很大的贡献。
    展开全文
  • JVM-5.程序编译代码优化

    千次阅读 2019-08-08 15:15:51
    但并不是所有的字节码都会被编译,大部分代码都是直接解释执行(使用解释器),只有热点代码才会被编译成机器码。 编译优化(javac编译器) 编译过程 解析填充符号 解析的过程分为词法语法解析填充符号,...

    目录:

    JVM-1.自动内存管理

    JVM-2.字节码和字节码指令

    JVM-3.类的加载机制

    JVM-4.字节码执行和方法调用

    JVM-5.程序编译与代码优化

    JVM-6.Java线程内存模型和线程实现

    java中有两种编译,第一种是由java代码编译成字节码(使用javac编译器),第二种则是由字节码编译成对应平台的机器码(使用即使编译器JIT)。但并不是所有的字节码都会被编译,大部分代码都是直接解释执行(使用解释器),只有热点代码才会被编译成机器码。

    编译期优化(javac编译器)

    编译过程

    在这里插入图片描述

    • 解析和填充符号
      解析的过程分为词法语法解析和填充符号,词法解析是把源代码解析成最小元素,如“int a=b+2, int,a,=,b,+,2都是一个元素。语法解析就是根据词法解析的元素,构建一个抽象语法树。抽象语法树的每个节点代表一个语法结构,如包,修饰符,运算符甚至是注释。
      符号表是一个键值对形式的结构,key是符号地址,value是符号信息,符号表在后续的操作中都有使用,如进行语义检查时,检查一个名字的使用和原先的说明是否一致(类型等)。

    • 注解处理器
      注解其实就是在编译阶段对抽象语法树进行了修改,为类方法等打上标记。

    • 语义分析与字节码合成
      语义分析的作用时保证源代码符合逻辑,比如类型审查等。
      在这个阶段有一个值得注意的是一个编译优化:常量折叠。

    int a = 1+2//在编译时会被优化成
    int a = 3//同样的,字符串也会有这样的优化
    String  s = "a"+"b";
    //和下面的代码在编译后时一致的,所以常量池中只有“ab”而没有“a”和“b”
    String s = "ab";
    

    字节码合成则是把抽象语法树转换为字节码,当然还做了其他的一些操作,比如把静态代码块和静态字段的赋值合并成一个()函数。

    解语法糖

    在语义分析的过程中,还会对代码进行解语法糖的操作

    泛型与类型的擦除

    在java中,可以在代码中编写泛型代码,但是在编译后,泛型中的类型却被擦除了,使用强制转换替代。所以实际上java的泛型只是一种语法糖。在java中List<String>和List<Integer>是同一个类,因为在编译后,其类型被擦除了,而在其他语义如C#中,List<int>和List<string>是两个不同的类。所以如果你编写以下的java代码,会出现编译错误。

    public class GenericTypes {
        public static void method(List<String> list) {
            System.out.println("invoke method(List<String>list)");
        }
    
        public static void method(List<Integer> list) {
            System.out.println("invoke method(List<Integer>list)");
        }
    }
    
    自动装箱拆箱和循环
    List<Integer> list= Arrays.asList(1,2,3,4);
    //编译后自动装箱代码
    List list = Arrays.asList(new Integer[]{
                    Integer.valueOf(1),
                    Integer.valueOf(2),
                    Integer.valueOf(3),
                    Integer.valueOf(4)});
    
    
    for (int index:list){    }
    //编译后循环代码
    for(Iterator localIterator=list.iterator();localIterator.hasNext();){
         int i=((Integer)localIterator.next()).intValue();
    }
    

    自动装箱拆箱的陷阱

    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);
    System.out.println(e==f);
    System.out.println(c==(a+b));
    System.out.println(c.equals(a+b));
    System.out.println(g==(a+b));
    System.out.println(g.equals(a+b));
    System.out.println(new Long(1).equals(1));
    

    上面打印出来的结果是什么?

    true
    false
    true
    true
    true
    false
    false
    
    • ==号比较的是对象的地址,equals比较的是对象的内容。
    • c==d为true:是因为-128~127的int数据,已经被缓存在常量池中了,所以并不会重新创建,3指向的都是常量池中的对象。
    • e==f为false:和上面的原因一样,由于并没有在常量池的范围内,所以各种创建了新的对象。因此地址是不一样的。
    • c==(a+b)为true,a+b运算的结果为3,这个运算的结果也是在常量池中,因此和第一条的原因一致。
    • c.equals(a+b)为true:比较的是值,当然是相等的。
    • g==(a+b)为true,这个的原因就是,这里会发生装箱,实际上该运算中,(a+b)会被提升成Long类型,而3L也在常量池中,因此他们的地址也是一致的。
    • g.equals(a+b)为false:不同类型的比较内容,当然是false
    • new Long(1).equals(1)为false:equals括号中的1是int类型,而new Long(1)是Long类型,也是不同的,必须使用new Long(1).equals(1L),标记equals括号中的1是Long类型
    条件编译

    对于无法到达的代码,字节码中是不存在的。

    int a=0;
    if(true){
        a=1;
    }
    else{
        a=2;
    }
    //编译后
    int a = 1;
    

    运行期优化

    javac编译器是将源码编译成字节码,而运行期则会把代码编译成对应平台的机器码。我们知道jvm在运行时,解释器和编译器是同时存在的。解释器把代码直接解释后执行,而编译器则是先编译后执行。但是思考一个问题,不管是解释还是编译,不都是转换成机器码码?为什么不直接保存解释后的机器码呢?
    实际上,编译器不仅仅是是对代码进行编译,还会对代码进行优化,提高运行效率。而且保存代码也需要耗费空间,对于不常用的代码,这种耗费是没有必要的。

    即时编译器(JIT)

    • c1和c2
      我们都知道JIT,但是不知道JIT实际上有两类,分别是C1和C2,C1的作用是对方法进行快速的编译,这时会使用一些代码优化的技术,但是不会使用比较激进的优化,所以编译速度比较快。而C2会执行一些激进的代码优化,编译慢,但是提升的效率高。不过过于激进的优化,也有可能出现“罕见缺陷”,这时候会退化成解释器执行。
    • 分层编译
      jvm会收集性能信息,让代码使用不同层次的编译。
    • 0层:解释执行
    • 1层:使用C1编译器进行简单,可靠的优化
    • 2层:使用C2编译器,启用耗时较长的优化,使用一些激进的优化技术

    编译对象和触发条件

    • 那么什么时候会触发编译呢?依据是什么?
      编译的最小对象是方法,被编译的对象是被多次调用的方法和多次执行的循环体(对应得方法)
    • 那么什么才算是多次呢?
      jvm使用基于计数器的热点探测技术:方法为每一个方法设置方法调用计数器和回边计数器(用于统计循环的执行次数),虚拟机会判断两个计数器之和是否大于阈值,如果大于阈值就会被标记为热点代码。在Client模式下阈值是1500,在server模式下是10000次。可通过-XX:CompileThreshold虚拟机参数修改。
      另外,方法计数器并不是只增不减,如果一个方法一段时间没有被调用,那么就会减少一半的数值(半衰期),但是回边计数器则不会减少,计算的是绝对调用次数。
      虚拟机参数:-XX:CounterHalfLifeTime可设置半衰期时间(秒)

    编译过程

    • 阻塞或者后台编译
      默认编译是后台编译,在编译完成前,仍然使用解释执行,可通过-XX:-BackgroundCompilation来禁止后台编译,变为阻塞。

    Client编译器(C1编译器)编译过程

    - 一阶段: 编译为高级中间代码,进行基础优化,如方法内联
    - 二阶段: 编译为低级中间代码,做一些优化:如空值检查消除等
    - 三阶段: 编译为机器码,分配寄存器,窥孔优化(如删除无效的代码,合并冗余的操作)
    

    Server编译器(C2编译器)在C1的基础上使用更多的优化技术

    • 公共子表达式的消除
    int d = (c*b)*12+a+a+b*c
    //上面的语句b*c出现了两次,所以可能被优化成以下语句
    int E = c*b;
    int d = E*12+a+a+E
    //编译器还可能进行代数优化
    int d = E*13+2*a;
    
    • 数组边界检查消除
      再编写数组相关的代码时,我们编写很多检查数组越界的代码,如果虚拟机通过数据流分析发现,取值范围都是合法的,那么就会去掉数组边界检查的代码,以减少判断。

    • 方法内联
      方法内联的思路很简单,就是把被调用的方法直接搬到调用方法的点,这样可以省去调用方法的花费。
      但是再上篇文章也提到过,静态分派的方法(构造器,私有方法,父类方法荷final修饰的方法)才能唯一确定调用的时哪个方法,而对于其他的方法(虚方法),只有再运行时才能确定调用的是哪个方法,这时候如何使用内联呢?
      为了使得虚方法可以内联,JVM引入了“类型继承分析”,作用是判断一个方法是否有多种实现(父类实现,子类实现),如果只有一种版本的实现,那么这个时候也是可以内联的。不过这个属于激进的优化方式,因为继承关系可能会随着新加载的类而导致变化,这个时候这个编译的代码就会被遗弃了。
      这也是为什么使用final荷private修饰方法可以提高运行速度的原因了。

    • 逃逸分析
      如果一个对象再方法中被定义,但是作为参数传到其他方法或者被其他线程使用,分别称之为方法逃逸和线程逃逸。
      如果能证明一个对象即没有方法逃逸也没有线程逃逸,那么就可以做以下高级优化

      • 栈上分配。没错,对象直接在栈上分配,而不是在堆里分配。随着方法的退出而直接销毁对象而不需要垃圾回收。
      • 同步消除。同步指的是线程间的同步,同步耗费时间,如果没有其他线程调用,当然不需要做同步操作,于是相关的同步操作就会被消除。
      • 标量替换:如果一个对象没有逃逸,那么在创建对象时,可能只是创建被使用到的对象中的若干成员变量。
        使用以下虚拟机变量开启
        • -XX:+DoEscapeAnalysis:手动开启逃逸分析 (JDK1.6之后默认开启)
        • -XX:+EliminateAllocations:开启标量替换
        • +XX:+EliminateLocks:开启同步消除
    展开全文
  • 文章目录早期(编译期)优化概述Javac编译器Javac的源码与调试解析与填充符号表注解处理器语义分析与字节码生成Java 语法糖的味道泛型与类型擦除自动装箱、拆箱与遍历循环条件编译实战:插入式注解处理器晚期(运行...

    本博客主要参考周志明老师的《深入理解Java虚拟机》第二版

    读书是一种跟大神的交流。阅读《深入理解Java虚拟机》受益匪浅,对Java虚拟机有初步的认识。这里写博客主要出于以下三个目的:一方面是记录,方便日后阅读;一方面是加深对内容的理解;一方面是分享给大家,希望对大家有帮助。

    《深入理解Java虚拟机》全书总结如下:

    序号 内容 链接地址
    1 深入理解Java虚拟机-走近Java https://blog.csdn.net/ThinkWon/article/details/103804387
    2 深入理解Java虚拟机-Java内存区域与内存溢出异常 https://blog.csdn.net/ThinkWon/article/details/103827387
    3 深入理解Java虚拟机-垃圾回收器与内存分配策略 https://blog.csdn.net/ThinkWon/article/details/103831676
    4 深入理解Java虚拟机-虚拟机执行子系统 https://blog.csdn.net/ThinkWon/article/details/103835168
    5 深入理解Java虚拟机-程序编译与代码优化 https://blog.csdn.net/ThinkWon/article/details/103835883
    6 深入理解Java虚拟机-高效并发 https://blog.csdn.net/ThinkWon/article/details/103836167

    从计算机程序出现的第一天起,对效率的追求就是程序天生的坚定信仰,这个过程犹如一场没有终点,永不停歇的F1方程式竞赛,程序员试车手,技术平台则是在赛道上飞驰的赛车。

    早期(编译期)优化

    概述

    Java 语言的「编译期」其实是一段「不确定」的操作过程。因为它可能是一个前端编译器(如 Javac)把 *.java 文件编译成 *.class 文件的过程;也可能是程序运行期的即时编译器(JIT 编译器,Just In Time Compiler)把字节码文件编译成机器码的过程;还可能是静态提前编译器(AOT 编译器,Ahead Of Time Compiler)直接把 *.java 文件编译成本地机器码的过程。

    Javac 这类编译器对代码的运行效率几乎没有任何优化措施,虚拟机设计团队把对性能的优化都放到了后端的即时编译器中,这样可以让那些不是由 Javac 产生的 class 文件(如 Groovy、Kotlin 等语言产生的 class 文件)也能享受到编译器优化带来的好处。但是 Javac 做了很多针对 Java 语言编码过程的优化措施来改善程序员的编码风格、提升编码效率。相当多新生的 Java 语法特性,都是靠编译器的「语法糖」来实现的,而不是依赖虚拟机的底层改进来支持。

    Java 中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说更加密切。

    Javac编译器

    Javac的源码与调试

    Javac 编译器的编译过程大致可分为 3 个步骤:

    1. 解析与填充符号表;
    2. 插入式注解处理器的注解处理;
    3. 分析与字节码生成。

    这 3 个步骤之间的关系如下图所示:

    在这里插入图片描述

    解析与填充符号表

    解析步骤包含了经典程序编译原理中的词法分析和语法分析两个过程;完成词法分析和语法分析之后,下一步就是填充符号表的过程。符号表是由一组符号地址和符号信息构成的表格。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

    注解处理器

    注解(Annotation)是在 JDK 1.5 中新增的,有了编译器注解处理的标准 API 后,我们的代码就可以干涉编译器的行为,比如在编译期生成 class 文件。

    语义分析与字节码生成

    语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,比如进行类型审查。

    字节码生成是 Javac 编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。如前面提到的 () 方法就是在这一阶段添加到语法树中的。

    在字节码生成阶段,除了生成构造器以外,还有一些其它的代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBiulder 或 StringBuffer。

    完成了对语法树的遍历和调整之后,就会把填充了所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,最终生成字节码文件,到此为止整个编译过程就结束了。

    Java 语法糖的味道

    Java 中提供了有很多语法糖来方便程序开发,虽然语法糖不会提供实质性的功能改进,但是它能提升开发效率、语法的严谨性、减少编码出错的机会。下面我们来了解下语法糖背后我们看不见的东西。

    泛型与类型擦除

    泛型顾名思义就是类型泛化,本质是参数化类型的应用,也就是说操作的数据类型被指定为一个参数。这种参数可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

    在 Java 语言还没有泛型的时候,只能通过 Object 是所有类型的父类和强制类型转换两个特点的配合来实现类型泛化。例如 HashMap 的 get() 方法返回的就是一个 Object 对象,那么只有程序员和运行期的虚拟机才知道这个 Object 到底是个什么类型的对象。在编译期间,编译器无法检查这个 Object 的强制类型转换是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期。

    Java 语言中泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型,并且在相应的地方插入了强制类型转换的代码。因此对于运行期的 Java 语言来说, ArrayList 与 ArrayList 是同一个类型,所以泛型实际上是 Java 语言的一个语法糖,这种泛型的实现方法称为类型擦除。

    自动装箱、拆箱与遍历循环

    自动装箱、拆箱与遍历循环是 Java 语言中用得最多的语法糖。这块比较简单,我们直接看代码:

    public class SyntaxSugars {
    
        public static void main(String[] args){
    
            List<Integer> list = Arrays.asList(1,2,3,4,5);
    
            int sum = 0;
            for(int i : list){
                sum += i;
            }
            System.out.println("sum = " + sum);
        }
    }
    

    自动装箱、拆箱与遍历循环编译之后:

    public class SyntaxSugars {
    
        public static void main(String[] args) {
    
            List list = Arrays.asList(new Integer[]{
                    Integer.valueOf(1),
                    Integer.valueOf(2),
                    Integer.valueOf(3),
                    Integer.valueOf(4),
                    Integer.valueOf(5)
            });
    
            int sum = 0;
            for (Iterator iterable = list.iterator(); iterable.hasNext(); ) {
                int i = ((Integer) iterable.next()).intValue();
                sum += i;
            }
            System.out.println("sum = " + sum);
        }
    }
    

    第一段代码包含了泛型、自动装箱、自动拆箱、遍历循环和变长参数 5 种语法糖,第二段代码则展示了它们在编译后的变化。

    条件编译

    Java 语言中条件编译的实现也是一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除。

    public static void main(String[] args) {
        if (true) {
            System.out.println("block 1");
        } else {
            System.out.println("block 2");
        }
    }
    

    上述代码经过编译后 class 文件的反编译结果:

    public static void main(String[] args) {
        System.out.println("block 1");
    }
    

    实战:插入式注解处理器

    感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

    晚期(运行期)优化

    概述

    在部分商业虚拟机中,Java 最初是通过解释器解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为「热点代码」(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。

    即时编译器不是虚拟机必须的部分,Java 虚拟机规范并没有规定虚拟机内部必须要有即时编译器存在,更没有限定或指导即时编译器应该如何实现。但是 JIT 编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。

    HotSpot 虚拟机内的即时编译器

    由于 Java 虚拟机规范中没有限定即时编译器如何实现,所以本节的内容完全取决于虚拟机的具体实现。我们这里拿 HotSpot 来说明,不过后面的内容涉及具体实现细节的内容很少,主流虚拟机中 JIT 的实现又有颇多相似之处,因此对理解其它虚拟机的实现也有很高的参考价值。

    解释器与编译器

    尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。

    解释器与编译器两者各有优势:

    • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码之后,可以获得更高的执行效率。
    • 当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释器执行来节约内存,反之可以使用编译执行来提升效率。

    同时,解释器还可以作为编译器激进优化时的一个「逃生门」,当编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新的类后类型继承结构出现变化、出现「罕见陷阱」时可以通过逆优化退回到解释状态继续执行。

    编译对象与触发条件

    程序在运行过程中会被即时编译器编译的「热点代码」有两类:

    • 被多次调用的方法;
    • 被多次执行的循环体。

    这两种被多次重复执行的代码,称之为「热点代码」。

    • 对于被多次调用的方法,方法体内的代码自然会被执行多次,理所当然的就是热点代码。
    • 而对于多次执行的循环体则是为了解决一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也是热点代码。

    对于第一种情况,由于是方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但是编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为发生在方法执行过程中,因此形象地称之为栈上替换(On Stack Replacement,简称 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

    我们反复提到多次,可是多少次算多次呢?虚拟机如何统计一个方法或一段代码被执行过多少次呢?回答了这两个问题,也就回答了即时编译器的触发条件。

    判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为「热点探测」。其实进行热点探测并不一定需要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种。

    • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是「热点方法」。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因数的影响而扰乱热点探测。
    • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是「热点方法」。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是统计结果相对来说更加精确和严谨。

    HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

    在确定虚拟机运行参数的情况下,这两个计数器都有一个确定的阈值,当计数器超过阈值就会触发 JIT 编译。

    方法调用计数器

    顾名思义,这个计数器用于统计方法被调用的次数。当一个方法被调用时,会首先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加 1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果超过阈值,将会向即时编译器提交一个该方法的代码编译请求。

    如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

    在这里插入图片描述

    如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期。

    进行热度衰减的动作是在虚拟机进行 GC 时顺便进行的,可以设置虚拟机参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。此外还可以设置虚拟机参数调整半衰期的时间。

    回边计数器

    回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。建立回边计数器统计的目的是为了触发 OSR 编译。

    当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否已经有编译好的版本,如果有,它将优先执行已编译的代码,否则就把回边计数器值加 1,然后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

    在这里插入图片描述

    与方法计数器不同,回边计数器没有计算热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

    编译过程

    感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

    查看及分析即时编译结果

    感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

    编译优化技术

    我们都知道,以编译方式执行本地代码比解释执行方式更快,一方面是因为节约了虚拟机解释执行字节码额外消耗的时间;另一方面是因为虚拟机设计团队几乎把所有对代码的优化措施都集中到了即时编译器中。这一小节我们来介绍下 HotSpot 虚拟机的即时编译器在编译代码时采用的优化技术。

    优化技术概览

    代码优化技术有很多,实现这些优化也很有难度,但是大部分还是比较好理解的。为了便于介绍,我们先从一段简单的代码开始,看看虚拟机会做哪些代码优化。

    static class B {
        int value;
        final int get() {
            return value;
        }
    }
    
    public void foo() {
        y = b.get();
        z = b.get();
        sum = y + z;
    }
    

    首先需要明确的是,这些代码优化是建立在代码的某种中间表示或者机器码上的,绝不是建立在 Java 源码上。这里之所使用 Java 代码来介绍是为了方便演示。

    上面这段代码看起来简单,但是有许多可以优化的地方。

    第一步是进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施。方法内联的目的主要有两个,一是去除方法调用的成本(比如建立栈帧),二是为其它优化建立良好的基础,方法内联膨胀之后可以便于更大范围上采取后续的优化手段,从而获得更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最前面。内联优化后的代码如下:

    public void foo() {
        y = b.value;
        z = b.value;
        sum = y + z;
    }
    

    第二步进行冗余消除,代码中「z = b.value;」可以被替换成「z = y」。这样就不用再去访问对象 b 的局部变量。如果把 b.value 看做是一个表达式,那也可以把这项优化工作看成是公共子表达式消除。优化后的代码如下:

    public void foo() {
        y = b.value;
        z = y;
        sum = y + z;
    }
    

    第三步进行复写传播,因为这段代码里没有必要使用一个额外的变量 z,它与变量 y 是完全等价的,因此可以使用 y 来代替 z。复写传播后的代码如下:

    public void foo() {
        y = b.value;
        y = y;
        sum = y + y;
    }
    

    第四步进行无用代码消除。无用代码可能是永远不会执行的代码,也可能是完全没有意义的代码。因此,又被形象的成为「Dead Code」。上述代码中 y = y 是没有意义的,因此进行无用代码消除后的代码是这样的:

    public void foo() {
        y = b.value;
        sum = y + y;
    }
    

    经过这四次优化后,最新优化后的代码和优化前的代码所达到的效果是一致的,但是优化后的代码执行效率会更高。编译器的这些优化技术实现起来是很复杂的,但是想要理解它们还是很容易的。接下来我们再讲讲如下几项最有代表性的优化技术是如何运作的,它们分别是:

    • 公共子表达式消除;
    • 数组边界检查消除;
    • 方法内联;
    • 逃逸分析。

    公共子表达式消除

    如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就好了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围覆盖了多个基本块,那就称为全局公共子表达式消除。

    数组边界检查消除

    如果有一个数组 array[],在 Java 中访问数组元素 array[i] 的时候,系统会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < array.length,否则会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException,这就是数组边界检查。

    对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这是一种不小的性能开销。为了安全,数组边界检查是必须做的,但是数组边界检查并不一定每次都要进行。比如在循环的时候访问数组,如果编译器只要通过数据流分析就知道循环变量是不是在区间 [0, array.length] 之内,那在整个循环中就可以把数组的上下界检查消除。

    方法内联

    方法内联前面已经通过代码分析介绍过,这里就不再赘述了。

    逃逸分析

    逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

    如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法和线程无法通过任何途径访问到这个方法,则可能为这个变量进行一些高效优化。比如:

    1. 栈上分配:如果确定一个对象不会逃逸到方法之外,那么就可以在栈上分配内存,对象所占的内存空间就可以随栈帧出栈而销毁。通常,不会逃逸的局部对象所占的比例很大,如果能栈上分配就会大大减轻 GC 的压力。
    2. 同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,因而变量的同步措施也就可以消除了。
    3. 标量替换:标量是指一个数据无法再拆分成更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步拆分,所以它们就是标量。相反,一个数据可以继续分解,那它就称作聚合量,Java 中的对象就是聚合量。如果把一个 Java 对象拆散,根据访问情况将其使用到的成员变量恢复成原始类型来访问,就叫标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。对象被拆分后,除了可以让对象的成员变量在栈上分配和读写,还可以为后续进一步的优化手段创造条件。

    Java与C/C++编译器对比

    感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

    总结

    本文介绍了 Java 程序从源代码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。

    展开全文
  • 编译原理之代码优化

    万次阅读 多人点赞 2017-12-18 10:43:49
    编译原理出于代码编译的模块化组装考虑,一般会在语义分析的阶段生成平台无关的中间代码,经过中间代码级的代码优化,而后作为输入进入代码生成阶段,产生最终运行机器平台上的目标代码,再经过一次目标代码级别的...

    前面介绍完了词法分析、语法分析和语义分析,以及各阶段如何利用符号表来实现代码合理性确认以及代码地址拉链式回填等工作。编译原理出于代码编译的模块化组装考虑,一般会在语义分析的阶段生成平台无关的中间代码,经过中间代码级的代码优化,而后作为输入进入代码生成阶段,产生最终运行机器平台上的目标代码,再经过一次目标代码级别的代码优化(一般和具体机器的硬件结构高度耦合,复杂且不通用)。故而出于理解编译原理的角度考虑,代码优化一般都是以中间代码级代码优化手段作为研究对象。

    SouthEast

    代码优化按照优化的代码块尺度分为:局部优化、循环优化和全局优化。即
    1. 局部优化:只有一个控制流入口、一个控制流出口的基本程序块上进行的优化;
    2. 循环优化:对循环中的代码进行的优化;
    3. 全局优化:在整个程序范围内进行的优化。

    1. 常见的代码优化手段

    常见的代码优化技术有:删除多余运算、合并已知量和复写传播,删除无用赋值等。采用转载自《编译原理》教材中关于这些优化技术的图例快速地展示下各优化技术的具体内容。

    针对目标代码:

    P := 0
    for I := 1 to 20 do 
        P := P + A[I]*B[I] 

    假设其翻译所得的中间代码如下

    SouthEast

    1. 删除多余运算
    分析上图的中间代码,可以发现(3)和式(6)属于重复计算(因为I并没有发生变化),故而式(6)是多余的,完全可以采用T4∶=T1代替。

    2. 代码外提
    减少循环中代码总数的一个重要办法是循环中不变的代码段外提。这种变换把循环不变运算,即结果独立于循环执行次数的表达式,提到循环的前面,使之只在循环外计算一次。针对改定的例子,显然数组A和 B的首地址在计算过程中并不改变,则作出的改动如下

    SouthEast

    3. 强度削弱
    强度削弱的本质是把强度大的运算换算成强度小的运算,例如将乘法换成加法运算。针对上面的循环过程,每循环一次,I的值增加1T1的值与I保持线性关系,每次总是增加4。因此,可以把循环中计算T1值的乘法运算变换成在循环前进行一次乘法运算,而在循环中将其变换成加法运算。

    SouthEast

    4. 变换循环控制条件
    IT1始终保持T1=4*I的线性关系,因此可以把四元式(12)的循环控制条件I≤20变换成T1≤80,这样整个程序的运行结果不变。这种变换称为变换循环控制条件。经过这一变换后,循环中I的值在循环后不会被引用,四元式(11)成为多余运算,可以从循环中删除。变换循环控制条件可以达到代码优化的目的。

    5. 合并已知量和复写传播
    四元式(3)计算4*I时,I必为1。即4*I的两个运算对象都是编码时的已知量,可在编译时计算出它的值,即四元式(3)可变为T1=4,这种变换称为合并已知量。

    四元式(6)T1的值复写到T4中,四元式(8)要引用T4的值,而从四元式(6)到四元式(8)之间未改变T4T1的值,则将四元式(8)改为T6∶=T5[T1],这种变换称为复写传播。

    SouthEast

    6. 删除无用赋值
    (6)T4赋值,但T4未被引用;另外,(2)(11)对I赋值,但只有(11)引用I。所以,只要程序中其它地方不需要引用T4I,则(6)(2)(11)对程序的运行结果无任何作用。我们称之为无用赋值,无用赋值可以从程序中删除。至此,我们可以得到删减后简洁的代码

    SouthEast

    2. 基本块内的局部优化

    1. 基本块的划分
      入口语句的定义如下:
      ① 程序的第一个语句;或者,
      ② 条件转移语句或无条件转移语句的转移目标语句;
      ③ 紧跟在条件转移语句后面的语句。
    有了入口语句的概念之后,就可以给出划分中间代码(四元式程序)为基本块的算法,
      其步骤如下:
      ① 求出四元式程序中各个基本块的入口语句。
      ② 对每一入口语句,构造其所属的基本块。它是由该入口语句到下一入口语句(不包括下一入口语句),或到一转移语句(包括该转移语句),或到一停语句(包括该停语句)之间的语句序列组成的。
      ③ 凡未被纳入某一基本块的语句、都是程序中控制流程无法到达的语句,因而也是不会被执行到的语句,可以把它们删除。

    2. 基本块的优化手段
    由于基本块内的逻辑清晰,故而要做的优化手段都是较为直接浅层次的。目前基本块内的常见的块内优化手段有:
    1. 删除公共子表达式
    2. 删除无用代码
    3. 重新命名临时变量 (一般是用来应对创建过多临时变量的,如t2 := t1 + 3如果后续并没有对t1的引用,则可以t1 := t1 + 3来节省一个临时变量的创建
    4. 交换语句顺序
    5. 在结果不变的前提下,更换代数操作(x∶=y**2是需要根据**运算符重载指数函数的,这是挺耗时的操作,故而可以用强度更低的x∶=y*y来代替
    根据以上原则,对如下代码进行优化

    t1 := 4 - 2
    t2 := t1 / 2 
    t3 := a * t2
    t4 := t3 * t1
    t5 := b + t4
     c := t5 * t5

    给出优化的终版代码

       t1 := a + a
       t1 := b + t1
        c := t1 * t1

    显然代码优化的工作不能像上面那样的人工一步步确认和遍历,显然必然要将这些优化工作公理化。而一般到涉及到数据流和控制流简化的这种阶段,都是到了图论一展身手的时候。

    3. DAG(无环路有向图)应用于基本块的优化工作
    在DAG图中,通过节点间的连线和层次关系来表示表示式或运算的归属关系:
    ① 图的叶结点,即无后继的结点,以一标识符(变量名)或常数作为标记,表示这个结点代表该变量或常数的值。如果叶结点用来代表某变量A的地址,则用addr(A)作为这个结点的标记。
    ② 图的内部结点,即有后继的结点,以一运算符作为标记,表示这个结点代表应用该运算符对其后继结点所代表的值进行运算的结果。
    (注:该部分内容转载自教材《编译原理》第11章DAG无环路有向图应用于代码优化)

    DAG构建的流程如下

    对基本块的每一四元式,依次执行:
      1. 如果NODE(B)无定义,则构造一标记为B的叶结点并定义NODE(B)为这个结点;
      如果当前四元式是0型,则记NODE(B)的值为n,转4。
      如果当前四元式是1型,则转2.(1)。
      如果当前四元式是2型,则:(Ⅰ)如果NODE(C)无定义,则构造一标记为C的叶结点并定义NODE(C)为这个结点,(Ⅱ)转2.(2)。
      2. 
      (1) 如果NODE(B)是标记为常数的叶结点,则转2.(3),否则转3.(1)。
      (2) 如果NODE(B)和NODE(C)都是标记为常数的叶结点,则转2.(4),否则转3.(2)。
      (3) 执行op B(即合并已知量),令得到的新常数为P。如果NODE(B)是处理当前四元式时 新构造出来的结点,则删除它。如果NODE(P)无定义,则构造一用P做标记的叶结点n。置NODE(P)=n,转4.。
      (4) 执行B op C(即合并已知量),令得到的新常数为P。如果NODE(B)或NODE(C)是处理当前四元式时新构造出来的结点,则删除它。如果NODE(P)无定义,则构造一用P做标记的叶结点n。置NODE(P)=n,转4.。
      3.
      (1) 检查DAG中是否已有一结点,其唯一后继为NODE(B),且标记为op(即找公共子表达式)。如果没有,则构造该结点n,否则就把已有的结点作为它的结点并设该结点为n,转4.。
      (2) 检查DAG中是否已有一结点,其左后继为NODE(B),右后继为NODE(C),且标记为op(即找公共子表达式)。如果没有,则构造该结点n,否则就把已有的结点作为它的结点并设该结点为n。转4.。
      4.
      如果NODE(A)无定义,则把A附加在结点n上并令NODE(A)=n;否则先把A从NODE(A)结点上的附加标识符集中删除(注意,如果NODE(A)是叶结点,则其标记A不删除),把A附加到新结点n上并令NODE(A)=n。转处理下一四元式。

    说着很复杂,下面看一个案例

    (1) T0∶=3.14
    (2) T1∶=2 * T0
    (3) T2∶=R + r
    (4) A∶=T1 * T2
    (5) B∶=A
    (6) T3∶=2 * T0
    (7) T4∶=R + r
    (8) T5∶=T3 * T4
    (9) T6∶=R - r
    (10) B∶=T5 * T6

    其DAG图的构建过程如下

    SouthEast

    通过DAG图可以发现诸多的优化信息,如重复定义、无用定义等,则根据上图的DAG图可以构建最后的优化代码序列

      (1) S1∶=R+r
      (2) A∶=6.28*S1
      (3) S2∶=R-r
      (4) B∶=A *S2

    3.循环优化

    根据上面基本块的定义,我们将诸多基本块组装在一起,构建成程序循环图,如针对下面这个例子

      (1) read x
      (2) read y
      (3) r∶=x mod y
      (4) if r=0 goto (8)
      (5) x∶=y
      (6) y∶=r
      (7) goto (3)
      (8) write y
      (9) halt

    则按照上面基本块的划分,可以分成四个部分,四个部分的控制流分析可知可以得到一个循环图

    SouthEast

    循环块最主要的特点是只有一个数据流和控制流入口,而出口可能有多个。循环优化的主要手段有:循环次数无关性代码外提、删除归纳变量和运算强度削弱。关于这三种手段的理解可以借助此前的描述进行类比,基本并无太多差异。

    展开全文
  • 解释程序和编译程序的区别

    万次阅读 2019-05-09 15:13:56
    编译过程划分成词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成6个阶段。目标程序可以独立于源程序运行。(编译程序是一个语言处理程序,它可以把高级语言程序给语言翻译成某个机器的汇编语言...
  • 编译原理过程简述及中间代码优化

    千次阅读 2017-09-28 17:21:23
    一、编译过程图示如下:词法分析作用:找出单词 。...二、中间代码优化所谓代码优化是指对程序代码进行等价(指不改变程序的运行结果)变换。程序代码可以是中间代码(如四元式代码),也可以是目标代码。
  •  javac把.java文件编译为class文件的这个编译过程,几乎没做什么优化,几乎将优化都放到了后端的即时编译器中,这样是为了其他非javac编译程序也能享受到优化的待遇。但javac给我们提供了很多便于编程的语法糖,...
  • 也正是由于这部分代码序列可能会被反复执行,所以在进行中间代码优化时应着重考虑循环优化,这对提高目标代码的效率起到很大的作用。为了进行循环优化,首先需要确定的是程序流图中哪些基本块构成一个循环。按照结构...
  • 概述Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,...
  • 优化其实可以在编译的各个阶段进行,但最主要的一类优化是在目标代码生成以前,对语法分析、语义分析后产生的中间代码进行优化。这是因为中间代码的形式不依赖于具体的计算机,它可以是三地址码的形式,所以相应的...
  • 一早期(编译期)优化 1概述 Java语言的“编译期”是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端...
  • 概述在部分的商用虚拟机(Sun HotSpot、IBM J9)中,...为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just
  • Linker设定:程序代码优化

    千次阅读 2015-04-17 16:56:05
    Linker设定:程序代码优化 不论是在Android或iOS的项目中,项目属性内都有一个Linker选项,例如下图: Android:  iOS: Linker设定指的就是程序代码的优化,  所谓...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 355,517
精华内容 142,206
关键字:

和代码优化不是编译程序必须的