JavaScript 內存機制(前端同窗進階必備)

簡介

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

內存模型

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

基礎數據類型與棧內存

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

乒乓球盒子
5
4
3
2
1

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

引用數據類型與堆內存

與其餘語言不一樣,JS的引用數據類型,好比數組Array,它們值的大小是不固定的。引用數據類型的值是保存在堆內存中的對象。JS不容許直接訪問堆內存中的位置,所以咱們不能直接操做對象的堆內存空間。在操做對象時,其實是在操做對象的引用而不是實際的對象。所以,引用類型的值都是按引用訪問的。這裏的引用,咱們能夠粗淺地理解爲保存在棧內存中的一個地址,該地址與堆內存的實際值相關聯。 堆存取數據的方式,則與書架與書很是類似。 書雖然也有序的存放在書架上,可是咱們只要知道書的名字,咱們就能夠很方便的取出咱們想要的書,而不用像從乒乓球盒子裏取乒乓同樣,非得將上面的全部乒乓球拿出來才能取到中間的某一個乒乓球。比如在JSON格式的數據中,咱們存儲的key-value是能夠無序的,由於順序的不一樣並不影響咱們的使用,咱們只須要關心書的名字。git

爲了更好的搞懂棧內存與堆內存,咱們能夠結合如下例子與圖解進行理解。
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

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

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

[複製前]npm

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

特別感謝:

最後很差意思推廣一下我基於 Taro 框架寫的組件庫:MP-ColorUI

能夠順手 star 一下我就很開心啦,謝謝你們。

點這裏是文檔

點這裏是 GitHUb 地址

相關文章
相關標籤/搜索