垃圾回收機制
通常情況下,垃圾數據回收分為手動回收
和自動回收
兩種策略。
手動回收策略,何時分配內存、何時銷毀內存都是由代碼控制的。
自動回收策略,產生的垃圾數據是由垃圾回收器來釋放的,並不需要手動通過代碼來釋放。
JavaScript 中調用棧中的數據回收
JavaScript 引擎會通過向下移動 ESP(記錄當前執行狀態的指針) 來銷毀該函數保存在棧中的執行上下文。
JavaScript 堆中的數據回收
在 V8 中會把堆分為新生代
和老生代
兩個區域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。
新生區通常只支持 1~8M 的容量,而老生區支持的容量就大很多了。
對於這兩塊區域,V8 分別使用兩個不同的垃圾回收器,以便更高效地實施垃圾回收。
- 副垃圾回收器,主要負責新生代的垃圾回收。
- 主垃圾回收器,主要負責老生代的垃圾回收。
不論什么類型的垃圾回收器,它們都有一套共同的執行流程。
- 第一步是標記空間中活動對象和非活動對象。所謂活動對象就是還在使用的對象,非活動對象就是可以進行垃圾回收的對象。
- 第二步是回收非活動對象所占據的內存。其實就是在所有的標記完成之后,統一清理內存中所有被標記為可回收的對象。
- 第三步是做內存整理。一般來說,頻繁回收對象后,內存中就會存在大量不連續空間,我們把這些不連續的內存空間稱為
內存碎片
,。當內存中出現了大量的內存碎片之后,如果需要分配較大連續內存的時候,就有可能出現內存不足的情況。所以最后一步需要整理這些內存碎片。(這步其實是可選的,因為有的垃圾回收器不會產生內存碎片).
新生代中垃圾回收
新生代中用Scavenge 算法
來處理,把新生代空間對半划分為兩個區域,一半是對象區域,一半是空閑區域。
新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就需要執行一次垃圾清理操作。
在垃圾回收過程中,首先要對對象區域中的垃圾做標記;標記完成之后,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象復制到空閑區域中,
同時它還會把這些對象有序地排列起來,所以這個復制過程,也就相當於完成了內存整理操作,復制后空閑區域就沒有內存碎片了。
完成復制后,對象區域與空閑區域進行角色翻轉,也就是原來的對象區域變成空閑區域,原來的空閑區域變成了對象區域。
這樣就完成了垃圾對象的回收操作,同時這種角色翻轉的操作還能讓新生代中的這兩塊區域無限重復使用下去.
為了執行效率,一般新生區的空間會被設置得比較小,也正是因為新生區的空間不大,所以很容易被存活的對象裝滿整個區域。
為了解決這個問題,JavaScript 引擎采用了對象晉升策略
,也就是經過兩次垃圾回收依然還存活的對象,會被移動到老生區中。
老生代中的垃圾回收
老生代中用標記 - 清除(Mark-Sweep)
的算法來處理。
首先是標記過程階段,標記階段就是從一組根元素開始,遞歸遍歷這組根元素(遍歷調用棧),在這個遍歷過程中,能到達的元素稱為活動對象
,沒有到達的元素就可以判斷為垃圾數據
.
然后在遍歷過程中標記,標記完成后就進行清除過程。它和副垃圾回收器的垃圾清除過程完全不同,這個的清除過程是刪除標記數據。
清除算法后,會產生大量不連續的內存碎片。而碎片過多會導致大對象無法分配到足夠的連續內存,於是又產生了標記 - 整理(Mark-Compact)
算法,
這個標記過程仍然與標記 - 清除算法
里的是一樣的,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,
然后直接清理掉端邊界以外的內存,從而讓存活對象占用連續的內存塊。
全停頓
由於 JavaScript 是運行在主線程之上的,一旦執行垃圾回收算法,都需要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢后再恢復腳本執行。我們把這種行為叫做全停頓
。
在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,所以全停頓的影響不大,但老生代就不一樣了。如果執行垃圾回收的過程中,占用主線程時間過久,主線程是不能做其他事情的。
比如頁面正在執行一個 JavaScript 動畫,因為垃圾回收器在工作,就會導致這個動畫在垃圾回收過程中無法執行,這將會造成頁面的卡頓現象。
為了降低老生代的垃圾回收而造成的卡頓,V8 將標記過程分為一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,我們把這個算法稱為增量標記(Incremental Marking)算法
.
使用增量標記算法,可以把一個完整的垃圾回收任務拆分為很多小的任務,這些小的任務執行時間比較短,可以穿插在其他的 JavaScript 任務中間執行,
這樣當執行上述動畫效果時,就不會讓用戶因為垃圾回收任務而感受到頁面的卡頓了。
內存泄漏
不再用到的內存,沒有及時釋放,就叫做內存泄漏(memory leak)。
內存泄漏發生的原因
- 緩存
有時候為了方便數據的快捷復用,我們會使用緩存,但是緩存必須有一個大小上限才有用。高內存消耗將會導致緩存突破上限,因為緩存內容無法被回收。
- 隊列消費不及時
當瀏覽器隊列消費不及時時,會導致一些作用域變量得不到及時的釋放,因而導致內存泄漏。
- 全局變量
除了常規設置了比較大的對象在全局變量中,還可能是意外導致的全局變量,如:
function foo(arg) { bar = "this is a hidden global variable"; }
在函數中,沒有使用 var/let/const 定義變量,這樣實際上是定義在window
上面,變成了window.bar
。 再比如由於this
導致的全局變量:
function foo() { this.bar = "this is a hidden global variable"; } foo()
這種函數,在window作用域下被調用時,函數里面的this
指向了window
,執行時實際上為window.bar=xxx
,這樣也產生了全局變量。
- 計時器中引用沒有清除
先看如下代碼:
var someData = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { node.innerHTML = JSON.stringify(someData)); } }, 1000);
這里定義了一個計時器,每隔1s把一些數據寫到Node節點里面。但是當這個Node節點被刪除后,這里的邏輯其實都不需要了,可是這樣寫,卻導致了計時器里面的回調函數無法被回收,同時,someData里的數據也是無法被回收的。
- 閉包
看以下這個閉包:
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(theThing)
的閉包,閉包的作用域一旦創建,它們有同樣的父級作用域,作用域是共享的。
即 someMethod
可以通過 theThing
使用,someMethod
與 unused
分享閉包作用域,盡管 unused
從未使用,它引用的 originalThing
迫使它保留在內存中(防止被回收)。
因此,當這段代碼反復運行,就會看到內存占用不斷上升,垃圾回收器(GC)並無法降低內存占用。
本質上,閉包的鏈表已經創建,每一個閉包作用域攜帶一個指向大數組的間接的引用,造成嚴重的內存泄漏。
- 事件監聽
例如,Node.js 中 Agent 的 keepAlive 為 true 時,可能造成的內存泄漏。當 Agent keepAlive 為 true 的時候,將會復用之前使用過的 socket,如果在 socket 上添加事件監聽,忘記清除的話,因為 socket 的復用,將導致事件重復監聽從而產生內存泄漏。
內存泄漏的識別方法
- 使用 Chrome 任務管理器實時監視內存使用 打開 chrome 瀏覽器,點擊右上角主菜單,選擇
更多工具->任務管理器
,這樣就開啟了任務管理器面板,然后再右鍵點擊任務管理器的表格標題並啟用 JavaScript使用的內存,能看到這樣的面板:
下面兩列可以告訴您與頁面的內存使用有關的不同信息:

