WebAssembly 系列(二)JavaScript Just-in-time (JIT) 工做原理

做者:Lin Clark

编译:胡子大哈 javascript

翻译原文:huziketang.com/blog/posts/…

英文原文:A crash course in just-in-time (JIT) compilersjava

转载请注明出处,保留原文连接以及做者信息react


本文是关于 WebAssembly 系列的第二篇文章。若是你没有读先前文章的话,建议先读这里。若是对 WebAssembly 没概念,建议先读这里(中文文章)编程

JavaScript 的启动比较缓慢,可是经过 JIT 可使其变快,那么 JIT 是如何起做用的呢?数组

JavaScript 在浏览器中是如何运行的?

若是是你一个开发者,当你决定在你的页面中使用 JavaScript 的时候,有两个要考虑的事情:目标和问题。浏览器

目标:告诉计算机你想作什么。编程语言

问题:你和计算机说不一样的语言,没法沟通。函数

你说的是人类的语言,而计算机用的是机器语言。机器语言也是一种语言,只是 JavaScript 或者其余高级编程语言机器能看得懂,而人类不用他们来交流罢了。它们是基于人类认知而设计出来的。post

因此呢,JavaScript 引擎的工做就是把人类的语言转换成机器能看懂的语言。性能

这就像电影《降临》中,人类和外星人的互相交流同样。

在电影里面,人类和外星人不只仅是语言不一样,两个群体看待世界的方式都是不同的。其实人类和机器也是相似(后面我会详细介绍)。

那么翻译是如何进行的呢?

在代码的世界中,一般有两种方式来翻译机器语言:解释器和编译器。

若是是经过解释器,翻译是一行行地边解释边执行

编译器是把源代码整个编译成目标代码,执行时再也不须要编译器,直接在支持目标代码的平台上运行。

这两种翻译的方式都各有利弊。

解释器的利弊

解释器启动和执行的更快。你不须要等待整个编译过程完成就能够运行你的代码。从第一行开始翻译,就能够依次继续执行了。

正是由于这个缘由,解释器看起来更加适合 JavaScript。对于一个 Web 开发人员来说,可以快速执行代码并看到结果是很是重要的。

这就是为何最开始的浏览器都是用 JavaScript 解释器的缘由。

但是当你运行一样的代码一次以上的时候,解释器的弊处就显现出来了。好比你执行一个循环,那解释器就不得不一次又一次的进行翻译,这是一种效率低下的表现。

编译器的利弊

编译器的问题则刚好相反。

它须要花一些时间对整个源代码进行编译,而后生成目标文件才能在机器上执行。对于有循环的代码执行的很快,由于它不须要重复的去翻译每一次循环。

另一个不一样是,编译器能够用更多的时间对代码进行优化,以使的代码执行的更快。而解释器是在 runtime 时进行这一步骤的,这就决定了它不可能在翻译的时候用不少时间进行优化。

Just-in-time 编译器:综合了二者的优势

为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,造成混合模式。

不一样的浏览器实现这一功能的方式不一样,不过其基本思想是一致的。在 JavaScript 引擎中增长一个监视器(也叫分析器)。监视器监控着代码的运行状况,记录代码一共运行了多少次、如何运行的等信息。

起初,监视器监视着全部经过解释器的代码。

若是同一行代码运行了几回,这个代码段就被标记成了 “warm”,若是运行了不少次,则被标记成 “hot”。

基线编译器

若是一段代码变成了 “warm”,那么 JIT 就把它送到编译器去编译,而且把编译结果存储起来。

代码段的每一行都会被编译成一个“桩”(stub),同时给这个桩分配一个以“行号 + 变量类型”的索引。若是监视器监视到了执行一样的代码和一样的变量类型,那么就直接把这个已编译的版本 push 出来给浏览器。

经过这样的作法能够加快执行速度,可是正如前面我所说的,编译器还能够找到更有效地执行代码的方法,也就是作优化。

基线编译器能够作一部分这样的优化(下面我会给出例子),不过基线编译器优化的时间不能过久,由于会使得程序的执行在这里 hold 住。

不过若是代码确实很是 “hot”(也就是说几乎全部的执行时间都耗费在这里),那么花点时间作优化也是值得的。

优化编译器

若是一个代码段变得 “very hot”,监视器会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,而且存储之。

