JavaScript 記憶體機制(前端同學進階必備)

樑音發表於2018-06-01

簡介

每種程式語言都有它的記憶體管理機制,比如簡單的C有低階的記憶體管理基元,像malloc(),free()。同樣我們在學習JavaScript的時候,很有必要了解JavaScript的記憶體管理機制。 JavaScript的記憶體管理機制是:記憶體基元在變數(物件,字串等等)建立時分配,然後在他們不再被使用時“自動”釋放。後者被稱為垃圾回收。這個“自動”是混淆並給JavaScript(和其他高階語言)開發者一個錯覺:他們可以不用考慮記憶體管理。 對於前端開發來說,記憶體空間並不是一個經常被提及的概念,很容易被大家忽視。當然也包括我自己。在很長一段時間裡認為記憶體空間的概念在JS的學習中並不是那麼重要。可是後我當我回過頭來重新整理JS基礎時,發現由於對它們的模糊認知,導致了很多東西我都理解得並不明白。比如最基本的引用資料型別和引用傳遞到底是怎麼回事兒?比如淺複製與深複製有什麼不同?還有閉包,原型等等。 但其實在使用JavaScript進行開發的過程中,瞭解JavaScript記憶體機制有助於開發人員能夠清晰的認識到自己寫的程式碼在執行的過程中發生過什麼,也能夠提高專案的程式碼質量。

記憶體模型

JS記憶體空間分為棧(stack)堆(heap)池(一般也會歸類為棧中)。 其中存放變數,存放複雜物件,存放常量。

基礎資料型別與棧記憶體

JS中的基礎資料型別,這些值都有固定的大小,往往都儲存在棧記憶體中(閉包除外),由系統自動分配儲存空間。我們可以直接操作儲存在棧記憶體空間的值,因此基礎資料型別都是按值訪問 資料在棧記憶體中的儲存與使用方式類似於資料結構中的堆疊資料結構,遵循後進先出的原則。 基礎資料型別: Number String Null Undefined Boolean 複習一下,此問題常常在面試中問到,然而答不出來的人大有人在 ~ ~ 要簡單理解棧記憶體空間的儲存方式,我們可以通過類比乒乓球盒子來分析。

乒乓球盒子
5
4
3
2
1

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

引用資料型別與堆記憶體

與其他語言不同,JS的引用資料型別,比如陣列Array,它們值的大小是不固定的。引用資料型別的值是儲存在堆記憶體中的物件。JS不允許直接訪問堆記憶體中的位置,因此我們不能直接操作物件的堆記憶體空間。在操作物件時,實際上是在操作物件的引用而不是實際的物件。因此,引用型別的值都是按引用訪問的。這裡的引用,我們可以粗淺地理解為儲存在棧記憶體中的一個地址,該地址與堆記憶體的實際值相關聯。 堆存取資料的方式,則與書架與書非常相似。 書雖然也有序的存放在書架上,但是我們只要知道書的名字,我們就可以很方便的取出我們想要的書,而不用像從乒乓球盒子裡取乒乓一樣,非得將上面的所有乒乓球拿出來才能取到中間的某一個乒乓球。好比在JSON格式的資料中,我們儲存的key-value是可以無序的,因為順序的不同並不影響我們的使用,我們只需要關心書的名字。

為了更好的搞懂棧記憶體與堆記憶體,我們可以結合以下例子與圖解進行理解。
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] 作為物件存在於堆記憶體中

變數名 具體值
c 0x0012ff7d
b 0x0012ff7c
a3 null
a2 this is string
a1 0

[棧記憶體空間] ------->

        堆記憶體空間
        [1,2,3]           
                    {m:20}           
複製程式碼

因此當我們要訪問堆記憶體中的引用資料型別時,實際上我們首先是從棧中獲取了該物件的地址引用(或者地址指標),然後再從堆記憶體中取得我們需要的資料。 理解了JS的記憶體空間,我們就可以藉助記憶體空間的特性來驗證一下引用型別的一些特點了。 在前端面試中我們常常會遇到這樣一個類似的題目

// demo01.js
var a = 20;
var b = a;
b = 30;
// 這時a的值是多少?

// demo02.js
var m = { a: 10, b: 20 };
var n = m;
n.a = 15;
// 這時m.a的值是多少
複製程式碼

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

棧記憶體空間
a 20

[複製前]

棧記憶體空間
b 20
a 20

[複製後]

棧記憶體空間
b 30
a 20

[b值修改後]
這是 demo1 的圖解

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

m 0x0012ff7d

[複製前]