內存占用空間(Memory)
列表示原生內存。DOM 節點存儲在原生內存中。 如果此值正在增大,則說明正在創建 DOM 節點。JavaScript使用的內存(JavaScript Memory)
列表示 JS 堆。此列包含兩個值。 您感興趣的值是實時數字(括號中的數字)。實時數字表示您的頁面上的可到達對象正在使用的內存量。 如果此數字在增大,要么是正在創建新對象,要么是現有對象正在增長。
當你頁面穩定下來之后,這兩個的值還在上漲,你就可以查一查是否內存泄漏了。
- 利用chrome 時間軸記錄可視化內存泄漏
Performance(時間軸)能夠面板直觀實時顯示JS內存使用情況、節點數量、監聽器數量等。
打開 chrome 瀏覽器,調出調試面板(DevTools),點擊Performance
選項(低版本是Timeline),勾選Memory復選框。一種比較好的做法是使用強制垃圾回收開始和結束記錄。在記錄時點擊 Collect garbage 按鈕 (強制垃圾回收按鈕) 可以強制進行垃圾回收。 所以錄制順序可以這樣:開始錄制前先點擊垃圾回收-->點擊開始錄制-->點擊垃圾回收-->點擊結束錄制。 面板介紹如圖:

錄制結果如圖:

