一个程序的编译过程可以是步骤迭代式的,即每一轮步骤结束后得到的结果都可独立运行,比如,先构造AST 再输出字节码,中间状态AST 也是可以解释执行的。由于编译的本质就是代码转换,因此对一个语言可以有多个独立的编译器,每个负责一轮步骤
AOT Compiler 和JIT Compiler 就是针对编译形式做的分类:
* AOT:Ahead Of Time,指在运行前编译,比如普通的静态编译
* JIT:Just In Time,指在运行时编译,边运行边编译,比如java 虚拟机在运行时就用到JIT 技术
JIT 可能知道的人多些,AOT 这个名词就相对少见一些了,其实除了JIT,剩下的都是AOT。wiki 上JIT 的解释也比AOT 详尽很多,如果按wiki 上的理解,一般来说,是从形式上来区分这两个概念,即看编译是不是在“运行时”进行
然而,这两个概念又有模糊性,问题在于这个“运行时”怎么来区分,比方说,从这个概念来看,python 是用到JIT 技术的,因为:
```
... import a ...
```
当执行到import a 的时候,当然是运行时,这时候如果只找到了a.py,则会进行编译工作,并生成a.pyc,这就是python 的JIT 特性,但是一般来说,认为python 的JIT 是psyco、pypy 之类,并不认为python 本身的动态性属于JIT 范畴,或者说,它的这种“形式上”的JIT 特性不纳入讨论范围。其他脚本语言,动态语言也有类似的情况。具体原因我觉得有几点
* 首先被主流理论认定的JIT 编译器对于被其编译的语言来说属于附加品,也就是说,就算去掉JIT,并不影响语言本身的运行,例如java,如果关闭JIT,依然可以解释执行,而上述python 的运行时import 的特性虽然形式上符合JIT,但这个机制是语言本身规定的,如果去掉,语言(的主流实现)就不完整了。反过来说,如果python 采用源码直接解析执行,则编译为字节码的行为就可以看做是JIT,因为做不做都不影响解析执行过程
* 其次,python 的这种编译并非每次执行都会进行,因为一般来说会生成字节码结果pyc 文件存在磁盘,它更像是对java 源代码转class 文件这一过程的惰性化,在需要的时候进行
* 最后,JIT 会消耗运行时资源,可能导致进程卡顿,而java 等语言之所以引入JIT,是因为JIT 对字节码编译后能以更快的速度运行,卡顿的时间能补救回来,因此从工程角度讲,JIT 几乎就等于是运行时优化(虽然从概念和形式上并非如此),而python 的import 就只有卡顿,对速度没啥好处
于是,虽然从概念来说,上面的例子的确符合JIT,但一般来说也不这么认为,出发角度问题,说python 自带JIT 特性或没有JIT 都算说得通的
之所以先举这个例子,因为我觉得能体现AOT 和JIT 概念的对立和统一,对立是形式上的,以“运行”为分界线,而统一则是说,其实所有需要执行的指令序列,都是需要先编译再执行的,比如import a,这个相对于整个进程当然是JIT,但相对于a.py 这个模块(python 进程首次import 某个模块时会执行它)不妨看做AOT,如果有人觉得这么做不妥,那换个更明显的例子,如果一个python 程序的所有import 都在进程开启时立即运行,然后才进入执行,那按照概念来说,这是JIT,因为进程已经开始运行了,但是,为什么不能看做是先编译再执行的AOT 模式,只是整个过程被批处理化了呢?
带着这个问题再考虑很多资料(包括wiki)对JIT 的另一个描述,JIT 是在运行时将解释执行的语言(比如字节码)编译成机器指令,以提高运行速度。这个看法在前面的某篇也提过,的确很多JIT 编译器,比如java 的就是这么干的(我们下面就拿java 举例),但是,既然字节码编译成机器指令可以提高速度,为何一定要放在运行时进行,做成AOT 模式不是可以运行得更流畅吗,而且还能一次编译,N 次执行,为啥非要做成运行时做,JIT 本来是要提高运行速度,但这岂不是降低了效率?
这种看法是有道理的,事实上,java 的确有一些AOT 编译器,可以将字节码甚至java 源码直接编译成机器指令的可执行文件,微软当初的VJ++似乎就这么搞的,和sun 打了很久的架,sun 还喊出了pure java(纯粹的java,即按照sun的设计理念和标准来实现java)的口号,有兴趣可以去搜一下这段历史,挺搞笑的
另一方面,sun 的jvm 虽然采用了JIT 编译,但同时也提供了client 和server 模式,在server 模式下,虚拟机在一开始执行的时候会先尽可能多地对字节码进行编译,且优化程度也尽量高,这样可以使得服务器在运行过程中能尽量少卡顿,根据上面的讨论,这实际上相当于AOT 批处理了。client 模式下则不会这样做,主要是为了尽量缩短启动延迟,提高用户体验
顺便说一句,对于JIT 将字节码编译成机器指令,wiki 的描述比较暧昧,有时候用machine code,有时候用native code,比方说我们用java 实现一个A 语言的虚拟机,解释A 的字节码执行,并将字节码编译成java 自己的字节码,这也是JIT,因为A 跑在jvm 上,则java 字节码就看做是native code,而machine code这个machine 也不见得是真实机器,jvm 也是一种机器
由于JIT 编译耗费运行时间,则对于某些优化点就无法做到百分百支持,必须在代码优化和执行卡顿之间做一个权衡,AOT 就没有这个问题,另外,AOT 可以做到编译后持久化到存储,而JIT 一般是每运行一次就会搞一遍重复的编译
如果我们不考虑AOT 本身耗费的时间(比如编译一次,N 次运行),也不考虑使用上的方便性(AOT 可能会有多次编译过程),那是不是可以认为,AOT 编译可以完全替换JIT 编译,JIT 就完全没必要了,实际情况当然不是这样,JIT 还是有它的优势和必要性的,否则研究它的那群人岂不都是傻子
从动静态来看这个问题,AOT 是静态编译,而JIT 是运行时动态编译,则JIT 的优势在于,它不但能看到静态信息(代码),还能看到运行时的情况,这就是JIT的优势。接下来讨论的JIT 是一种狭义的JIT,即在AOT 搞不定的地方使用的JIT,而非上述形式上的
关于JIT 的优势,wiki 上给出了四点理由,但有意思的是,其中有两条连它自己都承认并非只有JIT 能做,也就是说至少理论上,用AOT 实现(或部分实现)是可行的,这四条是:
* 1、JIT 可以根据当前的硬件情况实时编译成最优机器指令,比如cpu 中如果含FPU,MMX,SSE2,或者Intel cpu 的并行计算特性,则可以做到同一份字节码,在不同机器运行时最大限度利用硬件资源。而如果是AOT 编译一个程序放出去给不同用户使用,就只能去兼容特性最少的cpu,或者内部实现多个版本
* 2、JIT 可以根据当前进程实际运行状态,将字节码编译成适合最优化的机器指令序列。wiki 认为静态编译也可以通过分析profile 来实现这方面的优化(可能有点麻烦)
* 3、当程序需要支持动态链接时,即在静态编译阶段,可能不知道运行时会引入什么样的代码来和程序协作执行,这时候就只能依靠JIT
* 4、考虑到垃圾收集,JIT 可以根据进程中的内存实际情况来调整代码,使得cache能更充分地使用,wiki 认为静态编译也可以做到,但JIT 做起来更容易实现
对于第一条,JIT 的确可以实现这种优化,但是AOT 一样可以实现,虽然AOT编译一个程序给不同用户执行无法做到,但是可以编译字节码发布,用户使用时再根据当前机器再做一次AOT
对于第二条,首先我认为大多数程序的运行状态不会经常变动,比如同一个程序有时候是整数计算居多,有时候是浮点计算居多,一般来说程序应用场景是固定的;其次对于特定场景也可以AOT
对于第三条,的确动态链接的全文静态优化AOT 无法做到,但是如上篇所说,必要时候我们可以直接砍掉语言的动态性,再者静态编译时候也不是什么都感知不到,比如C 语言做静态链接时,至少是知道头文件的,动态性没那么强
对于第四条,AOT 也是有可能实现的,虽然麻烦很多。另一方面,静态编译时也有指令乱序来提高cache 使用效果,再者这块也和垃圾收集算法、程序本身的局部性有很大关系,如果程序本身写的烂,这个调整效果可能也比较有限
所以我觉得,这四条虽然都有道理,但没精确说到点子上。再来审视这个问题,
我们可以看出,从理论上讲,AOT 可以完全代替JIT,因为一个进程的状态是有限的,AOT 可以预测所有可能情况并进行优化,实际运行时的状态不会超出AOT的预测,采用最优代码执行即可,而JIT 在这里的优势就是,它能精准地得知运行时状态,而不是像AOT 那样预测,成本更低,如果一个AOT 优化的成本过高,则应该选择JIT。AOT 不是不能做,而是不可行
JIT 相关的资料,相比wiki 我更推荐这篇论文:《Representation-basedJust-in-time Specialization and the Psyco prototype for Python》by Armin Rigo,这个论文是以python 和其JIT 插件库psyco 为例来分析,论文题目中的单词Specialization 可谓画龙点睛,它指出至少在动态类型语言中,JIT 的关键作用之一是特化,用上篇的话说,就是动态行为静态化,而这些场景中AOT 不可行的原因是它很难找到特化的方向,而枚举所有特化是不可行的
一个典型的特化案例,也是论文中提到的,假设有一个函数f(x,y),则对于x 的输入x1,x2,x3...,我们可以特化这个函数为f1(y),f2(y),f3(y)...,其中fk(y)在功能上对应f(xk,y),这样一来,每个fk 可以单独地做优化,与其他函数无关,而特化后的函数列表至少不会比原来的f(x,y)慢。唯一的问题是,x 的取值可能很多,比如x 是一个int,则如果采用AOT 方式来特化,则需要编译42 亿多个函数,这显然是不现实的,但是JIT 就有可能对这个场景做优化,原因在于,x 的取值虽然很多,但在一个具体运行过程中范围相对小,甚至是很小,这符合二八定律
于是,在运行时我们可以对函数f 做监控,统计每次输入的x 的值,如果发现这些值的分布不平均,比如x 为123 的情况占大多数,则动态特化一个f123(y),对其进行高度优化,然后修改f 函数为:
```
func f(x, y):
if x == 123:
return f123(y)
... //f 的正常流程
```
于是只需要一个特化函数,就能带来运行时效率的提升,这就是JIT 特化的优势对很多程序来说,对这种数值做监控和特化可能性价比不高,因为不是每个函数的输入值范围都呈现不平衡状态,或者说不是那么明显,但上面这个例子中,x和y 不一定是变量,也可以是类型,这样一来对动态类型语言就有很大的意义
前面讲过,在C++中可以用模板来实现鸭子类型,实质是通过代码替换来实现类型静态化,C++这个方式虽然效率高,但渠道是通过静态编译中的全文分析,是AOT 编译,如果改成稍微动态性强一些的语言,就用不上了。在动态类型中,一个函数如果有k 个参数,有n 个可能类型,则AOT 需要将一个函数扩展为n^k个特化实例,n 和k 稍大一点就不可操作了,何况本身就是动态类型,n 的范围都不一定在编译期能知道
对这种场景,JIT 就可以通过统计的方式来选择性地特化,这个的可行性和现实意义更大,原因在于,程序员在用动态类型写程序的时候,比如写一个函数:
```
func f(x, y):
return x + y
```
理论上,这个函数可以接受任意类型的x 和y,只要x 能和y 相加即可,但具体到一个确定的程序,这个函数的业务意义一般是固定的,或者是做字符串拼接,或者是数值相加,很少说写一个函数,接收八竿子打不着的不同的类型还能运算,而且还是程序员刻意这么设计,就像前面讲过的C++模板的二义性一样,基本见不到这种需求,所以在函数的输入参数类型上,符合二八定律。于是对于上述代码,假设x 和y 绝大多数情况下都是整数,则进行特化(假设这个伪代码中不考虑整数溢出):
```
func f(x, y):
if not (x instanceof int and y instanceof int):
//有一个不是整数,走原有流程
return x + y
//整数加法的特化流程
internal_code:
int ix = get_internal_int(x)
int iy = get_internal_int(y)
int iresult
asm:
push ... //当前状态压栈
mov eax, ix
mov ebx, iy
add eax, ebx
mov iresult, eax
pop ... //状态出栈
return build_int_object(iresult)
```
当然这只是个例子,如果只是为了一个加法,这多少有点小题大做,但如果f 的逻辑较为复杂,优化就很明显了
还可以逆向思维一下,AOT 难以实现特化的原因是无法考虑所有情况,但我们也没有必要考虑所有情况,实际上类型使用的二八定律本身也在另一个二八定律里,具体到int 类型,一个绝大多数使用到的类型都是int 的程序在所有程序中占绝大多数,至少在一个有限的领域是这样,因此干脆对于每个函数都只做int相关的特化,这样2k 种情况还算能接受(实际情况数比2k 低很多,因为很多参数如果被假定为int,会语法错误,就不用假设了),如果再做的好一点,还可以做成编译器选项,由用户来指定AOT 的时候对哪个类型特化,这样就比较完美了
除类型的动态性外,其他动态性也可以类似讨论,仅拿上篇的例子,不赘述了:
```
for i in range(n):
print(i)
转换为: if not (range is builtins.range and print is builtins.print):
for i in range(n):
print(i) else:
internal_code:
long tmp = get_internal_long(n)
long i
//这里应该用汇编,仅表个意思
for (i = 0; i < tmp; ++ i):
print_long(i)
```
需要在程序启动时在builtins 里面保存默认函数,用于检测当前运行环境是否被用户修改过,这样就兼顾了效率和动态性,跟上面一样,这里JIT 或AOT 实现都可以。
- 第一章 热修复设计
- 第一节、AOT/JIT & dexopt 与dex2oat
- 一、AOT/JIT
- 二、dexopt 与dex2oat
- 第二节、热修复设计之CLASS_ISPREVERIFIED 问题
- 一、前言
- 二、建立测试Demo
- 三、制作补丁
- 四、加载补丁
- 五、CLASS_ISPREVERIFIED
- 第三节、热修复设计之热修复原理
- 一、Android 热修复
- 二、Android 虚拟机和编译加载顺序
- 三、混合模式的理解
- 四、源码类到机器执行的文件过程
- 五、补丁包
- 六、类补丁生效原理
- 七、Davlik 虚拟机的限制
- 八、Davlik Class resolved by unexpected DEX: 限制和处理方式
- 九、类加载器的双亲委派加载机制
- 第四节、Tinker 的集成与使用(自动补丁包生成)
- 一、简述
- 二、Tinker 组件依赖
- 三、Tinker 的配置及任务
- 四、Tinker 封装与拓展
- 五、编写Application 的代理类
- 六、常用API
- 七、测试
- 八、细节
- 第二章 插件化设计
- 第一节、Class 文件与Dex 文件的结构解读
- 一、Class 文件
- 二、Dex 文件
- 三、Class 文件和Dex 文件对比
- 第二节、Android 资源加载机制详解
- 第三节、四大组件调用原理
- 第四节、so 文件加载机制
- 第五节、Android 系统服务实现原理
- 第三章 组件化框架设计
- 第一节、阿里巴巴开源路由框——ARouter 原理分析
- 第二节、APT 编译时期自动生成代码&动态类加载
- 第三节、Java SPI 机制
- 第四节、AOP&IOC
- 第五节、手写组件化架构
- 第四章 图片加载框架
- 第一节 图片加载框架选型
- 第二节 Glide 原理分析
- 第三节 手写图片加载框架实战
- 第五章 网络访问框架设计
- 第一节 网络通信必备基础
- 第二节 OkHttp 源码解读
- 第三节 Retrofit2 源码解析
- 第六章 RXJava响应式编程框架设计
- 第一节 RXJava之链式调用
- 第二节 RXJava之扩展的观察者模式
- 第三节 RXJava之事件变换设计
- 第四节 Scheduler 线程控制
- 第七章 IOC架构设计
- 第一节 依赖注入与控制反转
- 第二节 ButterKnife 原理上篇、中篇、下篇
- 第三节 IOC架构设计之Dagger2架构设计
- 第八章 Android架构组件 JetPack
- 第一节 LiveData的工作原理
- 第二节 Navigation 如何解决tabLayout 问题
- 第三节 ViewModel 如何感知View 生命周期及内核原理
- 第四节 Room 架构方式方法
- 第五节 dataBinding 为什么能够支持MVVM
- 第六节 WorkManager 内核揭秘
- 第七节 Lifecycles 生命周期