敲黑板,先来讲几个术语:
1. JIT
全称是Just-in-time,即时编译;当Java字节码运行在JVM上的时候,JVM实时得把字节码编译成机器码就叫JIT。
2. AOT
全称是Ahead-of-time,预先编译;与JIT对应,你JIT不是实时的吗?那我先提前编译好,就是AOT。
3. IR
全程是Intermediate representation,即中间表示。中间表示是一个从原始表示到目标表示之间的中间层。
现代编译器分为前端和后端,前后端的分界线就是IR。
现代编译器的大致流程:词法分析->语法分析->语义分析->IR->优化->生成目标代码。
针对华为给出的方舟编译器的讲解,我们来看看方舟到底做了什么,以及推测一下方舟可能做了什么,或者方舟可以做什么。
1. 无需虚拟机运行
我们都知道,Java的字节码需要运行在Java虚拟机(JVM)上。JVM最重要的功能有两个:执行字节码和内存管理;我们分头来说说。
执行字节码
当JVM运行字节码的时候,会读取一条一条的指令,然后把指令翻译成当前机器的机器码并执行该操作,比如把当前栈上的两个数加起来然后再次压栈等等,这种方式叫做解释执行。
当JVM发现某一些指令经常会被执行到,每次翻译一遍会导致运行效率降低,于是JVM就把这些指令直接编译成当前机器的机器码,下次就直接执行机器码,不需要逐句翻译一遍,这就是JIT。
内存管理
写C代码的同学们,想要使用内存的时候,需要调用malloc函数动态申请一段内存,不再使用这段内存的时候,需要调用free函数进行内存释放,如果不释放,后果很严重。
而写Java代码的同学们就没有这个困惑,因为这件事被JVM承包了下来。JVM在执行字节码的过程中,会调用gc(garbage collection),gc帮我们释放不需要的内存。
方舟是怎么做的?
清楚了以上过程,我们就明白方舟编译器是怎么做的了。
既然JVM可以在运行过程中可以把字节码编译为机器码(JIT),那么为什么不能在运行字节码之前把字节码编译成机器码呢?没错,方舟就是这么做的,我们称之为AOT。
JVM的两大功能之一执行字节码就不需要了,那还有一个内存管理的功能怎么办呢?这个也好办,华为可以提供一个库,这个库实现gc所有的功能,我们称这个库为runtime。
以前我们使用JVM来运行一段字节码,现在这个流程变了,变成先把字节码(或者源程序)编译成机器码,然后带上runtime,直接运行在操作系统上,就不再需要VM了。
VM是不需要了,runtime是必不可少的,这个runtime需要处理包括但不限于以下几件事:创建对象,gc,函数调用,异常处理,锁,同步,多线程,反射。
都已经带上了这么多功能,那再带上一个解释器吧,多一个不嫌多。这些东西好像有些耳熟啊,好像安卓的ART也是这样的?我猜是的,由于Java语言本身和Java的运行时库等等一些历史原因,想推翻重来把这些东西都去掉,复杂度是很高的;所以安卓的爸爸谷歌也是在这些基础上进行修修补补。
当然,华为也可以选择不支持Java中一些动态的特性比如反射等功能,那么这个runtime是有可能简化的。到底方舟编译器和安卓已有的ART有什么不同,我们拭目以待。
2. 多语言联合优化编译器
这个很神奇对吧,C语言竟然可以和Java语言联合在一起编译。
我们知道C语言的代码编译过后是二进制文件,Java语言的代码编译过后是字节码;其实现代编译器在编译过程中有很多层中间表示,如果把源代码层看做***层次,目标语言看成***层次,编译过程中是逐层下降的,***下降到目标层,和我们下楼梯是一样一样的,并不是自由落体对不对。
比如源代码经过编译器前端之后变成抽象语法树(AST),抽象语法树又可以转变为另一种更低层级的中间表示(IR),然后从IR再到目标层。
所以方舟可以定义一个中间表示(IR),把C语言和Java语言都先编译到这个中间表示层,然后在中间表示层做一系列的优化或者分析,再从中间表示层编译到机器码,这样就实现了多语言联合编译。
是不是把不同的语言编译到同一种IR上就万事大吉了呢?不是这样的!
方舟为什么要把多个语言放在一起编译?是好玩吗?当然不是!多个语言联合编译至少有以下几点好处:
减小跨语言调用开销
不同的语言之间,类型系统、调用规范、数据布局等等都不同,所以不同语言相互调用时有一些额外的开销。
我们知道Java调用C的接口规范叫做JNI,JNI帮助我们跨越语言的鸿沟,实现Java和C相互之间的调用。AOT在跨越语言鸿沟方面有一些好处,不同语言用同一个IR表示,runtime也是自己定制的,这不就是前店后厂嘛;
这样就有机会抹平不同语言之间的差异,比如可以让Java对象的数据布局和C中的对象数据布局保持一致,比如可以让C来兼容Java的类型系统(Java语言可以看做C++语言的一个子集)等等;提前抹平差异,使不同的东西保持一致,就不必在运行程序的时候再次进行转换,可以减小开销。
跨语言优化
一般情况下,不同的语言是分开编译的。而方舟编译器将不同的语言编译到同样的IR,便于将不同语言的代码联合起来进行全局优化,比如常量传播,函数内联等等。
当所有的代码都在同一IR上之后,还可以针对Java语言的特性做一些特定的静态分析,通过分析结果进行特定优化,比如可以针对不同种类的函数调用做de-virtualization等等。
什么是de-virtulization?简单来讲就是一些函数调用是通过类似于函数指针调用的方式间接调用,分析清楚这些间接调用可以把一些间接调用改成直接调用,而且是跨语言的直接调用,神奇吧!
3. 更高效的内存回收机制
内存回收是一个大问题,安卓应用卡顿部分原因就在内存回收。
前面提到,Java的内存回收工作被JVM接管了,写Java代码的同学并不需要手动进行内存回收,JVM会在“适当”的时候进行内存回收。
这个“适当”的时候通常是没有办法的时候,内存耗尽的时候;好比我有一张干净的桌子(堆内存),我们在桌子上面摆放了一些东西(消耗内存),当没有地方可以摆放新东西的时候,那就需要妈妈来帮忙收拾桌面了(内存回收)。
JVM中的GC如何判断哪些内存是需要的哪些内存是不需要的呢?这里面有个叫可达性分析的技术来帮我们判断哪些内存可以回收。
可达性分析的大致思想是,JVM运行过程中,创建了很多对象,这些对象之间有复杂的依赖关系,JVM先确定一些对象是根对象,从根对象出发,把所有直接依赖的对象和间接依赖的对象都标记出来,没有被依赖到的对象就不需要使用了,可以进行回收。
当有一段程序,在循环中大量创建新的对象,会造成内存快速耗尽,然后触发gc进行内存回收;频繁触发gc回收大量内存,这种现象叫做内存抖动,是造成安卓应用卡顿的一个很重要的原因。
写iOS应用的同学说我也没有管理内存,但是我写的应用就如丝般顺滑。是的,iOS应用较少发生内存抖动现象,使用了一种叫做引用计数的方法,其实这也是可达性分析技术里面的一种,Objective-C中称之为ARC。
引用计数是这样一种算法,每个对象都有一个计数器,当创建对象时候或者有其它的对象引用这个对象的时候,计数器数字也加1;当别的对象不再引用它时,计数器数字减1。
当计数器的数字回到0时,就将该对象回收。
还是刚才那个循环,在循环中创建大量对象,只要本次循环结束,就可以回收刚刚创建的对象,不会造成内存抖动。
对引用计数进行加1的动作好理解,这是用户自己写的代码,用户的代码中会写清楚什么时候创建对象,什么时候有了新的引用;对引用计数进行减1是谁来做的呢?
这个时候编译器就派上用场了,编译器可以分析对象的生命周期,在合适的地方插入这个对象减1的代码,这样在程序运行的时候引用计数就会加加减减。
方舟编译器的宣传材料中提到“随用随回收”,那么应该是使用了引用计数类似的技术,来减小内存抖动。当然,由于Java语言的问题,引用计数并不能解决所有问题,即使使用了引用计数,也需要gc来帮助回收内存。宣传材料中“回收时无需暂停应用”,应该是实现或者改进了Concurrent GC,来尽可能减小应用的停顿。
通过引用计数和改进GC,可以优化内存回收,减少内存回收的次数和减少暂停时间;既然有了统一的IR是不是可以天马行空一下,除了以上的东西可不可以做更多的一些优化呢?
前面提到引用计数可以解决局部变量用完马上回收的问题,而全局变量就搞不定了。那么方舟编译器有可能可以在这方面做一些文章,比如可以通过分析把一部分全局变量变成局部变量;再比如可以分析全局变量的生存周期,对全局变量也进行引用计数。总之,立即释放更多不需要使用的内存,就可以减少GC,减少卡顿。
好了,胡言乱语完了,我们还是等方舟编译器开源了,然后再一探究竟吧。
【本文为51CTO专栏作者“刘欣”的原创稿件,转载请通过作者微信公众号coderising获取授权】