答案:当然可以。
首先要确定概念:题主说的“编译期”肯定是指诸如javac、ECJ之类的Java源码编译器运行的时候,也就是静态编译;而不是JVM里的JIT编译器运行的时候,也就是动态编译,对吧?
如果是的话,那么接下来就是:有现成的工具在静态编译器做逃逸分析啊。
以
WALA为例,有两块简单的逃逸分析实现:
TrivialMethodEscape- 方法内逃逸分析
SimpleThreadEscapeAnalysis- 线程内逃逸分析
以
Soot为例,
Thread escape analysis for Java programs based on Soot Field Flow Sensitive Pointer and Escape Analysis for Java Using Heap Array SSA然后题主肯定要问:那为啥没见到啥现成的产品在编译器时做逃逸分析和相关优化,或者为啥javac不做这种优化?
先回答后半:javac本来就几乎啥优化都不做,优化都扔给JVM了,所以这个问题本身就歪了——不是啥技术原因,而是Sun / Oracle的公司策略不在这方面投资源而已。
再回答前半:其实有现成的产品做这种事情啊,只不过是针对Android的
DexGuard。在
ProGuard的介绍页面上写到:
Some notable optimizations that aren't supported yet:
- Moving constant expressions out of loops.
- Optimizations that require escape analysis (DexGuard does).
那为啥同样是Java程序,针对Android就有优化器做逃逸分析,而针对普通Java就没呢?
Java当然也有啊。例如
Excelsior JET比HotSpot VM早得多就实现了逃逸分析及相关优化,而且是静态编译时做的而不是运行时(JIT)做的。Excelsior JET是一个AOT(Ahead-of-Time)编译器和运行时系统。
那那那题主肯定还得问这么好的思路为啥没有更多产品用呢?技术难点在哪里?
最主要的一个技术难点就是Java蛋疼的分离编译(separate compilation)和动态类加载(dynamic class loading)/动态链接(dynamic linking)。这就回到了前面几个回答所提到的:不知道运行时会加载并链接上什么代码;但是具体原因不是前面回答所说的“反射”“运行时字节码增强(runtime bytecode instrumentation)”。
具体来说,Java的标准做法是把每个引用类型编译为一个单独的Class文件。这些Class文件可以单独的被重新编译,在运行时可以单独的被动态加载。例如说:
// Foo.java public class Foo { public void greet(Bar b) { System.out.println("Greetings, " + b.toString()); } }
// Bar.java public class Bar { public String toString() { return "Bar 0x" + hashCode(); } }
这两个Java源码文件可以单独编译,也可以单独重编译,生成出Foo.class与Bar.class两个Class文件。它们在运行时可以单独被JVM加载,而且每个ClassLoader实例都可以加载一次所以同一个Class文件可能会在同一个JVM实例里被加载多次并被看作不同的Class。
这样,当我们在静态编译Foo.java时,我们无法假设运行时真的遇到的Bar实现跟现在看到的Bar.java还是一样,所以不能跨类型边界(编译后变成Class文件边界)做优化。在同一Class文件内能静态确定的东西倒还是可以优化的。
这种问题其实跟C/C++程序通常无法跨越动态链接库的边界做优化一样,只不过一个一般的Java Class文件内包含的代码远比不上一个一般的native的动态链接库,受的优化限制却一样,使得对Java程序的静态分析与优化的收益非常受限。
外加Java的面向对象特性带来的一些“副作用”:
例如:
public class Foo { public Object foo() { return bar(new Object()); } public Object bar(Object o) { return null; } }
对这个类,我们能不能把Foo.foo()静态优化,內联Foo.bar()并消除掉无用的new Object(),最好优化成return null呢?
考虑上动态加载与基于类基础的多态特性的话,答案是不能:我们不知道会不会在运行时有这么一个派生类:
public class Bar extends Foo { public Object bar(Object o) { return o; } }
被加载进来。假如有:
Foo o = new Bar(); o.foo(); // not null
那这个foo()显然不会返回null。
结合起来看,Java有很多小方法、很多虚方法调用、难以静态分析。而逃逸分析恰恰需要在比较大块的代码上工作才比较有效:编译器要能够看到更多的代码,以便更准确的判断对象有没有逃逸。
只保守的在小块代码上分析的话,很多时候都只能得到“对象逃逸了”的判断,就没啥效果了。拿上面的Foo / Bar例子说,Foo.foo()如果能内联Foo.bar()就可以判断new Object()没逃逸,那标量替换、消除对象分配之类的都可以做;反之,局限在Foo.foo()自身内部的话,就只能保守判断new Object()有逃逸,于是啥优化也做不了。
这些特性使得对Java程序做高质量的静态分析变得异常困难:
Java的分离编译+动态加载是个让JVM实现者非常非常蛋疼的特性。我天天工作都忍不住得骂它一通。
但是抛弃了它,那还能是Java么?
Android表示:我们从来就不说自己是全套Java,只是用了“Java编程语言”。
Java程序在Android上的典型部署方式是整个应用打包成一个apk文件,里面可能有一个或少量多个dex文件存着程序代码。Dex文件地位上跟Java的JAR包类似,都是把一大堆类型定义打包到一起。不同的是dex文件作为整体可以看作一个“动态链接库”,它里面包含的类型可以相互之间做跨类型的分析与优化,因此在这个层面做內联、逃逸分析之类都不在话下;反之,Java的JAR文件只是一堆Class文件zip起来了而已,不能对动态加载到什么做任何强假设,本质上跟一堆分散的Class文件没有任何区别,优化边界还是在Class文件上。
在典型的只用一个dex文件的部署模型下,一个Android应用可以相当彻底的在静态编译时做全程序(whole program)分析与优化,这是普通Java比不上的。当然,“可以做”跟“已经做了”又是两码事。反正Dalvik VM原本带的dexopt做的优化就不怎么样;新的ART还在发展中,做的编译优化也还不多;至于新的Jack和Jill编译器/优化器,这个或许可以期待一下。
那像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根据论文的描述它也有做逃逸分析,但只关注了线程级逃逸来做同步削除的优化,而没有关注方法级逃逸来做标量替换。
没接触过Opencj的具体代码不太肯定它的具体实现现在是啥状况。
=================================================================
前面提到反射和运行时字节码增强,这里简单说说为啥它们不是主要问题。
反射:Java中,反射只能用来查看类的结构信息,而不能改变类的结构信息;反射可以读写实例的状态,但无法改变实例的类型。
怎样算是可以修改类的结构信息?
这些Java的反射都不能做。有能通过反射做这些事情的语言,但Java不能。
诚然,参数无法静态确定的反射调用是没办法靠静态分析得知调用目标的。但这对静态分析的干扰程度其实跟普通的虚方法也差不了多少,反正都是目标无法确定,只能做保守分析;加入启发算法来猜测的话,普通虚方法比反射可能好猜一些,但也仅限于猜。
运行时字节码增强:在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编译器。
评论中有同学提到
Javassist,这就是典型的运行时Java字节码增强的应用。运行时用
ASM库也是如此。
与之相对,字节码增强也可以在运行之前做,通常叫做“weaving”。所有在运行之前对字节码做的修改都应该看作笼统的“编译时”的一部分——如果用javac编译也是你指定的,接着用啥post weaving也是你指定的,那你不能怪javac不知道后面还会有程序修改字节码,而应该把javac和post weaver看作达成你的字节码生成目的的整体看作一个逻辑上编译系统。
如何?