☕【Java 技术指南】「编译器专题」深入分析探究“静态编译器”(JAVA\IDEA\ECJ 编译器)是否可以实现代码优化?
技术分析
大家都知道 Eclipse 已经实现了自己的编译器,命名为 Eclipse 编译器 for Java (ECJ)。
ECJ 是 Eclipse Compiler for Java 的缩写,是 JavaTM 认可的 Java 编译工具(类似 javac)。可以单独下载使用。
IDEA 所支持的编译器,也有几种:javac(Java 原生编译器)、ECJ(支持使用 Eclipse 编译器)、ACJ 编译器(不太清楚),其中默认使用的是 Javac,同时也推荐使用 Javac。
有兴趣可以看看ECJ编译器的相关使用以及独立使用ECJ
大家的误解
首先,很多小伙伴们都跟我说过 Javac 和 JIT 还有 AOT 它们都是什么有啥区别啊?其实无论是 ECJ 之类的 Java 源码编译器运行的时候,也都是就是静态编译(前端编译器),而不是 JVM 里的 JIT(主要面向与优化!),此处之前的文章介绍过 JIT 和 AOT 编译器,所以此处不做过多赘述!
主流的使用方式
“主流”Java 系统的做法——javac + HotSpot VM 的组合就是如此。
运行时虚方法內联(virtual method inlining)就是这种例子。这样就可以跨越 Class 边界做优化,跟 C/C++程序的 LTO(link-time optimization)一样,不过 C/C++程序真在运行时做 LTO 的很少,这方面反而是 Java“更胜一筹”…呃,C/C++写的一个动态链接库通常也有大量代码可以放在一起优化,对 LTO 的需求本来就远没有 Java 高。
静态编译阶段
首先要确定概念:“编译期”肯定是指诸如 Javac、ECJ 之类的 Java 源码编译器运行的时候,也就是静态编译;而不是 JVM 里的 JIT 编译器运行的时候,也就是动态编译!
动态编译器之优化!
之前介绍过了逃逸分析属于动态编译器情况下对代码进行相关的逃逸分析优化技术,主要针对于动态编译时候做的优化,为什么不可以在静态编译器进行优化,这样性能不会很高吗?
例如,方法内逃逸分析:TrivialMethodEscape
javac 优化能力分析
你肯定会有一个疑问?那为啥没见到啥现成的产品在编译器时做逃逸分析和相关优化,或者为啥 javac 不做这种优化?
回答:目前 Javac 几乎啥优化都不做,优化的操作和能力都交接了 JVM(动态编译器)实现了,并非是技术原因(技术无法实现?),主要是 Sun / Oracle 的公司压根就没有考虑在 Javac 的时候进行代码优化操作。
不过即使是这样,仍然有现成的产品做这种事情啊,只不过是针对 Android,大家可以参考DexGuard。
但是也还是有一些值得注意的优化尚未得到支持:
例如将一些常量值提取到循环之外!
以及一些相关的逃逸分析技术考虑,具体可以参考其相关官方文档!
Java 也有静态编译优化技术,例如,[Excelsior JET]http://www.tucows.com/preview/371869/Excelsior-JET-For-Windows)比 HotSpot VM 早得多就实现了逃逸分析及相关优化,而且是静态编译时做的而不是运行时(JIT)做的。
Excelsior JET是一个 AOT(Ahead-of-Time)编译器和运行时系统。
技术难点在哪里?
主要就是 Java 的分离编译(separate compilation)和动态类加载(dynamic class loading)/动态链接(dynamic linking)。
不知道运行时会加载并链接上什么代码,但是具体原因不仅仅是“反射”“运行时字节码增强(runtime bytecode instrumentation)”。
Java 的标准做法是把每个引用类型编译为一个单独的 Class 文件,这些 Class 文件可以单独的被重新编译,在运行时可以单独的被动态加载。例如说:
这两个 Java 源码文件可以单独编译,也可以单独重编译,生成出 Foo.class 与 Bar.class 两个 Class 文件。它们在运行时可以单独被 JVM 加载,而且每个 ClassLoader 实例都可以加载一次所以同一个 Class 文件可能会在同一个 JVM 实例里被加载多次并被看作不同的 Class。
当在静态编译 Foo.java 时,无法假设运行时真的遇到的 Bar 实现跟现在看到的 Bar.java 还是一样,所以不能跨类型边界(编译后变成 Class 文件边界)做优化。
这种问题其实跟 C/C++程序通常无法跨越动态链接库的边界做优化一样,只不过一般的 Class 文件内包含的代码远比不上一个 native 的动态链接库,但是受的优化限制却一样,使得对 Java 程序的静态分析与优化的收益非常受限。
外加 Java 的面向对象特性带来的一些“副作用”:
一个风格良好的面向对象程序通常会有大量很小的方法,方法之间的调用非常多,而且很可能是对虚方法的调用(invokevirtual),Java 的非私有实例方法默认是虚方法。
一个类与它的派生类必然不会在同一个 Class 文件里,这样即便一个类的 A 方法调用该类的 B 方法,也未必能做有效的分析和优化。
例如:
对这个类,我们能不能把 Foo.foo()静态优化,內联 Foo.bar()并消除掉无用的 new Object(),最好优化成 return null 呢?
考虑上动态加载与基于类基础的多态特性的话,答案是不能:我们不知道会不会在运行时有这么一个派生类:
被加载进来。假如有:
那这个 foo()显然不会返回 null。
结合起来看,Java 有很多小方法、很多虚方法调用、难以静态分析。
而逃逸分析恰恰需要在比较大块的代码上工作才比较有效:JIT 编译器要能够看到更多的代码,以便更准确的判断对象有没有逃逸。
只保守的在小块代码上分析的话,很多时候都只能得到“对象逃逸了”的判断,就没啥效果了。
拿上面的 Foo / Bar 例子说,Foo.foo()如果能内联 Foo.bar()就可以判断 new Object()没逃逸,那标量替换、消除对象分配之类的都可以做;反之,局限在 Foo.foo()自身内部的话,就只能保守判断 new Object()有逃逸,于是啥优化也做不了。
这些特性使得对 Java 程序做高质量的静态分析变得异常困难:
运行时各种类都加载进来之后再激进的假设那就是当前已经加载的类就代表了“整个程序”,以“closed world”假设做激进优化,但留下“逃生门在遇到与现有假设冲突的新的类加载时抛弃优化,退回到安全的非优化状态。
要么可以抛弃 Java 的分离编译+动态加载特性,简化原始问题 ,这样就什么静态分析和优化都能做了。上面提到的 DexGuard、Excelsior JET 都走这个路线。
Excelsior JET 的实现优化的标准和条件
那样标榜自己实现了标准 Java,但又做很多静态编译优化,这又是怎么回事?
其实 Java 标准只是说要整个系统看起来维持动态类加载的表象,并没有说所有程序都一定要用动态类加载。
假如有一个 Java 应用,它不关心通过动态链接带来的灵活性,而是在开发时就可以保证所有用到的类全都能静态准备好,而且不在运行时“灵活”的实用 ClassLoader,那它完全可以找一个能对这种场景优化的 Java 系统来执行它。
Excelsior JET 就是针对这样的场景优化的。用户在使用 JET 把 Java 程序编译成 native code 时,可以指定编译模式是“我声明我的应用肯定不会用某些动态特性”,JET 就会相应的尝试激进的做静态全局编译优化。
动态类加载的 Java 程序怎么办?
Excelsior JET 的运行时系统里其实也包含了一个 JIT 编译器,所以真的有动态类加载也的话也不惧,兵来将挡而已。激进的静态优化可以依赖运行时可以回退到重新 JIT 编译来保证安全性。
跟 Excelsior JET 类似的系统还有一些,最出名的可能是GCJ,不过我觉得它没 Excelsior 做得完善。根据 GCJ 的todo列表,很明显它还没实现逃逸分析和相关优化。
国内的话,复旦大学有过一个基于 Open64 的 Java 静态编译器项目,叫做 Opencj。
请参考论文:Opencj: A research Java static compiler based on Open64
它也有做逃逸分析,但只关注了线程级逃逸来做同步削除的优化,而没有关注方法级逃逸来做标量替换。
反射和运行时字节码增强它们不是主要问题。
反射
Java 中,反射只能用来查看类的结构信息,而不能改变类的结构信息;反射可以读写实例的状态,但无法改变实例的类型。
怎样算是可以修改类的结构信息?
修改类的基类,或修改类实现的接口
添加或删除成员(成员方法或字段都算)
修改现有成员的类型(例如修改成员变量的声明类型,或者修改成员方法的 signature 之类)
参数无法静态确定的反射调用是没办法靠静态分析得知调用目标的。
但这对静态分析的干扰程度其实跟普通的虚方法也差不了多少,反正都是目标无法确定,只能做保守分析;加入启发算法来猜测的话,普通虚方法比反射可能好猜一些,但也仅限于猜。
运行时字节码增强
Java 程序运行的过程中修改程序逻辑的能力,从 Java 提供这一功能的方法就可以一窥其目的:这个能力主要不是给普通 Java 程序使用,而是给 profiler / debugger 用的。
Java 运行时字节码增强,要么得用 Java agent 来使用[java.lang.instrument]包里的功能,要么得用 JVMTI 接口写 C/C++代码实现个 JVM agent;普通的、不使用 agent 的 Java 程序是用不了这种功能的。讨论 Java 程序是否能在某场景下优化的话题,一般没必要考虑对运行时字节码增强的支持。
即便要支持,主流 JVM 通过 JIT 编译器可以重复多次优化编译代码,优化的代码可以被抛弃退回到非优化形式执行,从而既可以激进的做优化、又可以安全的支持这些动态功能;像 Excelsior JET 这种主要以 AOT 方式编译 Java 代码的,为了能提供完善的 Java 支持还是可选在运行时带有 JIT 编译器。
字节码增强也可以在运行之前做,通常叫做“weaving”。所有在运行之前对字节码做的修改都应该看作笼统的“编译时”的一部分——如果用 javac 编译也是你指定的,接着用啥 post weaving 也是你指定的,那你不能怪 javac 不知道后面还会有程序修改字节码,而应该把 javac 和 post weaver 看作达成你的字节码生成目的的整体看作一个逻辑上编译系统。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/85062f0e6fe7c76c526e226b3】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论