JIT 编译器快速入门

本文讲的是JIT 编译器快速入门,

本文是 WebAssembly 系列文章的第二部分。如果你还没有阅读过前面的文章,我们建议你从头开始.

JavaScript 刚面世时运行速度是很慢的,而 JIT 的出现令其性能快速提升。那么问题来了,JIT 是如何运作的呢?

JavaScript 在浏览器中的运行机制

作为一名开发者,当你向网页中添加 JavaScript 代码的时候,你有一个目标和一个问题。

目标: 你想要告诉计算机做什么。

问题: 你和计算机使用的是不同的语言。

你使用的是人类语言,而计算机使用的是机器语言。即使你不愿承认,对于计算机来说 JavaScript 甚至其他高级编程语言都是人类语言。这些语言是为人类的认知设计的,而不是机器。

所以 JavaScript 引擎的作用就是将你使用的人类语言转换成机器能够理解的东西。

我认为这就像电影 降临 里人类和外星人试图互相交谈的情节一样。

一个人用源代码示意,外星人以二进制回应

在电影中,人类和外星人在尝试交流的过程里并不只是做逐字翻译。这两个群体对世界有不同的思考方式,人类和机器也是如此(我将在下一篇文章中详细说明)。

既然这样,那转化是如何发生的呢?

在编程中,我们通常使用解释器和编译器这两种方法将程序代码转化为机器语言。

解释器会在程序运行时对代码进行逐行转义。

一个人正在白板前将代码翻译成二进制

相反的是,编译器会提前将代码转义并保存下来,而不是在运行时对代码进行转义。

一个人拿着一页翻译后的二进制代码

以上两种转化方式都各有优劣。

解释器的优缺点

解释器可以迅速开始工作。在运行代码之前,你不必等待所有的汇编步骤完成,只要开始转义第一行代码就可以运行程序了。

因此,解释器看起来自然很适用于 JavaScript 这类语言。对于 Web 开发者来说,能够快速运行代码相当重要。

这就是各浏览器在初期使用 JavaScript 解释器的原因。

但是当你重复运行同样的代码时,解释器的劣势就显现出来了。举个例子,如果在循环中,你就不得不重复对循环体进行转化。

编译器的优缺点

编译器的优缺点恰恰和解释器相反。

使用编译器在启动时会花费多一些时间,因为它必须在启动前完成编译的所有步骤。但是在循环体中的代码运行速度更快,因为它不需要在每次循环时都进行编译。

另一个不同之处在于编译器有更多时间对代码进行查看和编辑,来让程序运行得更快。这些编辑我们称为优化。

解释器在程序运行时工作,因此它无法在转义过程中花费大量时间来确定这些优化。

两全其美的解决办法 —— JIT 编译器

为了解决解释器在循环时重复编译导致的低效问题,浏览器开始将编译器混合进来。

不同浏览器的实现方式稍有不同,但基本思路是一致的。它们向 JavaScript 引擎添加了一个新的部件,我们称之为监视器(又名分析器)。监视器会在代码运行时监视并记录下代码的运行次数和使用到的类型。

起初,监视器只是通过解释器执行所有操作。

监视器监控代码运行并发出解释代码的信号

如果一段代码运行了几次,这段代码被称为 warm code;当这段代码运行了很多次时,它就会被称为 hot code。

基线编译器

当一个函数运行了数次时,JIT 会将该函数发送给编译器编译,然后把编译结果保存下来。

监视器发现一个函数运行了数次,示意应该将这段函数发送给基线编译器创建一个存根

该函数的每一行都被编译成一个“存根”,存根以行号和变量类型为索引(这很重要,我后面会解释)。如果监视器监测到程序再次使用相同类型的变量运行这段代码,它将直接抽取出对应代码的编译后版本。

这有助于加快程序的运行速度,但是像我说的,编译器可以做得更多。只要花费一些时间,它能够确定最高效的执行方式,即优化。

基线编译器可以完成一些优化(我会在后续给出示例)。不过,为了不阻拦进程过久,它并不愿意在优化上花费太多时间。

然而,如果这段代码运行次数实在太多,那就值得花费额外的时间对它做进一步优化。

优化编译器

当一段代码运行的频率非常高时,监视器会把它发送给优化编译器。然后得到另一个运行速度更快的函数版本并保存下来。

监视器发现一段代码运行了更多遍,示意这段代码应该被全面优化

为了得到运行速度更快的代码版本,优化编译器会做一些假设。

