容易被遺忘的前端基礎:JavaScript 記憶體詳解

稻草叔叔發表於2019-03-21

目錄

JS記憶體目錄

簡介

某些語言,比如C有低階的原生記憶體管理原語,像malloc()和free()。開發人員使用這些原語可以顯式分配和釋放作業系統的記憶體。

相對地,JavaScript會在建立變數(物件、字串)時自動分配記憶體,並在這些變數不被使用時自動釋放記憶體,這個過程被稱為垃圾回收。這個“自動”釋放資源的特性帶來了很多困惑,讓JavaScript(和其他高階級語言)開發者誤以為可以不關心記憶體管理。這是一個很大的錯誤

記憶體生命週期

無論使用什麼程式語言,記憶體生命週期基本是一致的:

記憶體生命週期圖

  • 分配記憶體: 記憶體被作業系統分配,允許程式使用它 (當申明變數、函式、物件的時候,系統會自動為他們分配記憶體)
  • 使用記憶體:通過在程式碼操作變數對內在進行讀和寫 (也就是使用變數、函式等)
  • 釋放記憶體:不用的時候,就可以釋放記憶體,以便重新分配 (由垃圾回收機制自動回收不再使用的記憶體)

JS 記憶體模型

在JavaScript中的記憶體空間分為兩種:棧記憶體(stack)堆記憶體(heap), 而JavaScript的資料型別也分為兩大類, 分別是基本資料型別引用資料型別。 這些資料型別在記憶體中是怎樣儲存的?

說是JS記憶體模型其實不太準確,只是便於理解。由於JavaScript中的記憶體分配是由js引擎完成的,所以更準確的描述是js引擎的記憶體模型。
複製程式碼

基礎資料型別與棧記憶體

JS中的基礎資料型別,這些值都有固定的大小,往往都儲存在棧記憶體中(閉包除外),由系統自動分配儲存空間。我們可以直接操作儲存在棧記憶體空間的值,因此基礎資料型別都是按值訪問資料在棧記憶體中的儲存與使用方式類似於資料結構中的堆疊資料結構,遵循後進先出的原則。

所熟知的基礎資料型別:

NumberString、Null、Boolean、Undefiend、Symbol(ES6新增)
複製程式碼

簡單理解棧的存取方式,我們可以通過類比乒乓球盒子來分析。如下圖左側。

棧存取方式

這種乒乓球的存放方式與棧中存取資料的方式如出一轍。處於盒子中最頂層的乒乓球5,它一定是最後被放進去,但可以最先被使用。而我們想要使用底層的乒乓球1,就必須將上面的4個乒乓球取出來,讓乒乓球1處於盒子頂層。這就是棧空間先進後出,後進先出的特點。

引用資料型別與堆記憶體

與其他語言不同,JS的引用資料型別,比如陣列Array,它們值的大小是不固定的。引用資料型別的值是儲存在堆記憶體中的物件。JavaScript不允許直接訪問堆記憶體中的位置,因此我們不能直接操作物件的堆記憶體空間。在操作物件時,實際上是在操作物件的引用而不是實際的物件。因此,引用型別的值都是按引用訪問的。這裡的引用,我們可以粗淺地理解為儲存在變數物件中的一個地址,該地址與堆記憶體的實際值相關聯。

堆資料結構是一種樹狀結構。它的存取資料的方式,則與書架與書非常相似。

所熟知的引用資料型別:

ObjectArrayDateRegExpFunction 等。
複製程式碼

為了更好的搞懂變數物件與堆記憶體,我們可以結合以下例子與圖解進行理解。

var a1 = 0;   // 變數物件
var a2 = 'this is string'; // 變數物件
var a3 = null; // 變數物件

var b = { m: 20 }; // 變數b存在於變數物件中,{m: 20} 作為物件存在於堆記憶體中
var c = [1, 2, 3]; // 變數c存在於變數物件中,[1, 2, 3] 作為物件存在於堆記憶體中
複製程式碼

Javascript記憶體詳解-2

因此當我們要訪問堆記憶體中的引用資料型別時,實際上我們首先是從變數物件中獲取了該物件的地址引用(或者地址指標),然後再從堆記憶體中取得我們需要的資料。

理解了JS的記憶體空間,我們就可以藉助記憶體空間的特性來驗證一下引用型別的一些特點了。

接下來,我們通過下面的例子來加深對JS記憶體的理解

var a = 20;
var b = a;
b = 30;

var m = { a: 10, b: 20 };
var n = m;
n.a = 15; 
複製程式碼

此時a的值是什麼? 而m.a的值又是什麼?

Javascript記憶體詳解-3

在變數物件中的資料發生複製行為時,系統會自動為新的變數分配一個新值。var b = a執行之後,a與b雖然值都等於20,但是他們其實已經是相互獨立互不影響的值了。具體如圖。所以我們修改了b的值以後,a的值並不會發生變化。

