Web應(yīng)用內(nèi)存分析與內(nèi)存泄漏定位
內(nèi)存分析與內(nèi)存泄漏定位是筆者現(xiàn)代 Web 開發(fā)工程化實(shí)踐之調(diào)試技巧的一部分,主要介紹 Web 開發(fā)中需要了解的內(nèi)存分析與內(nèi)存泄露定位手段,本部分涉及的參考資料統(tǒng)一聲明在Web 開發(fā)界面調(diào)試資料索引。
無論是分布式計算系統(tǒng)、服務(wù)端應(yīng)用程序還是 iOS、Android 原生應(yīng)用都會存在內(nèi)存泄漏問題,Web 應(yīng)用自然也不可避免地存在著類似的問題。雖然因為網(wǎng)頁往往都是即用即走,較少地存在某個網(wǎng)頁長期運(yùn)行的問題,即使存在內(nèi)存泄漏可能表現(xiàn)地也不明顯;但是在某些數(shù)據(jù)展示型的,需要長期運(yùn)行的頁面上,如果不及時解決內(nèi)存泄漏可能會導(dǎo)致網(wǎng)頁占據(jù)過大地內(nèi)存,不僅影響頁面性能,還可能導(dǎo)致整個系統(tǒng)的崩潰。前端每周清單推薦過的 How JavaScript works 就是非常不錯地介紹 JavaScript 運(yùn)行機(jī)制的系列文章,其也對內(nèi)存管理與內(nèi)存泄漏有過分析,本文部分圖片與示例代碼即來自此系列。
類似于 C 這樣的語言提供了 malloc() 與 free() 這樣的底層內(nèi)存管理原子操作,開發(fā)者需要顯式手動地進(jìn)行內(nèi)存的申請與釋放;而 Java 這樣的語言則是提供了自動化的內(nèi)存回收機(jī)制,筆者在垃圾回收算法與 JVM 垃圾回收器綜述一文中有過介紹。JavaScript 也是采用的自動化內(nèi)存回收機(jī)制,無論是 Object、String 等都是由垃圾回收進(jìn)程自動回收處理。自動化內(nèi)存回收并不意味著我們就可以忽略內(nèi)存管理的相關(guān)操作,反而可能會導(dǎo)致更不易發(fā)現(xiàn)的內(nèi)存泄漏出現(xiàn)。
內(nèi)存分配與回收
筆者在 JavaScript Event Loop 機(jī)制詳解與 Vue.js 中實(shí)踐應(yīng)用一文中介紹過 JavaScript 的內(nèi)存模型,其主要也是由堆、棧、隊列三方面組成:
其中隊列指的是消息隊列、棧就是函數(shù)執(zhí)行棧,其基本結(jié)構(gòu)如下所示:
而主要的用戶創(chuàng)建的對象就存放在堆中,這也是我們內(nèi)存分析與內(nèi)存泄漏定位所需要關(guān)注的主要的區(qū)域。所謂內(nèi)存,從硬件的角度來看,就是無數(shù)觸發(fā)器的組合;每個觸發(fā)器能夠存放 1 bit 位的數(shù)據(jù),不同的觸發(fā)器由***的標(biāo)識符定位,開發(fā)者可以根據(jù)該標(biāo)識符讀寫該觸發(fā)器。抽象來看,我們可以將內(nèi)存當(dāng)做比特數(shù)組,而數(shù)據(jù)就是在內(nèi)存中順序排布:
JavaScript 中開發(fā)者并不需要手動地為對象申請內(nèi)存,只需要聲明變量,JavaScript Runtime 即可以自動地分配內(nèi)存:
- var n = 374; // allocates memory for a number
- var s = 'sessionstack'; // allocates memory for a string
- var o = {
- a: 1,
- b: null
- }; // allocates memory for an object and its contained values
- var a = [1, null, 'str']; // (like object) allocates memory for the
- // array and its contained values
- function f(a) {
- return a + 3;
- } // allocates a function (which is a callable object)
- // function expressions also allocate an object
- someElement.addEventListener('click', function() {
- someElement.style.backgroundColor = 'blue';
- }, false);
某個對象的內(nèi)存生命周期分為了內(nèi)存分配、內(nèi)存使用與內(nèi)存回收這三個步驟,當(dāng)某個對象不再被需要時,它就應(yīng)該被清除回收;所謂的垃圾回收器,Garbage Collector 即是負(fù)責(zé)追蹤內(nèi)存分配情況、判斷某個被分配的內(nèi)存是否有用,并且自動回收無用的內(nèi)存。大部分的垃圾回收器是根據(jù)引用(Reference)來判斷某個對象是否存活,所謂的引用即是某個對象是否依賴于其他對象,如果存在依賴關(guān)系即存在引用;譬如某個 JavaScript 對象引用了它的原型對象。最簡單的垃圾回收算法即是引用計數(shù)(Reference Counting),即清除所有零引用的對象:
- var o1 = {
- o2: {
- x: 1
- }
- };
- // 2 objects are created.
- // 'o2' is referenced by 'o1' object as one of its properties.
- // None can be garbage-collected
- var o3 = o1; // the 'o3' variable is the second thing that
- // has a reference to the object pointed by 'o1'.
- o1 = 1; // now, the object that was originally in 'o1' has a
- // single reference, embodied by the 'o3' variable
- var o4 = o3.o2; // reference to 'o2' property of the object.
- // This object has now 2 references: one as
- // a property.
- // The other as the 'o4' variable
- o3 = '374'; // The object that was originally in 'o1' has now zero
- // references to it.
- // It can be garbage-collected.
- // However, what was its 'o2' property is still
- // referenced by the 'o4' variable, so it cannot be
- // freed.
- o4 = null; // what was the 'o2' property of the object originally in
- // 'o1' has zero references to it.
- // It can be garbage collected.
不過這種算法往往受制于循環(huán)引用問題,即兩個無用的對象相互引用:
- function f() {
- var o1 = {};
- var o2 = {};
- o1.p = o2; // o1 references o2
- o2.p = o1; // o2 references o1. This creates a cycle.
- }
- f();
稍為復(fù)雜的算法即是所謂的標(biāo)記-清除(Mark-Sweep)算法,其根據(jù)某個對象是否可達(dá)來判斷某個對象是否可用。標(biāo)記-清除算法會從某個根元素開始,譬如 window 對象開始,沿著引用樹向下遍歷,標(biāo)記所有可達(dá)的對象為可用,并且清除其他未被標(biāo)記的對象。
2012 年之后,幾乎所有的主流瀏覽器都實(shí)踐了基于標(biāo)記-清除算法的垃圾回收器,并且各自也進(jìn)行有針對性地優(yōu)化。
內(nèi)存泄漏
所謂的內(nèi)存泄漏,即是指某個對象被無意間添加了某條引用,導(dǎo)致雖然實(shí)際上并不需要了,但還是能一直被遍歷可達(dá),以致其內(nèi)存始終無法回收。本部分我們簡要討論下 JavaScript 中常見的內(nèi)存泄漏情境與處理方法。在新版本的 Chrome 中我們可以使用 Performance Monitor 來動態(tài)監(jiān)測網(wǎng)頁性能的變化:
上圖中各項指標(biāo)的含義為:
- CPU usage - 當(dāng)前站點(diǎn)的 CPU 使用量;
- JS heap size - 應(yīng)用的內(nèi)存占用量;
- DOM Nodes - 內(nèi)存中 DOM 節(jié)點(diǎn)數(shù)目;
- JS event listeners- 當(dāng)前頁面上注冊的 JavaScript 時間監(jiān)聽器數(shù)目;
- Documents - 當(dāng)前頁面中使用的樣式或者腳本文件數(shù)目;
- Frames - 當(dāng)前頁面上的 Frames 數(shù)目,包括 iframe 與 workers;
- Layouts / sec - 每秒的 DOM 重布局?jǐn)?shù)目;
- Style recalcs / sec - 瀏覽器需要重新計算樣式的頻次;
當(dāng)發(fā)現(xiàn)某個時間點(diǎn)可能存在內(nèi)存泄漏時,我們可以使用 Memory 標(biāo)簽頁將此時的堆分配情況打印下來:
全局變量
JavaScript 會將所有的為聲明的變量當(dāng)做全局變量進(jìn)行處理,即將其掛載到 global 對象上;瀏覽器中這里的 global 對象就是 window:
- function foo(arg) {
- bar = "some text";
- }
- // 等價于
- function foo(arg) {
- window.bar = "some text";
- }
另一種常見的創(chuàng)建全局變量的方式就是誤用 this 指針:
- function foo() {
- this.var1 = "potential accidental global";
- }
- // Foo called on its own, this points to the global object (window)
- // rather than being undefined.
- foo();
一旦某個變量被掛載到了 window 對象,就意味著它永遠(yuǎn)是可達(dá)的。為了避免這種情況,我們應(yīng)該盡可能地添加 use strict 或者進(jìn)行模塊化編碼(參考 JavaScript 模塊演化簡史)。我們也可以擴(kuò)展類似于下文的掃描函數(shù),來檢測出 window 對象的非原生屬性,并加以判斷:
- function scan(o) {
- Object.keys(o).forEach(function(key) {
- var val = o[key];
- // Stop if object was created in another window
- if (
- typeof val !== "string" &&
- typeof val !== "number" &&
- typeof val !== "boolean" &&
- !(val instanceof Object)
- ) {
- debugger;
- console.log(key);
- }
- // Traverse the nested object hierarchy
- });
- }
定時器與閉包
我們經(jīng)常會使用 setInterval 來執(zhí)行定時任務(wù),很多的框架也提供了基于回調(diào)的異步執(zhí)行機(jī)制;這可能會導(dǎo)致回調(diào)中聲明了對于某個變量的依賴,譬如:
- var serverData = loadData();
- setInterval(function() {
- var renderer = document.getElementById('renderer');
- if(renderer) {
- renderer.innerHTML = JSON.stringify(serverData);
- }
- }, 5000); //This will be executed every ~5 seconds.
定時器保有對于 serverData 變量的引用,如果我們不手動清除定時器話,那么該變量也就會一直可達(dá),不被回收。而這里的 serverData 也是閉包形式被引入到 setInterval 的回調(diào)作用域中;閉包也是常見的可能導(dǎo)致內(nèi)存泄漏的元兇之一:
- var theThing = null;
- var replaceThing = function () {
- var originalThing = theThing;
- var unused = function () {
- if (originalThing) // a reference to 'originalThing'
- console.log("hi");
- };
- theThing = {
- longStr: new Array(1000000).join('*'),
- someMethod: function () {
- console.log("message");
- }
- };
- };
- setInterval(replaceThing, 1000);
上述代碼中 replaceThing 會定期執(zhí)行,并且創(chuàng)建大的數(shù)組與 someMethod 閉包賦值給 theThing。someMethod 作用域是與 unused 共享的,unused 又有一個指向 originalThing 的引用。盡管 unused 并未被實(shí)際使用,theThing 的 someMethod 方法卻有可能會被外部使用,也就導(dǎo)致了 unused 始終處于可達(dá)狀態(tài)。unused 又會反向依賴于 theThing,最終導(dǎo)致大數(shù)組始終無法被清除。
DOM 引用與監(jiān)聽器
有時候我們可能會將 DOM 元素存放到數(shù)據(jù)結(jié)構(gòu)中,譬如當(dāng)我們需要頻繁更新某個數(shù)據(jù)列表時,可能會將用到的數(shù)據(jù)列表存放在 JavaScript 數(shù)組中;這也就導(dǎo)致了每個 DOM 元素存在了兩個引用,分別在 DOM 樹與 JavaScript 數(shù)組中:
- var elements = {
- button: document.getElementById('button'),
- image: document.getElementById('image')
- };
- function doStuff() {
- elements.image.src = 'http://example.com/image_name.png';
- }
- function removeImage() {
- // The image is a direct child of the body element.
- document.body.removeChild(document.getElementById('image'));
- // At this point, we still have a reference to #button in the
- //global elements object. In other words, the button element is
- //still in memory and cannot be collected by the GC.
- }
此時我們就需要將 DOM 樹與 JavaScript 數(shù)組中的引用皆刪除,才能真實(shí)地清除該對象。類似的,在老版本的瀏覽器中,如果我們清除某個 DOM 元素,我們需要首先移除其監(jiān)聽器,否則瀏覽器并不會自動地幫我們清除該監(jiān)聽器,或者回收該監(jiān)聽器引用的對象:
- var element = document.getElementById('launch-button');
- var counter = 0;
- function onClick(event) {
- counter++;
- element.innerHtml = 'text ' + counter;
- }
- element.addEventListener('click', onClick);
- // Do stuff
- element.removeEventListener('click', onClick);
- element.parentNode.removeChild(element);
- // Now when element goes out of scope,
- // both element and onClick will be collected even in old browsers // that don't handle cycles well.
現(xiàn)代瀏覽器使用的現(xiàn)代垃圾回收器則會幫我們自動地檢測這種循環(huán)依賴,并且予以清除;jQuery 等第三方庫也會在清除元素之前首先移除其監(jiān)聽事件。
iframe
iframe 是常見的界面共享方式,不過如果我們在父界面或者子界面中添加了對于父界面某對象的引用,譬如:
- // 子頁面內(nèi)
- window.top.innerObject = someInsideObject
- window.top.document.addEventLister(‘click’, function() { … });
- // 外部頁面
- innerObject = iframeEl.contentWindow.someInsideObject
就有可能導(dǎo)致 iframe 卸載(移除元素)之后仍然有部分對象保留下來,我們可以在移除 iframe 之前執(zhí)行強(qiáng)制的頁面重載:
- <a href="#">Remove</a>
- <iframe src="url" />
- $('a').click(function(){
- $('iframe')[0].contentWindow.location.reload();
- // 線上環(huán)境實(shí)測重置 src 效果會更好
- // $('iframe')[0].src = "javascript:false";
- setTimeout(function(){
- $('iframe').remove();
- }, 1000);
- });
或者手動地執(zhí)行頁面清除操作:
- window.onbeforeunload = function(){
- $(document).unbind().die(); //remove listeners on document
- $(document).find('*').unbind().die(); //remove listeners on all nodes
- //clean up cookies
- /remove items from localStorage
- }
Web Worker
現(xiàn)代瀏覽器中我們經(jīng)常使用 Web Worker 來運(yùn)行后臺任務(wù),不過有時候如果我們過于頻繁且不加容錯地在主線程與工作線程之間傳遞數(shù)據(jù),可能會導(dǎo)致內(nèi)存泄漏:
- function send() {
- setInterval(function() {
- const data = {
- array1: get100Arrays(),
- array2: get500Arrays()
- };
- let json = JSON.stringify( data );
- let arbfr = str2ab (json);
- worker.postMessage(arbfr, [arbfr]);
- }, 10);
- }
- function str2ab(str) {
- var buf = new ArrayBuffer(str.length*2); // 2 bytes for each char
- var bufView = new Uint16Array(buf);
- for (var i=0, strLen=str.length; i<strLen; i++) {
- bufView[i] = str.charCodeAt(i);
- }
- return buf;
- }
在實(shí)際的代碼中我們應(yīng)該檢測 Transferable Objects 是否正常工作:
- let ab = new ArrayBuffer(1);
- try {
- worker.postMessage(ab, [ab]);
- if (ab.byteLength) {
- console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
- }
- else {
- console.log('USING TRANSFERABLE OBJECTS');
- }
- }
- catch(e) {
- console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
- }
【本文是51CTO專欄作者“張梓雄 ”的原創(chuàng)文章,如需轉(zhuǎn)載請通過51CTO與作者聯(lián)系】