堆記憶體空間
{a:10,b:20}

[複製前]

棧記憶體空間
變數名
m
n

[複製後]

堆記憶體空間
{a:10,b:20}

[複製後]

這是demo2圖解

除此之外,我們還可以以此為基礎,一步一步的理解JavaScript的執行上下文,作用域鏈,閉包,原型鏈等重要概念。其他的以後再說,光做這個就累死了。

記憶體的生命週期

JS環境中分配的記憶體一般有如下生命週期:

  1. 記憶體分配:當我們申明變數、函式、物件的時候,系統會自動為他 們分配記憶體
  2. 記憶體使用:即讀寫記憶體,也就是使用變數、函式等
  3. 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體

為了便於理解,我們使用一個簡單的例子來解釋這個週期。

var a = 20;  // 在記憶體中給數值變數分配空間
alert(a + 100);  // 使用記憶體
var a = null; // 使用完畢之後,釋放記憶體空間
複製程式碼

第一步和第二步我們都很好理解,JavaScript在定義變數時就完成了記憶體分配。第三步釋放記憶體空間則是我們需要重點理解的一個點。

現在想想,從記憶體來看 nullundefined 本質的區別是什麼?

為什麼typeof(null) //object typeof(undefined) //undefined

現在再想想,建構函式和立即執行函式的宣告週期是什麼?

對了,ES6語法中的 const 宣告一個只讀的常量。一旦宣告,常量的值就不能改變。但是下面的程式碼可以改變 const 的值,這是為什麼?

const foo = {}; 
foo.prop = 123;
foo.prop // 123
foo = {}; // TypeError: "foo" is read-only
複製程式碼

記憶體回收

JavaScript有自動垃圾收集機制,那麼這個自動垃圾收集機制的原理是什麼呢?其實很簡單,就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。垃圾收集器會每隔固定的時間段就執行一次釋放操作。 在JavaScript中,最常用的是通過標記清除的演算法來找到哪些物件是不再繼續使用的,因此 a = null 其實僅僅只是做了一個釋放引用的操作,讓 a 原本對應的值失去引用,脫離執行環境,這個值會在下一次垃圾收集器執行操作時被找到並釋放。而在適當的時候解除引用,是為頁面獲得更好效能的一個重要方式。

  • 在區域性作用域中,當函式執行完畢,區域性變數也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收。但是全域性變數什麼時候需要自動釋放記憶體空間則很難判斷,因此在我們的開發中,需要儘量避免使用全域性變數,以確保效能問題。

  • 以Google的V8引擎為例,在V8引擎中所有的JAVASCRIPT物件都是通過堆來進行記憶體分配的。當我們在程式碼中宣告變數並賦值時,V8引擎就會在堆記憶體中分配一部分給這個變數。如果已申請的記憶體不足以儲存這個變數時,V8引擎就會繼續申請記憶體,直到堆的大小達到了V8引擎的記憶體上限為止(預設情況下,V8引擎的堆記憶體的大小上限在64位系統中為1464MB,在32位系統中則為732MB)。

  • 另外,V8引擎對堆記憶體中的JAVASCRIPT物件進行分代管理。新生代:新生代即存活週期較短的JAVASCRIPT物件,如臨時變數、字串等; 老生代:老生代則為經過多次垃圾回收仍然存活,存活週期較長的物件,如主控制器、伺服器物件等。

請各位老鐵see一下以下的程式碼,來分析一下垃圾回收。

function fun1() {
    var obj = {name: 'csa', age: 24};
}
 
function fun2() {
    var obj = {name: 'coder', age: 2}
    return obj;
}
 
var f1 = fun1();
var f2 = fun2();
複製程式碼

在上述程式碼中,當執行var f1 = fun1();的時候,執行環境會建立一個{name:'csa', age:24}這個物件,當執行var f2 = fun2();的時候,執行環境會建立一個{name:'coder', age=2}這個物件,然後在下一次垃圾回收來臨的時候,會釋放{name:'csa', age:24}這個物件的記憶體,但並不會釋放{name:'coder', age:2}這個物件的記憶體。這就是因為在fun2()函式中將{name:'coder, age:2'}這個物件返回,並且將其引用賦值給了f2變數,又由於f2這個物件屬於全域性變數,所以在頁面沒有解除安裝的情況下,f2所指向的物件{name:'coder', age:2}是不會被回收的。 由於JavaScript語言的特殊性(閉包...),導致如何判斷一個物件是否會被回收的問題上變的異常艱難,各位老鐵看看就行。

垃圾回收演算法

