即时编译:让解释执行的代码跑得更快
程序代码最终都需要转换为处理器能够直接执行的机器指令。实现这一转换有两条传统路线:
- 提前编译(Ahead-of-Time Compilation,AOT):在程序运行之前,由编译器将源代码一次性翻译为机器码。C、C++、Rust 等语言通常采用这种方式;
- 解释执行(Interpretation):在程序运行时,由解释器逐条读取代码,并执行相应的操作。许多脚本语言的早期实现采用这种方式。
提前编译生成的程序运行速度快,但编译器在编译时无法得知程序实际运行时的情况;解释执行启动迅速、灵活,可移植性好,但运行速度往往比编译后的代码慢一到两个数量级。
即时编译(Just-In-Time Compilation,JIT)试图兼得两者的优点:程序以解释或简单编译的方式开始运行,运行时系统在执行过程中观察程序的行为,找出被频繁执行的代码,再将其编译为高度优化的机器码。Java、C#、JavaScript、Lua 等语言的主流运行时,以及 Python 的部分实现,都采用了即时编译技术。
本文介绍即时编译的基本原理、分层编译的架构、基于运行时信息的推测优化、去优化机制、几种主要的 JIT 设计方式,以及即时编译在性能上的特点与代价。
一、从解释执行说起
解释器的工作方式
要理解即时编译解决了什么问题,首先需要了解解释器为什么慢。
现代的解释器通常不直接解释源代码,而是先将源代码编译为一种更紧凑、更便于执行的中间形式,称为字节码(Bytecode)。字节码是一种为虚拟机设计的指令集,每条指令完成一个简单的操作,例如将一个值压入栈中、将栈顶的两个值相加、跳转到某个位置。
解释器的核心是一个分派循环:
- 读取下一条字节码指令;
- 根据指令的类型,跳转到对应的处理代码;
- 执行该指令的操作;
- 回到第 1 步。
解释器的开销
解释执行的开销主要来自以下几个方面:
分派开销。 每执行一条字节码指令,都需要读取指令、判断类型、跳转到处理代码。这些额外操作本身就可能比指令的实际工作量还大。更重要的是,分派中的跳转目标取决于指令的类型,处理器难以准确预测,频繁的分支预测失败会造成流水线的大量停顿。
栈操作开销。 许多虚拟机采用基于栈的设计,所有的运算都通过操作数栈进行。一个简单的加法需要先将两个操作数压栈,执行加法,再将结果出栈存入变量,产生多次内存读写。而在编译后的机器码中,这些值通常直接保存在寄存器中。
动态类型检查。 在动态类型语言中,一个加法操作在执行时并不知道两个操作数的类型:它们可能是整数、浮点数、字符串,甚至是带有自定义加法逻辑的对象。解释器每次执行加法都必须先检查操作数的类型,再选择相应的处理方式。
缺乏跨指令的优化。 解释器一次只处理一条指令,无法看到更大的代码范围,因此无法进行诸如消除冗余计算、将不变的计算移出循环、内联函数调用等优化。
解释器的优化
解释器本身也可以通过一些技术提升性能,例如:
- 直接线程化:在每条指令的处理代码末尾,直接跳转到下一条指令的处理代码,而不是回到一个统一的分派位置。这样,每条指令都有自己独立的跳转位置,处理器可以根据不同指令的跳转历史分别进行预测,提高预测准确率;
- 基于寄存器的虚拟机:使用虚拟寄存器代替操作数栈,减少指令的数量和数据的搬移;
- 超级指令:将经常一起出现的指令序列合并为一条指令,减少分派次数;
- 内联缓存:在动态类型语言中,为每个属性访问或方法调用的位置缓存上次查找的结果,下次执行时如果对象的类型相同,就可以直接使用缓存的结果。
这些优化能够显著提升解释器的速度,但解释执行与编译执行之间仍然存在相当大的差距。要进一步提升性能,就需要将代码编译为机器码。
二、即时编译的基本思路
为什么不全部提前编译
既然编译后的代码更快,为什么不在程序启动时就将所有代码编译为机器码?
编译本身需要时间。 生成高度优化的机器码是一个计算密集的过程,可能比执行这段代码本身还要耗时。对于一个大型应用,如果在启动时编译全部代码,启动时间会变得难以接受。
大部分代码很少执行。 大量的程序遵循一个经验规律:程序大部分的执行时间,都花在一小部分代码上。许多代码,例如错误处理、初始化逻辑、很少使用的功能,在整个运行过程中只执行几次甚至从未执行。为这些代码花费大量时间进行优化编译,是一种浪费。
编译时缺少运行时信息。 对于动态类型语言,编译器在编译时不知道变量的实际类型,只能生成能够处理所有可能类型的通用代码,这样的代码并不会比解释执行快多少。即使是静态类型语言,在面向对象的程序中,一个虚方法调用在编译时也无法确定实际调用的是哪个实现。
识别热点代码
即时编译的核心策略是:只编译那些值得编译的代码。
运行时系统在解释执行的过程中,为每个函数维护一个调用计数器,并为每个循环维护一个回边计数器,记录循环跳回开头的次数。当某个函数的调用次数,或某个循环的迭代次数超过一定阈值时,就认为它是热点代码,触发即时编译。
这样,编译的开销只花在少量真正被频繁执行的代码上,而它们恰恰是对整体性能影响最大的部分。
栈上替换
如果一个函数只被调用一次,但其中包含一个执行数百万次的循环,基于调用计数的策略就无法及时编译它。等到函数执行完毕后再编译,已经没有意义了。
栈上替换(On-Stack Replacement,OSR)解决了这个问题:当回边计数器显示某个循环非常热时,即时编译器编译包含该循环的代码,生成一个特殊的入口点。然后在函数执行到循环的某次迭代时,将当前解释器栈帧中的状态,包括局部变量的值、操作数栈的内容,转换为编译后代码所期望的格式,并直接跳转到编译后代码的对应位置继续执行。
这样,即使在一次长时间运行的函数调用过程中,程序也能从解释执行切换到编译执行。
三、分层编译
编译速度与代码质量的权衡
即时编译面临一个固有的矛盾:
- 快速编译器能够在很短的时间内生成机器码,让程序尽早获得编译执行的加速,但生成的代码优化程度较低;
- 优化编译器能够生成高度优化的代码,但编译时间较长,并且只有在收集到足够多的运行时信息后,才能做出有效的优化决策。
如果只使用快速编译器,程序的峰值性能有限;如果只使用优化编译器,程序在启动后很长一段时间都在解释执行,等待编译完成。
多层架构
现代的即时编译运行时普遍采用分层编译(Tiered Compilation),将解释器和多个不同级别的编译器组合起来:
- 第零层,解释器:所有代码从这里开始执行,同时收集运行时的统计信息;
- 基线编译器:对于开始变热的代码,使用一个非常快速的编译器生成未经深度优化的机器码。基线编译器通常几乎逐条地将字节码翻译为对应的机器指令,去掉了解释器的分派开销,同时在代码中嵌入收集运行时信息的探针;
- 优化编译器:对于非常热的代码,在收集到足够的运行时信息后,使用优化编译器生成高度优化的机器码。
有些运行时还会在这两者之间设置更多的中间层级,例如一个进行部分优化、编译速度适中的编译器。
代码会随着执行次数的增加,从低层级逐步晋升到高层级。这种设计使得程序能够快速启动,然后逐步加速,最终在热点代码上达到很高的峰值性能。
后台编译
即时编译通常在后台线程中进行。当某段代码被判定为需要编译时,编译任务被放入队列,由专门的编译线程处理。在编译完成之前,应用线程继续以原来的方式执行这段代码,不会因为等待编译而停顿。编译完成后,运行时将这段代码的入口切换为新生成的机器码,后续的调用便会执行编译后的版本。
这种方式利用了多核处理器的并行能力,将编译的开销从应用线程中转移出去。但在 CPU 资源紧张的环境中,例如容器只分配了一个 CPU 核心时,编译线程会与应用线程争抢 CPU 时间,可能导致程序的预热过程明显变慢。
四、性能剖析与推测优化
运行时剖析信息
即时编译器相对于提前编译器最大的优势,在于它能够利用程序实际运行时的信息。在解释执行和基线编译阶段,运行时系统会收集大量的剖析信息(Profiling Information),例如:
- 每个变量或操作数实际出现过的类型;
- 每个方法调用点实际调用了哪些目标方法;
- 每个条件分支走向两个方向的次数;
- 某些操作是否曾经遇到过特殊情况,例如整数溢出、数组越界、空值。
这些信息反映了程序在真实负载下的行为模式。
推测优化
基于剖析信息,优化编译器可以做出推测(Speculation):假设程序未来的行为会与过去观察到的相同,并据此生成针对这种情况专门优化的代码。
例如,在一个动态类型语言中,函数 add(a, b) 返回 a + b。如果剖析信息显示,到目前为止,这个函数的参数始终是整数,那么优化编译器可以生成如下逻辑的代码:
- 检查 a 和 b 是否都是整数;
- 如果是,直接执行一条机器整数加法指令,并检查是否溢出;
- 如果不是,或者发生了溢出,就退出优化代码。
第 1 步和第 2 步中的检查称为守卫(Guard)。在推测成立的情况下,守卫的检查开销很小,而节省下来的工作量很大:不再需要查询类型对应的加法实现、不再需要创建表示结果的对象、可以将整数直接保存在寄存器中。
推测优化是即时编译能够让动态语言获得接近静态编译语言性能的关键技术。
内联
方法内联(Inlining)是最重要的编译优化之一:将被调用方法的代码直接嵌入调用者中,消除方法调用的开销。
内联的价值远不只是省去一次调用。更重要的是,内联之后,被调用方法的代码与调用者的代码成为一个整体,编译器可以在更大的范围内进行分析和优化:常量可以传播到被调用的方法中,冗余的检查可以被消除,原本跨越方法边界的计算可以被合并。可以说,内联是开启许多其他优化的前提。
但对于虚方法调用,或者动态语言中的方法调用,编译器在编译时不知道实际会调用哪个方法,因此无法直接内联。
内联缓存与多态
运行时剖析信息让编译器能够针对实际的调用情况进行内联。根据一个调用点实际出现过的接收者类型数量,可以分为三种情况:
- 单态(Monomorphic):调用点始终只看到一种类型。编译器可以插入一个类型检查守卫,然后直接内联该类型对应的方法实现;
- 多态(Polymorphic):调用点看到少数几种类型,例如两到四种。编译器可以生成一系列的类型检查,每种类型对应一个内联的分支;
- 超多态(Megamorphic):调用点看到了大量不同的类型。此时逐一检查已不划算,编译器只能生成一个通用的、通过查表进行动态分派的调用,无法内联。
实际程序中,绝大多数调用点都是单态的,即使在大量使用多态的面向对象程序中也是如此。这正是推测内联能够获得显著效果的原因。
隐藏类
在 JavaScript 这类动态语言中,对象可以随时添加和删除属性,对象的结构在运行时可以任意变化。如果将每个对象表示为一个哈希表,那么每次访问属性都需要进行一次哈希查找,速度很慢。
许多 JavaScript 引擎使用隐藏类(Hidden Class,也称形状或映射)来解决这一问题:
- 每个对象都关联一个隐藏类,描述其拥有哪些属性,以及每个属性存储在对象内部的哪个偏移位置;
- 具有相同属性、并且属性以相同顺序添加的对象,共享同一个隐藏类;
- 当对象添加一个新属性时,它会转换到一个新的隐藏类。隐藏类之间的转换关系会被记录下来,相同的转换路径会得到相同的隐藏类。
有了隐藏类,访问属性时只需检查对象的隐藏类是否与预期一致,如果一致,就直接按照已知的偏移量读取,就像静态语言中访问结构体的字段一样快。
这也解释了为什么在编写 JavaScript 代码时,建议在构造函数中以固定的顺序初始化对象的所有属性,并避免在对象创建后动态添加或删除属性:这样可以让大量对象共享相同的隐藏类,使属性访问保持在单态状态,便于即时编译器优化。
其他优化
除了推测和内联,优化编译器还会应用一系列经典的编译优化技术:
- 常量折叠与传播:在编译时计算出值确定的表达式;
- 公共子表达式消除:对重复出现的相同计算只计算一次;
- 循环不变量外提:将循环中每次迭代结果都相同的计算移到循环之外;
- 死代码消除:删除结果从未被使用的计算,以及根据剖析信息从未被执行过的分支;
- 边界检查消除:在能够证明数组访问不会越界时,去掉每次访问时的边界检查;
- 逃逸分析:分析一个对象是否会被传递到当前方法之外。如果对象不会逃逸,就可以不在堆上分配它,而是将其字段拆散保存在寄存器或栈上,这称为标量替换。这不仅省去了内存分配的开销,也减轻了垃圾回收的负担;
- 向量化:将对数组元素逐个进行的操作,转换为使用 SIMD 指令一次处理多个元素;
- 寄存器分配:将频繁使用的值尽可能保存在寄存器中,减少内存访问。
五、去优化
推测失败
推测优化的前提是程序的行为与过去保持一致,但这一假设并不总是成立。例如,一个一直以整数为参数的函数,某天收到了一个浮点数;一个虚方法调用点原本只见过一种类型,后来出现了一个新的子类;一个之前从未执行过的分支,突然被执行了。
当守卫检查失败,说明推测不再成立,优化后的代码无法正确处理当前的情况。此时,运行时需要执行去优化(Deoptimization)。
去优化的过程
去优化需要将程序的执行,从优化后的机器码无缝地切换回解释器或基线代码,并从正确的位置继续执行,就好像从未进行过优化一样:
- 在守卫失败的位置,暂停优化代码的执行;
- 根据编译器在编译时记录的元数据,确定当前在原始字节码中对应的位置,以及每个变量当前的值存放在哪个寄存器或栈槽中;
- 如果优化代码中包含了内联的方法,需要为每一层内联的方法重建对应的解释器栈帧;
- 如果某些对象因为逃逸分析而被标量替换,没有在堆上实际分配,此时需要根据保存的字段值重新创建这些对象;
- 使用重建的状态,在解释器或基线代码中,从对应的字节码位置继续执行。
为了支持去优化,编译器需要在每个可能发生去优化的位置记录大量的映射信息,这增加了编译的复杂性和元数据的体积。这也意味着,优化编译器在进行某些转换时必须小心,确保在任何去优化点上都能还原出正确的程序状态。
去优化之后
去优化发生后,运行时会更新剖析信息,将新出现的类型或分支记录下来。如果这段代码仍然很热,它会在适当的时候被重新编译,这一次的优化会考虑到新的情况,例如生成能够同时处理整数和浮点数的代码,或者将单态调用点改为多态调用点。
但如果某段代码反复地优化、去优化、再优化,会浪费大量的编译资源,并造成性能的反复波动。运行时通常会记录每段代码去优化的次数,超过一定次数后,就放弃对其进行某些激进的推测优化,或者干脆不再优化。
依赖失效
除了守卫在执行时失败外,还有一种去优化的情形:全局假设被打破。
例如,在 Java 中,如果某个类当前没有任何子类,编译器可以推测该类的方法不会被重写,从而直接内联这些方法调用,甚至不需要插入类型检查守卫。这称为类层次分析。但如果程序之后动态加载了一个新的子类,重写了这个方法,那么之前基于"没有子类"这一假设编译的所有代码都变得不正确了。
为此,运行时会记录每段编译代码所依赖的全局假设。当这些假设被打破时,运行时会找出所有依赖它的编译代码,将它们标记为无效。正在执行这些代码的线程,会在到达安全点时被去优化,之后的调用不再进入这些代码。
六、即时编译的不同设计
基于方法的编译
大多数即时编译器以方法为编译单位:当一个方法变热时,编译整个方法,并通过内联将其调用的其他方法也一并纳入编译范围。
基于方法的编译与传统编译器的结构相似,可以复用成熟的编译技术。其难点在于,需要通过启发式规则决定内联哪些方法、内联多深,以平衡代码质量和编译开销。
基于追踪的编译
另一种设计是基于追踪的编译(Tracing JIT)。它的编译单位不是方法,而是程序实际执行过的一条路径。
其工作方式是:
- 解释器检测到一个热循环;
- 在下一次执行该循环时,进入记录模式,将这次迭代实际执行的每一条操作按顺序记录下来,形成一条追踪(Trace)。在记录过程中,遇到方法调用时,就跟随进入被调用的方法继续记录,因此追踪天然地跨越了方法边界,实现了内联的效果;遇到条件分支时,只记录实际走过的那个方向,并在追踪中插入一个守卫,检查该分支条件是否与记录时一致;
- 将这条追踪编译为机器码。由于追踪是一条没有分支的线性代码,许多优化变得非常简单;
- 以后执行循环时,运行编译后的追踪。如果某个守卫失败,就退出追踪,回到解释器。如果某个守卫频繁失败,可以从该位置开始记录一条新的分支追踪,并将其连接到原追踪上。
基于追踪的编译实现相对简单,在循环密集、执行路径稳定的代码上效果很好。但当程序的控制流非常复杂、分支众多时,可能产生大量的追踪,导致追踪爆炸,而且不同追踪之间难以共享优化成果。一些早期的 JavaScript 引擎曾采用追踪编译,后来大多转向了基于方法的设计;而在另一些语言运行时中,追踪编译仍然取得了很大的成功。
元追踪与部分求值
为一门新语言编写一个高质量的即时编译器,是一项非常艰巨的工作。有两种技术试图让这项工作变得更容易:开发者只需编写一个解释器,就能自动获得一个即时编译器。
元追踪(Meta-Tracing):追踪的对象不是用户的程序,而是解释器本身的执行。当解释器在执行用户程序的一个热循环时,追踪记录的是解释器为此执行的所有操作。由于解释器的分派循环在这条追踪中被完全展开,分派开销自然消失了。语言实现者只需在解释器中添加少量的提示,标明哪里是用户程序的循环、哪些值在追踪中可以视为常量,就能获得一个追踪即时编译器。
部分求值(Partial Evaluation):将解释器视为一个以用户程序和输入数据为参数的函数。当用户程序的某部分代码确定后,将其作为常量代入解释器,对解释器进行特化和优化,得到的结果就是这部分用户代码的编译版本。这一思想在理论上被称为第一类二村映射。基于这一方法构建的语言实现框架,允许开发者以抽象语法树解释器的形式实现语言,由框架自动完成即时编译。
这两种方法极大地降低了构建高性能语言实现的门槛,已经被用于实现多种语言的高性能运行时。
七、即时编译的代价
即时编译带来了显著的性能提升,但它也有不可忽视的代价。
预热时间
使用即时编译的程序,在刚启动时以解释方式运行,性能较低。随着热点代码逐步被识别和编译,性能逐渐提升,直到达到稳定的峰值。这个过程称为预热(Warm-up)。
对于长时间运行的服务器应用,预热只占整个生命周期的很小一部分,影响不大。但对于以下场景,预热会成为明显的问题:
- 短生命周期的程序:例如命令行工具、构建脚本、批处理任务,可能在代码被充分编译之前就已经结束了;
- 无服务器函数:每次冷启动都需要重新预热,而每次调用的执行时间又很短;
- 频繁的发布和扩容:新启动的实例在预热完成前性能较低,如果立即承接全部流量,可能导致响应缓慢甚至超时。
缓解预热问题的方法包括:
- 预热流量:在实例正式接收流量之前,先用模拟请求执行关键代码路径,触发编译;
- 剖析信息与编译结果的缓存:将上一次运行中收集的剖析信息或编译好的代码保存下来,下次启动时直接加载,跳过大部分预热过程;
- 提前编译与即时编译结合:在构建阶段预先编译部分代码,启动时直接使用,运行时再由即时编译器进一步优化。
内存开销
即时编译需要额外的内存来存储:
- 编译器本身的代码和数据结构;
- 编译过程中的中间表示,优化编译器的中间表示可能非常庞大;
- 生成的机器码,以及支持去优化所需的元数据;
- 剖析信息。
对于内存受限的环境,例如嵌入式设备、小规格的容器,这些开销可能相当可观。
性能的不确定性
即时编译使得程序的性能表现变得更加复杂,也更难以预测:
- 同一段代码在不同的运行阶段可能处于不同的编译层级,性能差异很大;
- 去优化和重新编译可能导致性能的突然波动;
- 编译决策依赖于启发式规则和运行时信息,对代码的微小修改,或者输入数据分布的变化,都可能导致编译结果发生较大变化;
- 编写可靠的性能测试变得更加困难,需要充分预热,并多次运行以排除偶然因素。
这也是在基准测试中常常看到的现象:同一个程序前几次运行的结果与后续运行的结果差异很大。专门用于即时编译语言的基准测试框架,通常会自动执行预热轮次,并采用多种手段防止编译器将被测代码作为死代码消除掉。
安全问题
即时编译器在运行时生成并执行机器码,这带来了一些独特的安全挑战:
- 可写可执行的内存:生成代码需要写入内存,执行代码需要该内存可执行。如果一块内存同时可写且可执行,一旦程序存在内存破坏漏洞,攻击者就可能写入并执行恶意代码。现代运行时通常采用写异或执行的策略:一块内存在任一时刻只能是可写或可执行之一,写入代码后再切换为可执行;
- 编译器自身的缺陷:即时编译器是一个复杂的软件,其优化逻辑中的错误,例如错误地消除了一个必要的边界检查或类型检查,可能导致生成的代码访问越界内存。在浏览器中,这类缺陷曾多次被利用来实施攻击。
出于安全考虑,一些对安全性要求较高的环境会选择禁用即时编译,以牺牲一定性能为代价,缩小攻击面。
八、即时编译与提前编译的比较
| 对比项 | 提前编译 | 即时编译 |
|---|---|---|
| 启动速度 | 快,直接执行机器码 | 较慢,需要预热 |
| 峰值性能 | 高,但缺少运行时信息 | 可能更高,能利用运行时剖析信息 |
| 内存占用 | 较低 | 较高,需要编译器和元数据 |
| 性能可预测性 | 高 | 较低,受编译时机和去优化影响 |
| 对动态语言的适用性 | 有限,难以生成高效代码 | 很好,推测优化效果显著 |
| 部署形式 | 针对特定平台的可执行文件 | 可移植的字节码加运行时 |
两者之间的界限正在逐渐模糊:
- 一些传统上使用即时编译的语言,提供了将程序提前编译为原生可执行文件的选项,以获得更快的启动速度和更低的内存占用,代价是失去部分动态特性和运行时优化能力;
- 一些提前编译器支持基于剖析的优化:先用插桩后的程序在典型负载下运行,收集剖析信息,再根据这些信息重新编译程序,获得类似即时编译的优化效果;
- 一些运行时允许保存即时编译生成的代码,在下次启动时直接使用,兼顾启动速度和峰值性能。
九、编写对即时编译器友好的代码
在使用即时编译的语言中,代码的写法会影响编译器的优化效果。以下是一些通用的原则:
保持类型稳定。 在动态类型语言中,让同一个变量、同一个参数、同一个属性始终保持相同的类型。混用整数和浮点数、在同一个数组中存放不同类型的元素,都会阻碍编译器生成专门的代码。
保持对象结构稳定。 以相同的顺序初始化同类对象的属性,避免在对象创建后动态添加或删除属性,使同类对象共享相同的隐藏类。
避免调用点过度多态。 如果一个调用点需要处理大量不同的类型,它会退化为超多态状态,无法内联。可以考虑将不同类型的处理分离到不同的代码路径中。
保持热点函数小而专注。 编译器对内联的方法大小通常有限制,过大的方法可能无法被内联,也可能超出编译器愿意优化的范围。
避免在热点代码中使用阻碍优化的特性。 某些语言特性会让编译器难以分析,例如动态求值代码、在运行时修改作用域,可能导致整个函数无法被优化。
重视算法和数据结构。 即时编译器能够消除大量的解释开销,但无法弥补算法上的低效。一个时间复杂度更低的算法,带来的收益通常远大于任何微观层面的调整。
以测量为准。 即时编译器的行为复杂,直觉上的优化未必有效,甚至可能适得其反。许多运行时提供了观察编译决策的工具,可以查看哪些方法被编译、是否被内联、是否发生了去优化以及原因。在进行性能优化时,应当借助这些工具和性能分析器定位真正的问题。
结语
即时编译是现代语言运行时中最精巧的技术之一。它从"大部分时间花在少量代码上"这一观察出发,只对真正重要的热点代码进行编译;通过分层编译,在启动速度和峰值性能之间取得平衡;借助运行时收集的剖析信息,进行提前编译器难以实现的推测优化;再以去优化作为安全网,保证在推测失败时程序仍能正确运行。
正是这些技术,使得动态类型语言能够达到接近静态编译语言的性能,使得一次编写的字节码能够在不同的平台上高效运行,也使得 Web 浏览器能够运行越来越复杂的应用。
即时编译也有其代价:预热时间、额外的内存、性能的不确定性,以及编译器本身带来的复杂性与安全风险。理解这些原理和权衡,有助于在使用这些语言时写出更易于优化的代码,在进行性能分析时正确解读测量结果,也有助于在设计系统时,根据应用的特点在即时编译与提前编译之间做出合适的选择。
- 点赞
- 收藏
- 关注作者
评论(0)