本文主要深入介紹JavaScript內存機制
內存模型
JS內存空間分為棧(stack),堆(heap),池(一般也會歸類為棧中),其中棧存放變量,堆存放復雜對象,池存放常量。
注:閉包中的變量並不保存在棧內存中,而是保存在堆內存中,這就是函數之后為什么閉包還能引用函數內的變量的原因。
function A() { let a = 1 function B() { console.log(a) } return B }
閉包
的簡單定義是:函數 A 返回了一個函數 B,並且函數 B 中使用了函數 A 的變量,函數 B 就被稱為閉包。
函數 A Push調用棧后,函數 A 中的變量這時候是存儲在堆上的,所以函數B依舊能引用到函數A中的變量。現在的 JS 引擎可以通過逃逸分析辨別出哪些變量需要存儲在堆上,哪些需要存儲在棧上。
下面重點講解內存回收和內存泄漏
內存回收
JavaScript有自動垃圾收集機制,就是找出那些不在繼續使用的值。然后釋放其占用的內存。垃圾收集器會每隔固定的時間段就執行一次釋放操作。最常用的是標記清除法,例如 a=null 其實只是一個釋放引用的操作,讓 a 原本對應的值失去引用,脫離執行環境,這個值會在下一次垃圾收集器執行操作時被找到並釋放。並且在適當的時候解除引用,是為頁面獲得更好性能的一個重要方式。
局部變量和全局變量的回收
- 局部變量:在局部作用域中,當函數執行完畢,局部變量也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收;
- 全局變量:全局變量什么時候需要自動釋放內存內存空間很難判斷,因此在開發中應盡量避免使用全局變量,以確保性能問題。
以Google的V8引擎為例,在V8引擎中所有的JAVASCRIPT對象都是通過堆來進行內存分配的
- 初始分配:當我們在代碼中聲明變量並賦值時,V8引擎就會在堆內存中分配一部分給這個變量;
- 繼續申請:如果已申請的內存不足以存儲這個變量時,V8引擎就會繼續申請內存,直到堆的大小達到了V8引擎的內存上限為止(默認情況下,V8引擎的堆內存的大小上限在64位系統中為1464MB,在32位系統中則為732MB)。
V8引擎對堆內存中的JAVASCRIPT對象進行分代管理
- 新生代:新生代即存活周期較短的JAVASCRIPT對象,如臨時變量、字符串等;
- 老生代:老生代則為經過多次垃圾回收仍然存活,存活周期較長的對象,如主控制器、服務器對象等。
一段代碼分析一下垃圾回收:
function fun1() { var obj = {name: 'lhh', age: 21}; } function fun2() { var obj = {name: 'coder', age: 2} return obj; } var f1 = fun1(); var f2 = fun2();
在上述代碼中,當執行 var f1 = fun1(); 的時候,執行環境會創建一個 {name:'lhh',age:21} 這個對象,當執行 var f2 = fun2(); 的時候,執行環境會創建一個 {name:'coder',age:2} 這個對象,然后在下一次垃圾回收執行的時候,會釋放 {name:'lhh',age:21} 這個對象的內存,但並不會釋放 {name:'coder',age:100} 這個對象的內存。這就是因為在 fun2() 函數中將 {name:'coder',age:2} 這個對象返回,並且將其引用賦值給了 f2 變量,又由於 f2 這個對象屬於全局變量,所以在頁面存在的情況下, f2 所指向的對象 {name:'coder',age:2} 是不會被回收的。(由於JavaScript語言的特殊性,例如閉包...,導致如何判斷一個對象是否會被回收比較困難,以上僅供參考)
垃圾回收算法
對垃圾回收算法來說,核心思想就是如何判斷內存已經不再使用,常用垃圾回收算法有下面兩種。
-
引用計數(現代瀏覽器不再使用)
-
標記清除(常用)
引用計數
引用計數算法定義“內存不再使用”的標准很簡單,就是看一個對象是否有指向它的引用。如果沒有其他對象指向它了,說明該對象已經不再需要了。
// 創建一個對象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"); };
上面寫法創建一個DOM元素並綁定一個點擊事件,這里的變量div有事件處理函數的引用,同時事件處理函數也有div的引用(變量div可在函數內被訪問),所以循環引用就出現了。
標記清除(常用)
標記清除算法將“不再使用的對象”定義為“無法達到的對象”。簡單來說,就是從根部(在JS中就是全局對象)出發定時掃描內存中的對象。凡是能從根部到達的對象,都是還需要使用的,給予保留。那些無法由根部出發觸及到的對象被標記為不再使用,稍后進行回收。
從這個概念可以看出,無法觸及的對象包含了沒有引用的對象這個概念(沒有任何引用的對象也是無法觸及的對象)。但反之未必成立。
算法由以下幾步組成:
- 垃圾回收器創建了一個“roots”列表。roots 通常是代碼中全局變量的引用。JavaScript 中,“window” 對象是一個全局變量,被當作 root 。window 對象總是存在,因此垃圾回收器可以檢查它和它的所有子對象是否存在(即不是垃圾);
- 所有的 roots 被檢查和標記為激活(即不是垃圾)。所有的子對象也被遞歸地檢查。從 root 開始的所有對象如果是可達的,它就不被當作垃圾;
- 所有未被標記的內存會被當做垃圾,收集器現在可以釋放內存,歸還給操作系統了。
現代的垃圾回收器改良了算法,但是本質是相同的:可達內存被標記,其余的被當作垃圾回收。
根據這個概念,上面的例子可以正確被垃圾回收處理了。
當div與其事件處理函數不能再從全局對象出發觸及的時候,垃圾回收器就會標記並回收這兩個對象。
內存泄漏
例子:
對於主流瀏覽器來說,只需要切斷需要回收的對象與根部的聯系。最常見的內存泄露一般都與DOM元素綁定有關:
email.message = document.createElement(“div”); displayList.appendChild(email.message); // 稍后從displayList中清除DOM元素 displayList.removeAllChildren();
上面代碼中,div
元素已經從DOM樹中清除,但是該div
元素還綁定在email對象中,所以如果email對象存在,那么該div
元素就會一直保存在內存中。
概念:
對於持續運行的服務進程(daemon),必須及時釋放不再用到的內存。否則,內存占用越來越高,輕則影響系統性能,重則導致進程崩潰。 對於不再用到的內存,沒有及時釋放,就叫做內存泄漏(memory leak)。
內存泄漏識別方法:
- 瀏覽器方法
- 打開開發者工具,選擇 Memory
- 在右側的Select profiling type字段里面勾選 timeline
- 點擊左上角的錄制按鈕
- 在頁面上進行各種操作,模擬用戶的使用情況。
- 一段時間后,點擊左上角的 stop 按鈕,面板上就會顯示這段時間的內存占用情況。
- 命令行方法
使用Node
提供的process.memoryUsage
方法
console.log(process.memoryUsage()); // 輸出 { rss: 27709440, // resident set size,所有內存占用,包括指令區和堆棧 heapTotal: 5685248, // "堆"占用的內存,包括用到的和沒用到的 heapUsed: 3449392, // 用到的堆的部分 external: 8772 // V8 引擎內部的 C++ 對象占用的內存 }
判斷內存泄漏,以heapUsed
字段為准
WeakMap
ES6 新出的兩種數據結構:WeakSet
和 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的引用就是弱引用,不會被計入垃圾回收機制。
四種常見的JS內存泄漏
1.意外的全局變量
未定義的變量會在全局對象創建一個新變量,如下:
function foo(arg) { bar = "this is a hidden global variable"; }
真相是:
function foo(arg) { window.bar = "this is an explicit global variable"; }
函數 foo
內部忘記使用 var
,實際上JS會把bar掛載到全局對象上,意外創建一個全局變量。
另一個意外的全局變量可能由 this
創建:
function foo() { this.variable = "potential accidental global"; } // Foo 調用自己,this 指向了全局對象(window) // 而不是 undefined foo();
解決方法:在 JavaScript 文件頭部加上 'use strict'
,使用嚴格模式避免意外的全局變量,此時上例中的this指向undefined
。如果必須使用全局變量存儲大量數據時,確保用完以后把它設置為 null 或者重新定義。與全局變量相關的增加內存消耗的一個主因是緩存。緩存數據是為了重用,緩存必須有一個大小上限才有用。高內存消耗導致緩存突破上限,因為緩存內容無法被回收。
2.被遺忘的計時器或回調函數
在 JavaScript 中使用 setInterval
非常常見:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // 處理 node 和 someResource node.innerHTML = JSON.stringify(someResource)); } }, 1000);
上面的例子表明,在節點node或者數據不再需要時,定時器依舊指向這些數據。所以哪怕當node節點被移除后,interval 仍舊存活並且垃圾回收器沒辦法回收,它的依賴也沒辦法被回收,除非終止定時器。
var element = document.getElementById('button'); function onClick(event) { element.innerHTML = 'text'; } element.addEventListener('click', onClick);
對於上面觀察者的例子,一旦它們不再需要(或者關聯的對象變成不可達),明確地移除它們非常重要。老的 IE 6 是無法處理循環引用的。因為老版本的 IE 是無法檢測 DOM 節點與 JavaScript 代碼之間的循環引用,會導致內存泄漏。
3.脫離DOM的引用
有時,保存 DOM 節點內部數據結構很有用。假如你想快速更新表格的幾行內容,把每一行 DOM 存成字典(JSON 鍵值對)或者數組很有意義。此時,同樣的 DOM 元素存在兩個引用:一個在 DOM 樹中,另一個在字典中。將來你決定刪除這些行時,需要把兩個引用都清除。
var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; function doStuff() { image.src = 'http://some.url/image'; button.click(); console.log(text.innerHTML); // 更多邏輯 } function removeButton() { // 按鈕是 body 的后代元素 document.body.removeChild(document.getElementById('button')); // 此時,仍舊存在一個全局的 #button 的引用 // elements 字典。button 元素仍舊在內存中,不能被 GC 回收。 }
此外還要考慮 DOM 樹內部或子節點的引用問題。假如你的 JavaScript 代碼中保存了表格某一個 <td>
的引用。將來決定刪除整個表格的時候,直覺認為 GC 會回收除了已保存的 <td>
以外的其它節點。實際情況並非如此:此 <td>
是表格的子節點,子元素與父元素是引用關系。由於代碼保留了 <td>
的引用,導致整個表格仍待在內存中。保存 DOM 元素引用的時候,要小心謹慎。
4.閉包
閉包的關鍵是匿名函數可以訪問父級作用域的變量。
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log(someMessage); } }; }; setInterval(replaceThing, 1000);
每次調用 replaceThing
,theThing
得到一個包含一個大數組和一個新閉包(someMethod
)的新對象。同時,變量 unused
是一個引用 originalThing
的閉包(先前的 replaceThing
又調用了 theThing
)。最重要的事情是,閉包的作用域一旦創建,它們有同樣的父級作用域,作用域是共享的。someMethod
可以通過 theThing
使用,someMethod
與 unused
分享閉包作用域,盡管 unused
從未使用,它引用的 originalThing
迫使它保留在內存中(防止被回收)。當這段代碼反復運行,就會看到內存占用不斷上升,垃圾回收器(GC)並無法降低內存占用。本質上,閉包的鏈表已經創建,每一個閉包作用域攜帶一個指向大數組的間接的引用,造成嚴重的內存泄漏。
解決方法:在 replaceThing
的最后添加 originalThing = null
推薦閱讀:
https://juejin.im/post/5b10ba336fb9a01e66164346#comment
https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them//