對垃圾回收演算法來說,核心思想就是如何判斷記憶體已經不再使用了。

引用計數演算法

熟悉或者用C語言搞過事的同學的都明白,引用無非就是指向某一物體的指標。對不熟悉這個語言的同學來說,可簡單將引用視為一個物件訪問另一個物件的路徑。(這裡的物件是一個寬泛的概念,泛指JS環境中的實體)。

引用計數演算法定義“記憶體不再使用”的標準很簡單,就是看一個物件是否有指向它的引用。如果沒有其他物件指向它了,說明該物件已經不再需了。

老鐵們來看一個例子:

// 建立一個物件person,他有兩個指向屬性age和name的引用
var person = {
    age: 12,
    name: 'aaaa'
};

person.name = null; // 雖然設定為null,但因為person物件還有指向name的引用,因此name不會回收

var p = person; 
person = 1;         //原來的person物件被賦值為1,但因為有新引用p指向原person物件,因此它不會被回收

p = null;           //原person物件已經沒有引用,很快會被回收
複製程式碼

由上面可以看出,引用計數演算法是個簡單有效的演算法。但它卻存在一個致命的問題:迴圈引用。如果兩個物件相互引用,儘管他們已不再使用,垃圾回收器不會進行回收,導致記憶體洩露。

老鐵們再來看一個例子:

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1; 

    return "Cycle reference!"
}

cycle();
複製程式碼

上面我們申明瞭一個cycle方程,其中包含兩個相互引用的物件。在呼叫函式結束後,物件o1和o2實際上已離開函式範圍,因此不再需要了。但根據引用計數的原則,他們之間的相互引用依然存在,因此這部分記憶體不會被回收,記憶體洩露不可避免了。 正是因為有這個嚴重的缺點,這個演算法在現代瀏覽器中已經被下面要介紹的標記清除演算法所取代了。但絕不可認為該問題已經不再存在了,因為還佔有大量市場的IE老祖宗們使用的正是這一演算法。在需要照顧相容性的時候,某些看起來非常普通的寫法也可能造成意想不到的問題:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};
複製程式碼

上面這種JS寫法再普通不過了,建立一個DOM元素並繫結一個點選事件。那麼這裡有什麼問題呢?請注意,變數div有事件處理函式的引用,同時事件處理函式也有div的引用!(div變數可在函式內被訪問)。一個循序引用出現了,按上面所講的演算法,該部分記憶體無可避免地洩露哦了。 現在你明白為啥前端程式設計師都討厭IE了吧?擁有超多BUG並依然佔有大量市場的IE是前端開發一生之敵!親,沒有買賣就沒有殺害。

標記清除演算法

上面說過,現代的瀏覽器已經不再使用引用計數演算法了。現代瀏覽器通用的大多是基於標記清除演算法的某些改進演算法,總體思想都是一致的。

標記清除演算法將“不再使用的物件”定義為“無法達到的物件”。簡單來說,就是從根部(在JS中就是全域性物件)出發定時掃描記憶體中的物件。凡是能從根部到達的物件,都是還需要使用的。那些無法由根部出發觸及到的物件被標記為不再使用,稍後進行回收。

從這個概念可以看出,無法觸及的物件包含了沒有引用的物件這個概念(沒有任何引用的物件也是無法觸及的物件)。但反之未必成立。

根據這個概念,上面的例子可以正確被垃圾回收處理了(親,想想為什麼?)。

當div與其時間處理函式不能再從全域性物件出發觸及的時候,垃圾回收器就會標記並回收這兩個物件。

如何寫出對記憶體管理友好的JS程式碼?

如果還需要相容老舊瀏覽器,那麼就需要注意程式碼中的迴圈引用問題。或者直接採用保證相容性的庫來幫助優化程式碼。

對現代瀏覽器來說,唯一要注意的就是明確切斷需要回收的物件與根部的聯絡。有時候這種聯絡並不明顯,且因為標記清除演算法的強壯性,這個問題較少出現。最常見的記憶體洩露一般都與DOM元素繫結有關:

email.message = document.createElement(“div”);
displayList.appendChild(email.message);

// 稍後從displayList中清除DOM元素
displayList.removeAllChildren();
複製程式碼

div元素已經從DOM樹中清除,也就是說從DOM樹的根部無法觸及該div元素了。但是請注意,div元素同時也繫結了email物件。所以只要email物件還存在,該div元素將一直儲存在記憶體中。

小結

