基礎問答

問:有沒有遇到過內存泄漏?怎麼排查處理的

答:前端頁面上出現內存泄露,使用 Chrome devtools -> memory 工具排查,選擇時間軸分配(Allocations on timeline)功能後開始錄製操作,在頁面上進行相關組件的操作,停止錄製後,查看內存曲線,重點關注內存曲線上升的和下降的位置,如出現只升不降,沒有明顯回落的區域,再重點操作,重新錄製對應位置的操作,逐步縮小定位。對於這種重點關注的區域,可以同時使用堆快照追蹤持續增長的對象。對排查出來的點位進行驗證的時候,可以通過內存面板的垃圾回收按鈕,如下圖,回收後如果內存大小還是很高,可以確認是存在無法回收的內存,有泄露的情況。

8種常見的內存泄漏及處理方式(圖文)_內存泄露

擴展延伸

內存泄漏是 JavaScript 開發中隱蔽性強且影響嚴重的問題,尤其在長生命週期應用,如 SPA、後台管理系統中,可能導致頁面卡頓、崩潰甚至瀏覽器無響應的問題。

內存泄露的本質是:本來應該被回收的對象因為意外的引用而保留了下來,導致垃圾回收器無法釋放這個對象所佔用的內存,使得內存佔用持續增長。

垃圾回收機制

JavaScript 採用自動垃圾回收機制,不需要手動釋放內存,通過引用計數標記-清除算法回收不再使用的內存:

  • 引用計數:跟蹤每個對象被引用的次數,次數為 0 時回收,但是出現循環引用的時候,這個就無法解決了。
  • 標記 - 清除:從根對象(如 window )出發,標記所有可達對象,未被標記的對象將被回收,這是目前瀏覽器主流的算法。

OOM

和內存泄露相關聯的還有一個概念,即OOM,內存溢出,指的是在程序申請內存時,發現沒有可用內存分配,直接拋出了 OOM 異常。

一般來説,內存泄露是內存溢出的一個原因,但不是唯一的原因,而內存泄露會持續消耗內存資源,最終導致沒有可以分配的內存給程序,出現 OOM。

內存泄露的場景

  1. 意外的全局變量
    一般是在非嚴格模式下出現,使用的變量沒有聲明,會隱式的綁定到 window 對象上,變成持久性的引用,如:
function fn() {
	data = {};
}

解決方案:對於這種情況,第一優先的是啓動嚴格模式(現在的框架或項目都是默認為嚴格模式,通常不需要關注),其次,在現在使用的 es6 規範下,優先使用 let/const 關鍵字聲明,最後如果真的是全局變量,我們應該在確定不再使用後,賦值為 null ,從而切斷對象的引用,讓 GC 自動回收。

  1. 閉包導致內存泄露
    對於前端,閉包是一個非常好用的特性,但同時也需要在使用的時候注意,如果創建的閉包被長期使用,則閉包持有的變量就無法釋放,一個經典案例就是計時器:
function handleOnClickFac() {
	let timer = null;
	return function () {
		timer = setInterval(() => {
			console.log('hello');
		}, 3000);
	}
}

window.clickBtn = handleOnClickFac();

btn.addEventListener('click', window.clickBtn);

在這裏,每次點擊按鈕都會觸發定時器的創建,但是我們沒有清除回收,所以導致這個定時器一直存在,每次點擊的時候都會創建一個新的定時器。

這個例子中,包含兩個場景,一是閉包,二是定時器。

解決方案:限制閉包生命週期,比如這裏在 btn 組件卸載時,銷燬閉包,從而實現“不可達”的情況,讓 GC 回收,其次需要在使用完成後,清除閉包內的引用,在這個例子中,我們不僅要清楚引用,同時還應該清除定時器,否則依舊存在問題。

  1. DOM 元素引用未釋放
    分兩種情況:1. DOM 樹中已經沒有 DOM 元素了,但是 JavaScript 中還有這個 DOM 元素的鏈接(變量),2. 事件監聽器沒有移除,存在 DOM 和監聽回調存在互相引用的情況。