举例来说,如果它可以假设由特定构造函数创建的所有对象结构相同,即所有对象的属性名相同,并且这些属性的添加顺序相同,然后它就可以基于这个进行优化。

优化编译器会依据监视器监测代码运行时收集到的信息做出判断。如果在之前通过的循环中有一个值总是 true,它便假定这个值在后续的循环中也是 true。

但在 JavaScript 中没有任何情况是可以保证的。你可能会先得到 99 个结构相同的对象,但第 100 个就有可能缺少一个属性。

所以编译后的代码在运行前需要检查假设是否有效。如果有效,编译后的代码即运行。但如果无效,JIT 就认为它做了错误的假设并销毁对应的优化后代码。

监视器发现类型与期望不匹配,示意回到解释器。优化器将得到的优化代码销毁

进程会回退到解释器或基线编译器编译的版本。这个过程被称为去优化(或应急机制)。

通常优化编译器会加快代码运行速度,但有时它们也会导致意外的性能问题。如果你的代码被不断的优化和去优化,运行速度会比基线编译版本更慢。

为了防止这种情况发生,许多浏览器添加了限制,以便在“优化-去优化”这类循环发生时打破循环。例如,当 JIT 尝试了 10 次优化仍未成功时,就会停止当前优化。

优化示例: 类型专门化

优化的类型有很多,但我只演示其中一种以便你理解优化是如何发生的。优化编译器最大的成功之一来自于类型专门化。

JavaScript 使用的动态类型系统在运行时需要多做一些额外的工作。例如下面这段代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

执行循环中的 += 一步似乎很简单。看起来你可以一步就得到计算结果,但由于 JavaScript 的动态类型,处理它所需要的步骤比你想象的多。

假定 arr 是一个存放 100 个整数的数组。在代码执行几次后,基线编译器将为函数中的每个操作创建一个存根。sum += arr[i] 将会有一个把 += 依据整数加法处理的存根。

然而我们并不能保证 sum 和 arr[i] 一定是整数。因为在 JavaScript 中数据类型是动态的,有可能在下一次循环中的 arr[i] 是一个字符串。整数加法和字符串拼接是两个完全不同的操作,因此也会编译成非常不同的机器码。

JIT 处理这种情况的方法是编译多个基线存根。一段代码如果是单态的(即总被同一种类型调用),将得到一个存根。如果是多态的(即被不同类型调用),那么它将得到分别对应各类型组合操作的存根。

这意味着 JIT 在确定存根前要问许多问题。

4种类型检查的决策树

在基线编译器中,由于每一行代码都有各自对应的存根,每次代码运行时,JIT 要不断检查该行代码的操作类型。因此在每次循环时,JIT 都要询问相同的问题。

需要 JIT 在每次循环时询问类型的代码循环

如果 JIT 不需要重复这些检查,代码运行速度会加快很多。这就是优化编译器的工作之一了。

在优化编译器中,整个函数会被一起编译。所以类型检查可以在循环开始前完成。

在循环开始前询问问题的代码循环

一些 JIT 编译器做了进一步优化。例如,在 Firefox 中为仅包含整数的数组设立了一个特殊分类。如果 arr 是在这个分类下的数组,JIT 就不需要检查 arr[i] 是否是整数了。这意味着 JIT 可以在进入循环前完成所有类型检查。

总结

简而言之,这就是 JIT。它通过监控代码运行确定高频代码,并进行优化,加快了 JavaScript 的运行速度,因此令大多数 JavaScript 应用程序的性能提高了数倍。

即使有了这些改进,JavaScript 的性能仍是不可预测的。为了加速代码运行,JIT 在运行时增加了以下开销:

  • 优化和去优化
  • 用于存储监视器纪录和应急回退时的恢复信息的内存
  • 用于存储函数的基线和优化版本的内存

这里还有改进空间:除去以上的开销,提高性能的可预测性。这是 WebAssembly 实现的工作之一。

下一篇文章中,我将对汇编做更多说明并解释编译器与它是如何工作的。






原文发布时间为:2017年3月14日


本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。

时间: 2024-11-08 18:56:04

JIT 编译器快速入门的相关文章

汇编快速入门

本文讲的是汇编快速入门, 原文地址:A crash course in assembly 原文作者:Lin Clark 译文出自:掘金翻译计划 译者:zhouzihanntu 校对者:Tina92.zhaochuanxing 本文是 WebAssembly 系列文章的第三部分.如果你还没有阅读过前面的文章,我们建议你从头开始. 理解汇编和编译器如何生成它的有助于你后续理解 WebAssembly 的工作原理, 在介绍 JIT 的文章里,我谈到了与机器交流的方式和与外星人通信是相似的. 一个人用源

