javascript的内存管理以及3种常见的内存泄漏

前言

根据GitHut stats的统计数据显示,javascript语言在Github中的活跃项目仓库数量和总的push数量已经登上了榜首的位置,并且在愈来愈多的领域里咱们都能看见javascript持续活跃的身影和不断前行的脚步
javascript


尽管咱们正在愈来愈多的编写Javascript代码,可是咱们不必定真的了解它。编写本系列专栏的目的就是深刻到javascript的底层,了解其运行原理,帮助咱们写出更高效的代码,减小一些没必要要的bug.
javascript代码的执行分为3个部分:runtime, js engine, event loop,运行时(runtime)提供了window,dom等API注入,js引擎负责内存管理,代码编译执行,事件循环则负责处理咱们的异步逻辑,具体以下图所示:
javascript代码执行示意图

在这篇文章中我将主要探讨javascript内存管理,调用栈以及如何处理内存泄漏问题。 在后续的文章中我会继续介绍事件循环以及js引擎的执行机制。java

内存管理

javascript自带垃圾回收机制,它能够自动分配内存并回收再也不使用的内存,这也使不少开发者认为咱们没有必要去关注js的内存管理。可是我相信咱们在平时开发过程当中都或多或少的遇到过内存泄漏问题,理解javascript的内存管理机制能够帮助咱们解决此类问题并写出更好的代码,并且做为一名程序员,咱们也应当保持足够的好奇心去了解咱们写出的代码在底层的运行原理。node

内存是什么

在进入具体探讨以前,咱们先来看下内存究竟是什么。内存从物理意义上是指由一系列晶体管构成的能够存储数据的回路,从逻辑的角度咱们能够将内存看做是一个巨大的可读写的比特数组。它存储着咱们编写的代码以及咱们在代码中定义的各种变量。对于不少静态类型编程语言来讲,在代码进入编译阶段时编译器会根据变量声明时指定的类型提早申请分配给该变量的内存(好比,整型变量对应的是4个字节,浮点数对应8个字节)。内存区域分为栈空间和堆空间两部分,对于能够肯定大小的变量,它们会被存储在栈空间中,好比:git

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

还有一种类型的变量,不能在编译阶段就肯定其须要多大的存储区域,其占用内存大小是在运行时肯定的,好比:程序员

int n = readInput(); // n的大小依赖于用户的输入
复制代码

对于这一类型的变量,它们会被存储在堆空间中。内存静态分配(static allocation)与动态分配(Dynamic allocation)的区别以下所示:
算法

内存生命周期

对于任何编程语言,内存的生命周期都基本一致,以下所示:
express

  1. 分配内存,在一些底层语言,好比c语言中咱们也能够经过malloc() 和 free()函数来手动完成内存的分配和释放。在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
复制代码
  1. 使用内存,变量完成内存分配以后咱们的程序才可使用它们,作一些读或写的操做。
  2. 释放内存,当程序不须要再使用某些变量时,它们占用的内存就会进行释放,腾出空间。这里最大的问题是如何断定哪些变量是须要被回收的,对于像javascript这样的高级语言来讲内存释放过程是由垃圾回收器完成的,它用于肯定可回收内存的方法主要有两种:引用计数与标记清除。

引用计数

在讨论该算法前,咱们先来看下什么是引用(reference)。所谓引用是指一个对象与另外一个对象的链接关系,若是对象A能够隐式或显式的访问对象B,那么咱们就能够说对象A拥有一个对对象B的引用,好比在javascript中一个object能够经过__proto__访问到其prototype对象(隐式),也能够直接访问其属性(显式)。
对于引用计数算法来讲,它断定一个目标是能够被回收的标志就是该目标再也不存在与其余对象的引用关系,好比:编程

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.
复制代码

在上述示例中,o4就是能够被回收的,引用计数在大多数状况下都是没什么问题的,可是当咱们遇到循环引用它就会遇到麻烦,好比:数组

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

f();
复制代码

在上述示例中,o1,o2互相引用,使得彼此都不能被释放。浏览器

标记清除算法

标记清除判断某个对象是否能够被回收的标志是该对象不能再被访问到。其执行过程总共分为三步:

  1. 肯定根对象:在javascript中根对象主要是指全局对象,好比浏览器环境中的window,node.js中的global。
  2. 从根对象开始遍历子属性,并将这些属性变量标记为活跃类型,经过根对象不能访问到的就标记为可回收类型。
  3. 根据第二步标记出来的结果进行内存回收。
    标记清除的算法执行示意图以下所示:

    标记清除的算法比引用计数更优秀的地方在于它们对于可回收对象的断定方式上,一个对象不存在引用关系可使该对象不能被访问到,而反过来则不必定成立,好比以前的循环引用问题,当函数执行完成以后,o1,o2这两个变量都不能经过window查找到,在标记清除算法下会被看成可回收类型。

内存泄漏

内存泄漏是指再也不使用的内存区域没有被回收,致使这一块内存区域被白白浪费。虽然咱们有前面提到的垃圾回收算法,可是咱们在平常开发过程当中仍然会时时遇到内存泄漏的问题,主要有如下三种类型:

  1. 全局变量
    考虑如下这段代码:
function foo(arg) {
    bar = "some text";
}
复制代码

在这段代码中咱们在函数foo里给变量bar赋值了一个字符串,可是咱们没有在该函数做用域内先声明它,在javascript执行过程当中,bar会被挂载到全局变量window中(假设当前是浏览器环境),看成是window的属性。这带来的问题是即便函数foo执行完毕,该变量仍然是可访问到的,其占用的内存不会获得释放,从而致使内存泄漏。
咱们在平常开发过程当中要尽可能避免使用全局变量,除了污染全局做用域的问题,内存泄漏也是一个不容忽视的因素。
2. 闭包
内部函数能够访问其外部做用域内的变量,闭包为咱们编写javascript代码带来史无前例的灵活性,可是闭包也有可能会带来内存泄漏的风险。好比下面这段代码:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // a reference to 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
复制代码

在函数replaceThing中,函数unused会造成一个闭包并含有对originalThing的引用,一旦replaceThing执行,theThing会被赋予一个对象做为新值,在该对象中也会定义一个新的闭包someMethod, 这两个闭包是在相同的父级做用域中建立的,所以它们会共享外部做用域。因为someMethod方法能够经过theThing在replaceThing外部访问到,即便unused没有被调用,它对变量originalThing的引用会使该做用域不会被回收。由于每一个闭包做用域都含有对longstr的间接引用,这种状态下会致使大量的内存泄漏。
3. dom引用
先来看下面这一段代码:

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}
复制代码

在上述代码中咱们在两个地方保存了对image元素的引用,当函数removeImage执行时尽管image元素被删除,可是全局变量elements中仍然存在对button元素的引用,内存回收时不会将该元素回收。
除此以外还有另外一种状况也值得引发注意。若是你存在对某个table cell(td 标签)的引用,当你在dom树中删除它所属的table但该引用并无删除时也一样会发生内存泄漏,垃圾回收器并不会像你所想的那样回收全部只保留cell,而是会将整个table都保存在内存中,由于该table是cell的父节点,该cell依然会保持对其父节点的引用。

调用栈(call stack)

调用栈是内存中的一块存储区域,它负责记录程序当前的执行位置,咱们能够经过一个示例来看下调用栈的工做模式,先来看以下代码:

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}
printSquare(5);
复制代码

当这段代码开始执行时,调用栈会随着函数的调用发生变化


当printSquare被调用时它会先进栈,在函数执行过程当中调用了函数multiply,函数multiply被压入栈顶,执行完成以后出栈。再来看另一个示例:

function foo() {
    throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
    foo();
}
function start() {
    bar();
}
start();
复制代码

当这段代码执行时报错提示以下所示:


根据报错位置指示的函数名称,咱们能够对整个调用栈的顺序一目了然。
调用栈的空间是有限的,当函数调用信息超过该空间大小,就会发生常见的堆栈溢出的错误,好比:

function foo() {
    foo();
}
foo();
复制代码

它会不断的调用自身,其调用栈存储示意图和执行报错以下所示:


结语

本篇文章主要探讨了javascript中的内存管理策略,介绍了内存的分配,内存的回收以及三种容易致使内存泄漏的场景还有代码执行用到的调用栈等等,属于javascript中比较基础但却容易忽视的知识点,但愿对您有所帮助。

参考文章

  1. blog.sessionstack.com/how-does-ja…
  2. blog.sessionstack.com/how-javascr…
相关文章
相关标签/搜索