76

解读 JavaScript 之内存管理和常见内存泄露处理

 6 years ago
source link: https://www.oschina.net/translate/how-does-javascript-actually-work-part-3?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

解读 JavaScript 之内存管理和常见内存泄露处理

几周前,我们开始了一系列旨在深入挖掘 JavaScript 及其工作原理的研究:我们认为,通过了解 JavaScript 的构建块及其组合方式,你将可以更好的编写代码和应用程序。

该系列研究的第一篇幅重点介绍了引擎、运行时和调用堆栈的概述。第二篇幅则深入测试研究了谷歌的 V8 JavaScript 引擎的内部零件,也提供了一些关于如何更好编写 JavaScript 代码的技巧

在该研究的第三篇,我们将讨论的另一个重要的话题:内存管理。现如今编程语言日益成熟和复杂,导致内存管理日渐被开发者所忽略,但不得不承认内存管理是编程语言日常使用的基本技术。我们会提供一些关于如何在 JavaScript 处理内存泄漏的方式,比如,在 SessionStack 中需要确保 SessionStack 不会造成内存泄漏或在 Web 中不增加带有内存消耗的集成应用程序。

翻译于 2017/12/13 13:47

类似 C 这样的语言有着诸如 malloc() 和 free() 这种低级内存管理原语。开发者用这些原语明确的从操作系统中分配和释放内存。

与此同时呢,在 Javascript 中,在有东西(对象,字符串等等)被创建时分配内存,当这些东西不再使用时就有一个叫垃圾回收的程序来“自动”释放掉。这种看起来很自然的“自动”释放资源的行为,其实是让人混乱的源头,因为它给 JavaScript(以及其他的高级语言)开发者一种错误的感觉,那就是他们可以不用关心内存管理相关的事。这是一个很大的错误。

甚至在使用高级语言的时候,开发者也应该对内存管理有所了解(至少知道一些基本的东西)。有的时候,自动内存管理是有些问题的(比如垃圾回收器中存在 bug 或只实现了有限的功能等)。开发者应该了解这些东西以便于能够采取合适的处理方式(或找到一个合适的代价最小的解决方案)。

翻译于 2017/12/13 12:02

内存生命周期

无论你使用什么编程语言,内存生命周期几乎都是一样的:

110340_kUHL_2896879.png

以下是对循环中每个步骤发生的情况的概述:

  • 分配内存 - 内存由允许程序使用它的操作系统分配。 在低级语言(如C语言)中,这是一个开发人员应该掌握的明确操作。 然而,在高级语言中,这是为你服务的。

  • 使用内存 - 这是你的程序实际上使用之前分配的内存的时间。 读取和写入操作正在你的代码中使用分配的变量。

  • 释放内存 - 现在是释放你不需要的整个内存的时间,以便它可以变成空闲的并且可以再次使用。 与分配内存操作一样,这个操作在低级语言中是明确的。

有关调用堆栈和内存堆的概念的快速概述,可以阅读我们关于主题的第一篇文章

翻译于 2017/12/13 11:31

什么是内存?

在直奔 JavaScript 内存之前,我们将会简要的讨论一下什么是内存,以及它是如何工作的。

在硬件层面,计算机内存是由大量的触发器组成的。每一个触发器都包含有一些晶体管,能够存储1比特。单个触发器可通过一个唯一标识符来寻址,这样我们就可以读和写了。因此从概念上讲,我们可以把计算机内存看作是一个巨大的比特数组,我们可以对它进行读和写。

但是作为人类,我们并不善于用比特来思考和运算,因此我们将其组成更大些的分组,这样我们就可以用来表示数字。8个比特就是一个字节。比字节大的有字(16比特或32比特)。

有很多东西都存储在内存中:

  1. 所有被程序使用的变量和其他数据

  2. 程序的代码,包括操作系统自身的代码

编译器和操作系统一起为你做了大部分的内存管理工作,但是我建议你了解下其中背后到底发生了些什么。

翻译于 2017/12/13 12:27

当你编译你的代码时,编译器可以检查原始的数据类型并且提前计算出将会需要多少内存。然后把所需的(内存)容量分配给调用栈空间中的程序。这些变量因为函数被调用而分配到的空间被称为堆栈空间,它们的内存增加在现存的内存上面(累加)。如它们不再被需要就会按照 LIFO(后进,先出)的顺序被移除。例如,参见如下声明:

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器可以立即清楚这段代码需要4 + 4 × 4 + 8 = 28字节。

这就是它怎样工作于当前的 integers 和 doubles 型的大小。约20年前,integers通常(占用)2字节,double占4字节。你的代码不应该依赖于此时刻的基本数据类型的大小。

编译器将插入些会互相作用于操作系统在堆栈上去请求必要的字节数来存储变量代码。