如果你的引用只包含少量JS互動,那麼記憶體管理不會對你造成太多困擾。一旦你開始構建中大規模的 SPA 或是伺服器和桌面端的應用,那麼就應當將記憶體洩露提上日程了。不要滿足於寫出能執行的程式,也不要認為機器的升級就能解決一切。

記憶體洩露

什麼是記憶體洩露

對於持續執行的服務程式(daemon),必須及時釋放不再用到的記憶體。否則,記憶體佔用越來越高,輕則影響系統效能,重則導致程式崩潰。 不再用到的記憶體,沒有及時釋放,就叫做記憶體洩漏(memory leak)。 有些語言(比如 C 語言)必須手動釋放記憶體,程式設計師負責記憶體管理。

char * buffer;
buffer = (char*) malloc(42);

// Do something with buffer

free(buffer);
複製程式碼

看不懂沒關係,上面是 C 語言程式碼,malloc方法用來申請記憶體,使用完畢之後,必須自己用free方法釋放記憶體。 這很麻煩,所以大多數語言提供自動記憶體管理,減輕程式設計師的負擔,這被稱為"垃圾回收機制"(garbage collector),已經提過,不再多講。

記憶體洩漏的識別方法

怎樣可以觀察到記憶體洩漏呢? 經驗法則是,如果連續五次垃圾回收之後,記憶體佔用一次比一次大,就有記憶體洩漏。(咳咳,不裝逼了) 這要我們實時檢視記憶體佔用。

瀏覽器方法

  1. 開啟開發者工具,選擇 Timeline 皮膚
  2. 在頂部的Capture欄位裡面勾選 Memory
  3. 點選左上角的錄製按鈕。
  4. 在頁面上進行各種操作,模擬使用者的使用情況。
  5. 一段時間後,點選對話方塊的 stop 按鈕,皮膚上就會顯示這段時間的記憶體佔用情況。

如果記憶體佔用基本平穩,接近水平,就說明不存在記憶體洩漏。 反之,就是記憶體洩漏了。

命令列方法

命令列可以使用 Node 提供的 process.memoryUsage 方法。

console.log(process.memoryUsage());
// { rss: 27709440,
//  heapTotal: 5685248,
//  heapUsed: 3449392,
//  external: 8772 }
複製程式碼

process.memoryUsage返回一個物件,包含了 Node 程式的記憶體佔用資訊。該物件包含四個欄位,單位是位元組,含義如下。

Resident Set(常駐記憶體)
Code Segment(程式碼區)
Stack(Local Variables, Pointers)
Heap(Objects, Closures)
Used Heap
  • rss(resident set size):所有記憶體佔用,包括指令區和堆疊。
  • heapTotal:"堆"佔用的記憶體,包括用到的和沒用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎內部的 C++ 物件佔用的記憶體。

判斷記憶體洩漏,以heapUsed欄位為準。

WeakMap

前面說過,及時清除引用非常重要。但是,你不可能記得那麼多,有時候一疏忽就忘了,所以才有那麼多記憶體洩漏。

最好能有一種方法,在新建引用的時候就宣告,哪些引用必須手動清除,哪些引用可以忽略不計,當其他引用消失以後,垃圾回收機制就可以釋放記憶體。這樣就能大大減輕程式設計師的負擔,你只要清除主要引用就可以了。

ES6 考慮到了這一點,推出了兩種新的資料結構:WeakSetWeakMap。它們對於值的引用都是不計入垃圾回收機制的,所以名字裡面才會有一個"Weak",表示這是弱引用。

下面以 WeakMap 為例,看看它是怎麼解決記憶體洩漏的。

const wm = new WeakMap();

const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"
複製程式碼

上面程式碼中,先新建一個 Weakmap 例項。然後,將一個 DOM 節點作為鍵名存入該例項,並將一些附加資訊作為鍵值,一起存放在 WeakMap 裡面。這時,WeakMap 裡面對element的引用就是弱引用,不會被計入垃圾回收機制。

也就是說,DOM 節點物件的引用計數是1,而不是2。這時,一旦消除對該節點的引用,它佔用的記憶體就會被垃圾回收機制釋放。Weakmap 儲存的這個鍵值對,也會自動消失。

基本上,如果你要往物件上新增資料,又不想干擾垃圾回收機制,就可以使用 WeakMap

WeakMap 示例

WeakMap 的例子很難演示,因為無法觀察它裡面的引用會自動消失。此時,其他引用都解除了,已經沒有引用指向 WeakMap 的鍵名了,導致無法證實那個鍵名是不是存在。 (具體可以去看阮一峰老師的記憶體洩露文章)。 over.

特別感謝:

相關文章