为了生成一个更快速的代码版本,优化编译器必须作一些假设。例如,它会假设由同一个构造函数生成的实例都有相同的形状——就是说全部的实例都有相同的属性名,而且都以一样的顺序初始化,那么就能够针对这一模式进行优化。

整个优化器起做用的链条是这样的,监视器从他所监视代码的执行状况作出本身的判断,接下来把它所整理的信息传递给优化器进行优化。若是某个循环中先前每次迭代的对象都有相同的形状,那么就能够认为它之后迭代的对象的形状都是相同的。但是对于 JavaScript 历来就没有保证这么一说,前 99 个对象保持着形状,可能第 100 个就少了某个属性。

正是因为这样的状况,因此编译代码须要在运行以前检查其假设是否是合理的。若是合理,那么优化的编译代码会运行,若是不合理,那么 JIT 会认为作了一个错误的假设,而且把优化代码丢掉。

这时(发生优化代码丢弃的状况)执行过程将会回到解释器或者基线编译器,这一过程叫作去优化

一般优化编译器会使得代码变得更快,可是一些状况也会引发一些意想不到的性能问题。若是你的代码一直陷入优化<->去优化的怪圈,那么程序执行将会变慢,还不如基线编译器快。

大多数的浏览器都作了限制,当优化/去优化循环发生的时候会尝试跳出这种循环。好比,若是 JIT 作了 10 次以上的优化而且又丢弃的操做,那么就不继续尝试去优化这段代码了桩。

一个优化的例子:类型特化(Type specialization)

有不少不一样类型的优化方法,这里我介绍一种,让你们可以明白是如何优化的。优化编译器最成功一个特色叫作类型特化,下面详细解释。

JavaScript 所使用的动态类型体系在运行时须要进行额外的解释工做,例以下面代码:

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

+= 循环中这一步看起来很简单,只须要进行一步计算,可是偏偏由于是用动态类型,他所须要的步骤要比你所想象的更复杂一些。

咱们假设 arr 是一个有 100 个整数的数组。当代码被标记为 “warm” 时,基线编译器就为函数中的每个操做生成一个桩。sum += arr[i] 会有一个相应的桩,而且把里面的 += 操做当成整数加法。

可是,sumarr[i] 两个数并不保证都是整数。由于在 JavaScript 中类型都是动态类型,在接下来的循环当中,arr[i] 颇有可能变成了 string 类型。整数加法和字符串链接是彻底不一样的两个操做,会被编译成不一样的机器码。

JIT 处理这个问题的方法是编译多基线桩。若是一个代码段是单一形态的(即老是以同一类型被调用),则只生成一个桩。若是是多形态的(即调用的过程当中,类型不断变化),则会为操做所调用的每个类型组合生成一个桩。

这就是说 JIT 在选择一个桩以前,会进行多分枝选择,相似于决策树,问本身不少问题才会肯定最终选择哪一个,见下图:

正是由于在基线编译器中每行代码都有本身的桩,因此 JIT 在每行代码被执行的时候都会检查数据类型。在循环的每次迭代,JIT 也都会重复一次分枝选择。

若是代码在执行的过程当中,JIT 不是每次都重复检查的话,那么执行的还会更快一些,而这就是优化编译器所须要作的工做之一了。

优化编译器中,整个函数被统一编译,这样的话就能够在循环开始执行以前进行类型检查。

一些浏览器的 JIT 优化更加复杂。好比在 Firefox 中,给一些数组设定了特定的类型,好比里面只包含整型。若是 arr 是这种数组类型,那么 JIT 就不须要检查 arr[i] 是否是整型了,这也意味着 JIT 能够在进入循环以前进行全部的类型检查。

总结

简而言之 JIT 是什么呢?它是使 JavaScript 运行更快的一种手段,经过监视代码的运行状态,把 hot 代码(重复执行屡次的代码)进行优化。经过这种方式,可使 JavaScript 应用的性能提高不少倍。

为了使执行速度变快,JIT 会增长不少多余的开销,这些开销包括:

  • 优化和去优化开销
  • 监视器记录信息对内存的开销
  • 发生去优化状况时恢复信息的记录对内存的开销
  • 对基线版本和优化后版本记录的内存开销

这里还有很大的提高空间:即消除开销。经过消除开销使得性能上有进一步地提高,这也是 WebAssembly 所要作的事之一。


我最近正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,欢迎指点

相关文章
相关标签/搜索