// 場景1:DOM已刪除但 JS 仍引用
const list = document.getElementById('list');
const data = { element: list }; // 引用DOM元素
document.body.removeChild(list); 
// list已從DOM樹移除,但data.element仍引用它,無法回收

// 場景2:事件監聽器未移除
const button = document.getElementById('btn');
button.addEventListener('click', () => {
  console.log('點擊事件');
});
// 按鈕被刪除後,監聽器未移除,導致按鈕和回調函數都無法回收

解決方案:解決這類場景的核心依舊是在不需要的時候釋放引用,不過對於 DOM,還有一種方式就是使用事件委託,從而在子元素刪除的時候不受影響。

  1. 第三方庫資源未清理
    類似於 Echarts 、地圖等庫,會要求我們在不使用的時候,調用對應的銷燬的 API,如果我們沒有調用,這些庫創建的臨時資源就會持續佔用內存,導致內存泄露。

這些場景下的解決方案都是需要我們手動在需要的地方去清除引用,從而使 GC 能夠識別並回收內存,通過這些例子也不難發現,雖然在 JavaScript 中不需要我們做類似於 C++ 的手動內存回收,但是依舊需要我們去幫助 GC 更好的判斷資源是否需要回收。

檢測和分析

內存泄露的檢測和分析主要是通過瀏覽器的內存工具,這裏以 Chrome 為例,我們在檢測和分析時使用的是 Chrome Devtool Memory 面板:

8種常見的內存泄漏及處理方式(圖文)_內存泄露_02

  1. 觀察時間線上的分配(Allocation Timeline)
  1. 開啓記錄後,按照推測的問題,操作頁面內容,完成後停止記錄,開始自動分析
  2. 觀察只升不降的區域,重複錄製該區域對應的操作,查看內存是否確實存在只分配不回收的情況,記錄該操作
  1. 記錄堆快照(Heap Snapshot)
  1. 操作開始前,記錄一次初始的堆快照
  2. 重複第一步記錄的操作,拍攝第二次快照,並開啓比較(Comparison)模式,重點關注 Delta 和 Retainers 指標(這裏對應的面板的中文名是 #增量固定裝置 ,翻譯不是很準確,這裏提供英文界面的圖作為參考

    Delta 關注持續增長的對象,Retainer 追蹤引用該對象的變量
  1. 點擊垃圾桶(代表 GC)觸發一次 GC,如果 GC 後內存依舊很高,就可以確認是存在內存泄露。

面試追問

  1. 內存泄露和內存溢出有什麼關係?
    內存泄露會導致內存溢出,但是內存溢出不一定是內存泄露導致的。
  2. 常見的內存泄露場景,舉個例子?
    參考本文【內存泄露的場景】一節
  3. Node.js 服務中,長生命週期對象持有短生命週期對象是一個典型的泄露場景,舉例並給出排查思路
// 用全局對象做緩存,無淘汰策略
const cache = {}; 

// 接口每次請求都往緩存加數據
app.get('/api/data', (req, res) => {
  const key = `data_${req.query.id}`;
  const largeData = fetchLargeData(req.query.id); // 10MB 數據
  cache[key] = largeData; // 只加不刪,緩存持續膨脹
  res.send(largeData);
});

由於 cache 沒有設置緩存的過期時間、淘汰的方式,導致 largeData 一直被持有,使得內存不斷增長。

排查思路:1. Node.js 應用啓動時添加 --inspect 標誌,2. 在 Chrome 瀏覽器中,訪問 chrome://inspect 鏈接對應的 Node 進程,開始監測,3. 記錄初始時的堆快照和多次觸發後的堆快照,方式參考【檢測和分析】一節,4. 查看 cache 的引用路徑以及清理邏輯。5. 設置緩存時間或LRU淘汰策略解決這個問題

  1. 線上環境 Nodejs OOM 觸發報警了,你應該怎麼做?
    首先,應急止損,滾動重啓服務,避免損失擴大,同時增加內存延緩 OOM 時間。
    其次,分析問題出現的時間,判斷是否可以回滾服務解決。
    最後,分析定位根源,按照服務日誌和本地排查手段進行。
    如果使用的是 k8s 等虛化手段,可以配置服務重啓規則,避免人工低效的操作方式。