首先,從圖中我們可以看出不同顏色的曲線代表的含義,這里主要關注JS堆內存、節點數量、監聽器數量。鼠標移到曲線上,可以在左下角顯示具體數據。在實際使用過程中,如果您看到這種 JS 堆大小或節點大小不斷增大的模式,則可能存在內存泄漏。
- 使用堆快照發現已分離 DOM 樹的內存泄漏
只有頁面的 DOM 樹或 JavaScript 代碼不再引用 DOM 節點時,DOM 節點才會被作為垃圾進行回收。 如果某個節點已從 DOM 樹移除,但某些 JavaScript 仍然引用它,我們稱此節點為“已分離”,已分離的 DOM 節點是內存泄漏的常見原因。
同理,調出調試面板,點擊Memory
,然后選擇Heap Snapshot
,然后點擊進行錄制。錄制完成后,選中錄制結果,在 Class filter
文本框中鍵入 Detached
,搜索已分離的 DOM 樹。 以這段代碼為例:
<html> <head> </head> <body> <button id="createBtn">增加節點</button> <script> var detachedNodes; function create() { var ul = document.createElement('ul'); for (var i = 0; i < 10; i++) { var li = document.createElement('li'); ul.appendChild(li); } detachedTree = ul; } document.getElementById('createBtn').addEventListener('click', create); </script> </body> </html>
點擊幾下,然后記錄。可以得到以下信息:
- 按函數調查內存分配 打開面板,點擊
JavaScript Profiler
,如果沒看到這個選項,你可以點調試面板右上角的三個點,選擇more tools
,然后選擇。
ps: chrome 舊版的瀏覽器,這個功能在 Profiles
里面,點Record Allocation Profile
即可.
操作步驟:點start->在頁面進行你要檢測的操作->點stop。

DevTools 按函數顯示內存分配明細。默認視圖為 Heavy (Bottom Up),將分配了最多內存的函數顯示在最上方,還有函數的位置,你可以看看是哪些函數占用內存較多。
避免內存泄漏的方法
- 少用全局變量,避免意外產生全局變量
- 使用閉包要及時注意,有Dom元素的引用要及時清理。
- 計時器里的回調沒用的時候要記得銷毀。
- 為了避免疏忽導致的遺忘,我們可以使用
WeakSet
和WeakMap
結構,它們對於值的引用都是不計入垃圾回收機制的,表示這是弱引用。 舉個例子:
const wm = new WeakMap(); const element = document.getElementById('example'); wm.set(element, 'some information'); wm.get(element) // "some information"
這種情況下,一旦消除對該節點的引用,它占用的內存就會被垃圾回收機制釋放。Weakmap 保存的這個鍵值對,也會自動消失。
基本上,如果你要往對象上添加數據,又不想干擾垃圾回收機制,就可以使用 WeakMap。