[译] JavaScript 运行原理

3,959 阅读7分钟

原文地址:How Does JavaScript Really Work? (Part 2)

JavaScript V8 引擎是如何与内存管理,调用堆栈,线程和事件循环协同工作的。

上一篇文章中,我简要概述了编程语言的工作原理及 V8 引擎的细节。 这篇文章将涵盖每个 JavaScript 程序员都必须知道的一些重要概念,不仅限于 V8 引擎。

时间复杂度空间复杂度是所有程序员都关注两个问题。 上一篇文章介绍了 V8 的速度和优化部分,以提高 JavaScript 的执行时间,该部分将重点介绍内存管理方面。

内存

Orinoco logo: V8 的垃圾回收器

  • 每当您在 JavaScript 中定义变量,常量,对象等时,都需要一些地方来存储它。 这个地方就是内存。
  • 当遇到语句 var a = 10 时,内存将分配一个位置来存储 a 的值。
  • 可用内存是有限的,复杂的程序可能包含许多变量和嵌套对象。 因此合理地利用可用内存至关重要。
  • 与像 C 这样需要显式分配和释放内存的语言不同,JavaScript 提供了自动垃圾收集的功能。 一旦对象/变量脱离上下文并且不再使用,它占用的内存将被回收并返回空闲内存池。
  • 在 V8 中,垃圾收集器名为 Orinoco,它能高效地完成上面介绍的过程。

标记扫描算法

标记扫描算法

为了确定可以从内存中安全删除的对象,使用了这种简单有效的算法。 该算法的名称描述了其工作原理;将对象标记为可访问/不可访问,并清除不可访问的对象。

垃圾收集器会定期从根对象或全局对象开始,然后遍历它们所引用的对象,然后再遍历这些引用所引用的对象,依此类推。然后清除所有无法访问的对象。

内存泄漏

尽管垃圾回收是高效的,但这并不意味着开发人员可以对内存管理不管不顾。 管理内存是一个复杂的过程,确定哪一块内存是不需要的不能完全依赖算法。

内存泄漏是指程序使用过的一部分内存,现在不再使用了,但这些内存并未返回到内存池。

以下是一些导致程序内存泄漏的常见错误。

全局变量:如果您持续地创建全局变量,即使您不使用它们,它们也会在程序执行过程中始终存在。如果这些变量是深层嵌套的对象,则会浪费大量内存。

var a = { ... }
var b = { ... }
function hello() {
  c = a;  // this is the global variable that you aren't aware of.
}

如果您访问未声明的变量,则将在全局范围内创建一个变量。 在上面的示例中,c 是您没有使用 var 关键字隐式创建的全局变量/全局对象。

Event Listeners: This may happen when you create a lot of event listeners to make your website interactive or maybe just for those flashy animations and forget to remove them when the user moves to some other page in your single page application. Now when the user moves back and forth between these pages, these listeners keep adding up.

事件监听:假设您在网页中创建了大量监听事件来实现交互或动画,当用户跳转到单页应用程序中的其他页面时,而您忘记了移除它们,就可能会发生内存泄漏。 因为当用户在这些页面之间来回跳转时,这些事件监听会不断累加。

var element  = document.getElementById('button');
element.addEventListener('click', onClick)

定时器:当引用这些闭包中的对象时,垃圾收集器将永远不会清除被引用的对象,直到闭包本身被清除。

setInterval(() => {
  // reference objects
}
// now forget to clear the interval.
// you just created a memory leak!

被删除的 DOM 节点:有点类似于全局变量内存泄漏,并且非常常见。 DOM 节点存储在 Object Graph memory 和 DOM tree 中。通过一个示例可以更好地说明这种情况。

var terminator = document.getElementById('terminate');
var badElem = document.getElementById('toDelete');
terminator.addEventListener('click', function()  {memory
  badElem.remove();
});

在点击 id ='terminate' 的按钮后,toDelete 节点将从 DOM tree 中删除。 但是,由于事件监听中引用了该对象 badElem,因此会认为该对象分配的内存仍在被使用中。

var terminator = document.getElementById('terminate');
terminator.addEventListener('click', function()  {
  var badElem = document.getElementById('toDelete');
  badElem.remove();
});

现在,badElem 变量被定义为一个局部变量,当删除操作完成时,垃圾回收器可以回收它的内存。

调用堆栈

堆栈是遵循 LIFO(后进先出)方法来存储和访问数据的数据结构。对 JavaScript 引擎来说,堆栈用于记住函数中最后执行的命令的位置。

function multiplyByTwo(x) {
  return x*2;
}
function calculate() {
  const sum = 4 + 2;
  return multiplyByTwo(sum);
}
calculate()
var hello = "some more code follows"
  1. 首先,引擎知道程序中有两个函数。
  2. 运行calculate()函数。
  3. 在调用堆栈上弹出calculate函数并计算总和。
  4. 运行multiplyByTwo()函数。
  5. 在调用栈上弹出multiplyByTwo函数,并执行算术运算x * 2。
  6. 返回该值时,从堆栈中弹出multiplyByTwo(),然后返回calculate()函数。
  7. calculate()函数返回时,从堆栈中弹出calculate,然后继续执行代码。

堆栈溢出

堆栈溢出

在不弹出堆栈的情况下,连续压栈量取决于堆栈的大小。 如果您继续压栈达到堆栈容量的极限,将导致堆栈溢出,此时 chrome 浏览器 会报错,同时生成堆栈快照,也称为堆栈帧

递归:当函数调用自身时,称为递归。 在您想减少算法执行的时间(时间复杂度),但是其它方法理解和实现起来很复杂时,递归就显得非常有用。

在下面这个示例中,return 1 语句永远不会被执行,并且 lonely 函数会不断调用自身而不会返回,最终导致堆栈溢出。

function lonely() {
 if (false) {
  return 1;  // the base case
 }
 lonely();   // the recursive call
}

JavaScript 为什么是单线程的?

多个线程表示您可以同时独立执行程序的多个部分。 确定一种语言是单线程还是多线程的最简单方法是看它拥有有多少个调用堆栈。 JS 只有一个,所以它是单线程语言。

您可能会想这不是瓶颈吗? 如果我运行多个耗时的操作,也称为阻塞操作(如HTTP请求),那么该程序将必须等待每个操作的响应完成后,再执行下一个操作。

为了解决这个问题,我们需要一种异步执行任务的方法来解决单线程的弊端,事件循环为此而生。

事件循环

目前,上面提到的大部分内容都被 V8 囊括,但是如果您在 V8 代码库中搜索诸如 setTimeout 或 DOM 之类的内容的实现,那你可能什么都找不到。因为除了运行时引擎外,JS 还包含 Web API 模块,这些 API 是浏览器提供来扩展 JS 功能的。

您可以在这个视频中了解个中详情。

结语

编写一门编程语言还有很多工作要做,而且每年它的实现方式可能也在不断变化。 我希望这两篇文章可以帮助您成为更好的 JS 程序员,并接纳 JS 怪异的部分。 现在,您应该习惯使用V8事件循环调用堆栈等术语。

大多数和我一样人学习 JS 都是从学习一个新的框架开始。 我觉得我们现在应该对引擎内部执行的东西有一些了解,这将有助于我们编写出更好的代码。