VC#2005快速入门之使用if语句

快速入门|语句 如果想根据一个布尔表达式的结果选择执行两个不同的代码块,就可以使用if语句. 理解if语句的语法 if语句的语法格式如下(if和else是关键字): if ( booleanExpression ) statement-1;else statement-2; 如果booleanExpression求值为true,就运行 statement-1:否则就运行statement-2.else关键字和后续的statement-2是可有可无的.如果没有else子句,那么在booleanEx

Visual C# 2005快速入门之运用作用域

visual|快速入门 前面已经展示了一些在方法内部创建变量的例子.变量从定义了它的语句开始存在,同一个方法内的后续语句可以使用该变量.换言之,变量只能在创建了之后才能使用.方法执行完毕后,变量也会彻底消失. 假如一个变量能在程序中的一个特定位置使用,就说明该变量具有那个位置的作用域.也就是说,一个变量的作用域(scope)是指能够使用该变量的程序区域.作用域既作用于方法,也作用于变量.一个标识符(不管它代表变量还是代表方法)的作用域是从声明明该标识符的那个位置开始的. 定义局部作用域 界定方法

Visual C#2005快速入门之声明bool变量

visual|变量|快速入门 与现实世界不同,在编程的世界中,每一件事情要么黑,要么白:要么对,要么错:要么是真的,要么是假的.例如,假定你创建一个名为x的整数变量,把值99赋给x,然后问:"x中包含了值99吗?"答案显然是肯定的.如果你问:"x小于10吗?"答案显然是否定的.这些正是布尔(Boolean)表达式的例子.一个布尔表达式肯定求值为true或false.   注意 对于这些问题,并非所有编程语言都会做出同样的回答.例如,一个未赋值的变量有一个未定义的值,

Visual C#2005快速入门之switch语句

visual|快速入门|语句 某些时候,在嵌套使用if语句时,所有if语句看起来都非常相似,因为它们都在对一个完全相同的表达式进行求值,惟一的区别是每个if语句都将表达式的结果与一个不同的值进行比较.例如: if (day == 0) dayName = "Sunday";else if (day == 1) dayName = "Monday";else if (day == 2) dayName = "Tuesday";else if (da

《Java语言导学(原书第6版)》一1.5 问题和练习:快速入门

1.5 问题和练习:快速入门 问题 1. 编译Java程序时,编译器会将源文件翻译成Java虚拟机能识别的平台无关的代码.这种平台无关的代码叫什么? 2. 下述哪项不是有效的代码注释? 若运行时出现下述错误,首先要检查什么? 如何正确定义main方法? 声明main方法时,要先用哪个修饰符,public还是static? main方法中要定义什么参数?

《R语言游戏数据分析与挖掘》一2.1 开发环境准备和快速入门

2.1 开发环境准备和快速入门 2.1.1 R语言简介 R语言的前身是S语言,S语言是由AT &T Bell实验室的Rick Becker.John Chambers和Allan Wilks开发的一种用来进行数据探索.统计分析.作图的解释型语言.最初S语言的实现版本主要是S-PLUS.S-PLUS是一个商业软件,它基于S语言,并由MathSoft公司的统计科学部进一步完善.而R语言最初由来自新西兰大学的Ross Ihaka和Robert Gentleman开发(由于他们的名字都以R开头,所以该软

数据库快速入门教程--视频

数据库快速入门教程--视频 下载地址:http://v.51work6.com/courseInfoRedirect.do?action=courseInfo&courseId=240579 本课程是这个课程体系的核心之一,为软件开发人员所需数据库知识的学习教材,而不是培训一个DBA(数据库管理员),更具体的说是为Web开发程序员所需数据库知识的学习教材.基于培养程序员的目标,本课程对数据库和SQL语句的相关知识进行了深刻地阐明和分析,学习的重点是标准SQL语句的学习的掌握.常用数据库MySQL

json快速入门学习教程

JSON快速入门 计算机语言中三种数据 1.标量 一个单独的字符串或者数字 比如"成都":7  2.序列 若干相关的数据按一定的顺序并列在一起(数组或列表) 比如"北京,成都":7 8 9 3.映射 名/值对 即数据名称与相对应的值 又称散列(hash)或字典 字典等等 比如 "四川省会:成都" JSON(JavaScript Object Notation)是一种轻量级的数据交换格式 JSON的四个基本规则 (1)并列的数据之刘用逗号(&quo