Javascript記憶體詳解-4

通過var n = m執行一次複製引用型別的操作。引用型別的複製同樣也會為新的變數自動分配一個新的值儲存在變數物件中,但不同的是,這個新的值,僅僅只是引用型別的一個地址指標。當地址指標相同時,儘管他們相互獨立,但是在變數物件中訪問到的具體物件實際上是同一個。

垃圾回收

垃圾回收是一種記憶體管理機制,就是將不再用到的記憶體及時釋放,以防記憶體佔用越來越高,導致卡頓甚至程式崩潰。在JavaScript中有垃圾回收機制,其作用就是自動回收過期無效的變數。

在JavaScript中記憶體垃圾回收是由js引擎自動完成的。實現垃圾回收的關鍵在於如何確定記憶體不再使用,也就是確定物件是否無用。主要有兩種方式:引用計數 和 標記清除。

垃圾回收演算法

引用計數(reference counting)

這是IE6、7採用的一種比較老的垃圾回收機制。這是最初級的垃圾收集演算法。此演算法把“物件是否不再需要”簡化定義為“物件有沒有其他物件引用到它”。如果沒有引用指向該物件(零引用),物件將被垃圾回收機制回收。

var o1 = {
  o2: {
    x: 1
  }
};

//2個物件被建立
/'o2''o1'作為屬性引用
//誰也不能被回收

var o3 = o1; //'o3'是第二個引用'o1'指向物件的變數

o1 = 1;      //現在,'o1'只有一個引用了,就是'o3'
var o4 = o3.o2; // 引用'o3'物件的'o2'屬性
                //'o2'物件這時有2個引用: 一個是作為物件的屬性
                //另一個是'o4'

o3 = '374'; //'o1'原來的物件現在有0個對它的引用
             //'o1'可以被垃圾回收了。
            //然而它的'o2'屬性依然被'o4'變數引用,所以'o2'不能被釋放。

o4 = null;  //最初'o1'中的'o2'屬性沒有被其他的引用了
           //'o2'可以被垃圾回收了
複製程式碼

迴圈引用創造麻煩 在涉及迴圈引用的時候有一個限制。在下面的例子中,兩個物件被建立了,而且相互引用,這樣建立了一個迴圈引用。它們會在函式呼叫後超出作用域,應該可以釋放。然而引用計數演算法考慮到2個物件中的每一個至少被引用了一次,因此都不可以被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1\. 形成迴圈引用
}

f();
複製程式碼

Javascript記憶體詳解-6

標記清除(mark and sweep)

工作原理簡化後就是:從垃圾收集根(root)物件(在JavaScript中為全域性環境記錄)開始,標記出所有可以獲得的物件,然後清除掉所有未標記的不可獲得的物件。

這個演算法把“物件是否不再需要”簡化定義為“物件是否可以獲得”。 演算法包含以下步驟。

  • 垃圾回收器生成一個根列表。根通常是將引用儲存在程式碼中的全域性變數。在JavaScript中,window物件是一個可以作為根的全域性變數。
  • 所有的根都被檢查和標記成活躍的(不是垃圾),所有的子變數也被遞迴檢查。所有可能從根元素到達的都不被認為是垃圾。
  • 所有沒有被標記成活躍的記憶體都被認為是垃圾。垃圾回收器就可以釋放記憶體並且把記憶體還給作業系統。

Javascript記憶體詳解-7

2012年起,所有瀏覽器都內建了標記清除垃圾回收器。

記憶體洩漏

記憶體洩漏基本上就是不再被應用需要的記憶體,由於某種原因,沒有被歸還給作業系統或者進入可用記憶體池。 簡單來說: 就是不再用到的記憶體,沒有及時釋放,就叫做記憶體洩漏(memory leak)。

Chrome 瀏覽器檢視記憶體佔用

按照以下步驟操作

  • 開啟Chrome瀏覽器開發者工具的Performance皮膚
  • 選項欄中勾選Memory選項
  • 點選左上角錄製按鈕(實心圓狀按鈕)
  • 在頁面上進行正常操作
  • 一段時間後,點選Stop,觀察皮膚上的資料

Javascript記憶體詳解-8

更多方式檢視記憶體佔用,點選這裡

4種常見的JavaScript記憶體洩漏

    1. 意外的全域性變數
    1. 被遺忘的定時器或者回撥
    1. 閉包
    1. DOM外引用

閉包本身不會造成記憶體洩露,程式寫錯了才會造成記憶體洩漏或者閉包過多很容易導致記憶體洩漏。

具體詳情點選【譯】JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩露

總結

JS記憶體詳解

JS記憶體詳解

原文地址

參考資料

相關文章