JavaScript 內存機制

簡介

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

數據類型

基礎數據類型: Number, String, Null, Undefined, Boolean

引用數據類型: Object(特殊的有Array, Function, Date, Math, RegExp, Error等)

內存模型

JS內存空間分爲棧(stack)堆(heap)池(一般也會歸類爲棧中)
其中存放變量,存放複雜對象,存放常量。

基礎數據類型與棧內存

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

乒乓球盒子
5
4
3
2
1

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

引用數據類型與堆內存

引用數據類型: Object(特殊的有Array, Function, Date, Math, RegExp, Error等)

與其他語言不同,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] 作爲對象存在於堆內存中

變量名具體值
c0x0012ff7d
b0x0012ff7c
a3null
a2this is string
a10

[棧內存空間]
------->

        堆內存空間
        [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|
[複製前]

棧內存空間 
b20
a20

[複製後]

棧內存空間 
b30
a20

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

在demo02中,我們通過var n = m執行一次複製引用類型的操作。引用類型的複製同樣也會爲新的變量自動分配一個新的值保存在棧內存中,但不同的是,這個新的值,僅僅只是引用類型的一個地址指針。當地址指針相同時,儘管他們相互獨立,但是在堆內存中訪問到的具體對象實際上是同一個。
|棧內存空間||
|變量名|具體值|
|m|0x0012ff7d|
|:-:|:-:|
[複製前]

|堆內存空間|
|{a:10,b:20}|
|:-:|
[複製前]

|棧內存空間||
|變量名|具體值|
|m|0x0012ff7d|
|n|0x0012ef21|
|:--:|:--:|
[複製後]

|堆內存空間|
|{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.

特別感謝:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章