在以上例子中,编译器知道每个变量精确的内存地址。事实上,无论我们何时写入变量n,而本质上这会被翻译为如“内存地址 4127963 ”。

注意:如我们尝试去访问 accessx[4],我们将访问与 m 相关的数据。这是因为我们访问数组中的一个元素并不存在--它比数组中的最后一个被分配的元素x[3]多4个字节(简述:越界),并且最终可能读取(或覆盖)m中的一些字节。这几乎可以肯定对程序的后续部分产生不良的后果。

110434_abHP_2896879.png

当函数调用其它函数时,每个函数都会分到自己的堆栈块。它保存着所有的本地变量,而且还有一个程序计数器,它记录着程序执行的位置。当函数执行完毕后,其内存块可再次用于其他用途。

翻译于 2017/12/15 15:02

不幸的是,当我们不知道在编译时一个变量需要多少内存的时候,事情就不是那么简单了。假定我们打算做下面的事情:

int n = readInput(); // reads input from the user
...
// create an array with "n" elements

在编译时,编译器并不知道数组需要多大的内存,因为它的大小是由用户提供的值来决定的。

因此,不能够从栈上来分配空间。我们的程序在运行时需要显示的向操作系统申请正确的内存空间。这段内存是从堆区分配的。静态和动态内存分配的不同点总结起来如下表:

110523_b2Ux_2896879.png

静态和动态内存分配的不同

要完全理解动态内存分配是如何工作的,我们需要在指针上花更多的时间,这个偏离本文主题有点多。如果你有兴趣了解更多,就在评论中告诉我,我会在将来的文章中深入探讨指针的细节。

翻译于 2017/12/13 12:41

JavaScript 中的内存分配

现在我们来解释下 JavaScript 中的第一步(内存分配)是如何工作的。

JavaScript 让开发者从处理内存分配的责任中解放出来,连带着声明值都由 JavaScript 替你做了。

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object)
// function expressions also allocate an object
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

一些函数的调用也会导致对象的分配:

var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element

方法可以分配新的值或对象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// Since strings are immutable, 
// JavaScript may decide to not allocate memory, 
// but just store the [0, 3] range.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// new array with 4 elements being
// the concatenation of a1 and a2 elements
翻译于 2017/12/13 12:46

在 JavaScript 中使用内存

基本上在 JavaScript 中使用分配的内存,就是对它进行读和写操作。

可以读写变量的值或某个对象的属性,甚至是给某个函数传递一个参数。

当内存不再需要的时候要释放掉

大部分的内存管理问题出现在这个阶段。

这里面最难的任务是指出,在什么时候分配的内存不再被需要。这通常需要开发者来决定程序中的那一块内存不再需要了,并释放。

高级语言嵌入了一个叫垃圾收集器的程序,它可以跟踪内存分配和使用情况,以找出在哪种情况下某一块已分配的内存不再被需要,并自动的释放它。

不幸的是,这种程序只是一种近似的操作,因为知道某块内存是否被需要是不可判定的(并不能通过算法来解决)。

大部分的垃圾收集器的工作方式是收集那些不能够被再次访问的内存,比如超出作用域的变量。但是,能够被收集的内存空间是低于近似值的,因为在任何时候都可能存在一个在作用域内的变量指向一块内存区域,但是它永远不能够被再次访问。

翻译于 2017/12/13 14:57

由于发现某些内存是否“不再需要”是不可预测的事实,因此垃圾回收实现了对常规问题的解决方案的限制。本节将解释理解主要垃圾回收算法及其局限性的必要概念。

垃圾回收算法所依赖的主要概念之一是引用

在内存管理的上下文中,一个对象是另一个对象的引用是指:该对象可以访问后者(可以是隐含的或显式的)。例如,JavaScript 对象具有对其原型(隐式引用)及其属性值(显式引用)的引用。

在这种情况下,“对象”的概念被扩展到比普通 JavaScript 对象更广泛的范围,并且还包含函数作用域(或全局词法作用域)。

词法作用域定义了如何在嵌套函数中解析变量名称:即使父函数已经返回,内部函数也包含父函数的作用域。
翻译于 2017/12/13 14:13

基于引用计数的垃圾回收

这是最简单的内存回收算法。一个对象被认为是垃圾回收的条件是指向它的引用计数为0。

看看下面的代码:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.

环式引用产生问题

在环式引用方面有一个限制。在下面的例子中,创建了两个对象并相互引用,从而创建了一个闭环。在函数调用之后,它们会超出生命周期,所以它们实际上是无用的,可以被释放的。然而,引用计数算法认为,由于两个对象中的每一个都被引用至少一次,所以两者都不是能被垃圾回收的。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

110759_xQul_2896879.png
翻译于 2017/12/13 11:48

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK