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…
相關文章
相關標籤/搜索