深入理解JVM - 编译期/运行期优化

----------------------------编译期优化--------------------------------------

1、概述(编译期)

Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(Javac编译器)把Java文件转变为class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器)把字节码转变为机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把Java文件编译成本地机器码的过程。下面列举这三类编译过程中一些比较有代表性的编译器。

a)前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ);【编译期、Javac编译器用Java语言实现】

b)JIT编译器:HotSpot VM的C1、C2编译器;【运行期】

c)AOT编译器:GNU Compiler for the Java 、Excelsior JET;

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

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

2、语法糖的味道

语法糖:也成为糖衣语法,是指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序猿的使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java中最常用的语法糖主要是泛型(泛型并不一定都是语法糖实现,如c#的泛型就是直接由CLR支持的)、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。

2.1、泛型与类型擦出

泛型技术在c#和Java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面的泛型无论在程序源码中、编译后的IL中,或是运行期的CLR中,都是切实存在的,List<int>和List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

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

扫描二维码关注公众号,回复: 2869002 查看本文章

    ---------------泛型擦出前-----------------
        Map<String, String> map = new HashMap<>();
        map.put("hello", "你好");
        map.put("how are you", "吃了没?");
        System.out.println(map.get("hello"));
        System.out.println(map.get("how are you"));
        ---------------泛型擦出后-----------------
        Map map = new HashMap<>();
        map.put("hello", "你好");
        map.put("how are you", "吃了没?");
        System.out.println((String)map.get("hello"));
        System.out.println((String)map.get("how are you"));

a)当泛型遇见重载1

  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<String>和List<Integer>编译之后都被擦除了,变成了一样的原生类型List<E>,擦出动作导致这两个方法的特征签名变得一模一样。但是只能说,泛型擦除成相同的原生类型只是无法重载的其中一部分原因。

b)当泛型遇见重载2

public class GenericTypes {
        public static String method(List<String> list) {
            System.out.println("invoke method(List<String> list)");
            return "";
        }

        public static int method(List<Integer> list) {
            System.out.println("invoke method(List<Integer> list)");
            return 0;
        }

        public static void main(String[] args) {
            method(new ArrayList<String>());
            method(new ArrayList<Integer>());
        }
    }
上述方法的重载由于返回值的加入,居然成功了。why?当然不是根据返回值来确定的,之所以能编译和执行成功,是因为两个method方法加入不同的返回值才能共存在一个Class文件之中。方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但是返回值不同,那它们也是可以合法的共存于一个Class文件中的。

由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,如泛型类中如何获取传入的参数化类型等。因此,JCP组织对于虚拟机规范也做出了相应的修改,引入诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随着泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确的识别Signature参数。

从上面的例子中可以看到擦出法对实际编码带来的影响,由于List<String>和List<Integer>擦出后是同一个类型,我们只能添加两个不同的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案。

另外,从Signature属性的出现我们还可以得出结论,擦除法仅仅是方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

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

自动装箱、拆箱与遍历循环无论是实现还是思想上都不能和泛型相比。这里专门作为一小节,是因为它们是Java中使用最多的语法糖。

    -------自动装箱、拆箱与遍历循环--------
    public static void main(String[] args) {
        //asList(T... a)
        List<Integer> list = Arrays.asList(1, 2, 3, 4);
        int sum = 0;
        for (int i : list) {
            sum += i;
        }
        System.out.println(sum);
    }

    -------自动装箱、拆箱与遍历循环编译之后--------
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(new Integer[]{
                Integer.valueOf(1);
        Integer.valueOf(2);
        Integer.valueOf(3);
        Integer.valueOf(4);
        });
        int sum = 0;
        for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
            int i = ((Integer) localIterator.next()).intValue();
            sum += i;
        }
        System.out.println(sum);
    }
上述示例包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖。泛型不必说了,自动装箱/拆箱在编译后被转化成了对应的包装和还原方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数。

----------------------------运行期优化--------------------------------------

3、概述(运行期)

Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码” - 被多次调用的方法、被多次执行的循环体,HotSpot使用基于计数器的热点探测方法。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器)。
即时编译器并不是虚拟机必需的部分,Java虚拟机规范也没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器却是衡量一款商业虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。本文后面的部分将探索即时编译器的运作过程,同时解决如下几个疑问:
  • 为何HotSpot虚拟机要使用解释器与编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?为何要编译为本地代码?
  • 如何从外部观察即时编译器的编译过程和编译结果?
关于解释器和编译器的区别看这篇博文。

3.1、解释器与(JIT)编译器

许多主流的商用虚拟机,如HotSpot、J9等,都同时包含解释器与编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,获取更高的执行效率。
简单的讲:
a)解释器[启动响应速度快]:为了支持Java的跨平台特性, 所有的Java文件都会被编译成class字节码[Javac编译器]文件,这是一种中间代码。当每次运行Java程序的时候,都需要通过解释器将字节码解释成机器码,然后执行。
b)编译器[运行效率高]:这里的编译器我们特指JIT(Just In Time Complier)。JIT在运行时会将“热点代码”编译成与本地平台相关的机器码,然后保存起来。下次运行的时候直接使用,提高运行效率。
【由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释器的执行速度也是有影响的】
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之,使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化回退到解释器状态执行,因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作:

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Complier和Server Compiler,或者简称为C1编译器和C2编译器。目前,主流的HotSpot虚拟机中(JDK1.7之前版本的虚拟机),默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式。这种搭配使用的方式称为“混合模式”。
Java程序员有一个共识,以编译方式【JIT】执行本地代码(机器码)【通过JIT将"热点代码"编译成机器码,然后执行】比解释方式【在执行的时候,将字节码解释为机器码,然后执行】更快。之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中。

3.2、优化技术

这个地方只提供几个关键词,具体看参阅书中章节。

最具代表性的几项技术:

1、公共子表达式消除;2、数组范围检查消除;3、方法内联;4、逃逸分析;

猜你喜欢

转载自blog.csdn.net/json_it/article/details/79152367