原文:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec
Overview -概覽
在類似C的語言中,存在一些諸如malloc()和free()的低級操作方法,用來人為的精確分配和釋放操作系統內存。
然而JS則是在對象(或字符串等)被創建時自動分配內存,并在其不再被使用時“自動”用垃圾回收機制(gc)釋放內存。但這種看起來順其自然的“自動”釋放資源成了混亂之源,并給JS(及其他高級語言)開發者一種錯誤的印象,那就是他們可以不關心內存管理。這是個大毛病。
為了正確處理(或盡快找到合適的變通方案)時不時由自動內存管理引發的問題(一些bug或者gc的實現局限性等),即便是使用高級語言,開發者也應該理解內存管理(至少是基本的)。
Memory life cycle -內存生命周期
不管使用什么編程語言,內存生命周期幾乎總是相同的:
周期中每一步的基本是這樣的:
分配內存—內存被操作系統分配給程序使用。在低級語言(比如C)中,由開發者手動處理;而在高級語言中,開發者是很省心的。
使用內存—使用程序代碼中的變量等時,引發了讀寫操作,從而真正使用了先前分配的內存。
釋放內存—當不再需要使用內存時,就是完全釋放整個被分配內存空間的時機,內存重新變為可用的。與分配內存一樣,該操作只在低級語言中需要手動進行。
可以看這篇帖子快速了解調用棧和內存堆。 https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf
What is memory? -什么是內存?
在直接轉入JS內存的話題前,我們主要討論一下通常內存的含義,并簡短說一下它是如何工作的。
在硬件層面,計算機內存由大量觸發器組成。每個觸發器包含一些晶體管,并用來儲存1比特位(以下簡稱位)的數據。不同的觸發器由唯一標識符定位以便對其讀寫。所以從概念上講,我們可以把整個計算機內存想象成一個可讀寫的巨大位數組。
作為人類,難以在位層面思考和計算,而是從大的維度上管理數據—將位集合成大一些的組就可以用來表示數字。8位被叫做1字節。除了字節,有時還有16位或32位等分組稱呼。
內存中存儲了很多東西:
.所有程序使用的變量和其他數據
.操作系統和程序的所有代碼
編譯器和操作系統共同管理大部分內存,但最好看一看底層發生了什么。當編譯代碼時,編譯器會檢查基本數據類型并提前計算它們需要多少內存。所需的內存數量被以“棧空間”的名義分配給程序,而這種稱呼的原因是:當函數被調用時,其內存被置于已存在內存的頂部;當調用結束后,以LIFO(后入先出)的順序被移除。舉例來說,看一下以下聲明:
int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes
編譯器立刻就能算出這部分代碼需要的空間
4 + 4 × 4 + 8 = 28 bytes.
這就是當前整數和雙精度浮點數的工作方式;而在20年前(16位機器上),典型的整數只用2字節存儲,而雙精度數用4字節。所以代碼不應該依賴于當前基礎數據類型的大小。
編譯器向棧中申請好一定數量的字節,并把即將和操作系統交互的代碼插入其中,以存儲變量。
在以上例子中,編譯器清楚的制度每個變量所需內存。事實上,每當我們寫入變量n時,這個變量在內部就被翻譯成類似“內存地址4127963”了。
如果試圖訪問這里的x[4] ,就會訪問關聯數據m。這是因為訪問的是數組中一個并不存在的元素—比數組中實際分配的最后一個元素x[3]又遠了4個字節,也就有可能結束讀寫在m的某個位上。這幾乎可以確定將給后續的程序帶來非常不希望發生的后果。
當函數調用其他函數時,每個函數各自有其自己調用的那塊棧空間。該空間保存著函數所有本地變量,以及一個用來記住執行位置的程序計數器。當函數結束時,這個內存塊再次被置為可用,以供其他用處。
Dynamic allocation -動態分配
遺憾的是,當我們不知道編譯時變量需要多少內存時,事情就沒那么簡單了。假設我們要做如下的事情:
int n = readInput(); // reads input from the user
...
// create an array with "n" elements
此處,在編譯時,編譯器并不知道數組需要多少內存,因為這取決于用戶的輸入。
所以,無法為變量在棧上分配房間了。相應的,程序必須在運行時明確向操作系統申請正確數量的空間。這部分內存從堆空間中指派。關于靜態內存和動態內存分配的不同之處總結在下表中:
Differences between statically and dynamically allocated memory
要全面理解動態內存分配如何工作,需要花費更多時間在指針上,可能有點太過背離本篇的主題了。
Allocation in JavaScript - JS中的分配
現在解釋一下在JS中的第一步(分配內存)如何工作。與聲明變量并賦值的同時,JS自動進行了內存分配—從而在內存分配問題上解放了開發者們。
var n = 374; //為數字分配內存
var s = 'sessionstack'; //為字符串分配內存
var o = {
a: 1,
b: null
}; //為對象和其包含的值分配內存
var a = [
1, null, ‘str'
];? //為數組和其包含的值分配內存
function f(a) {
return a + 3;
} //為函數分配內存(也就是一個可調用的對象)
//函數表達式也是為對象分配內存
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);
一些函數調用也按對象分配:
var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element
方法會被分配新值或對象:
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2是一個新字符串
//因為字符串是不可變的,
//所以JS并不分配新的內存,
//只是存儲[0, 3]的范圍.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
//由a1和a2的元素串聯成新的4個元素的數組
Using memory in JavaScript -在JS中使用內存
在JS中使用內存,基本上就意味著對其讀寫。這將發生在讀寫一個變量、對象屬性,或對一個函數傳遞值等時候。
Release when the memory is not needed anymore -當不再需要內存時釋放它
大部分內存管理問題都發生在這個階段。
最難辦的事就是找出什么時候分配的內存不再有用了。這通常需要開發者決定代碼中的哪一塊不再需要內存,并釋放它。
高級語言包含了垃圾回收器的功能,其職責就是跟蹤內存分配和使用,以便找出什么時候相應的內存不再有用,并自動釋放它。
遺憾的是,這只是一個粗略估算的過程,因為要知道需要多少內存的問題是不可決定的(無法通過算法解決)。
大部分gc通過收集無法再被訪問到的內存來工作,例如所有指向該內存塊的變量都離開了其作用域。然而,這只是一組可被收集的內存空間的粗略估計,因為可能存在著某一個變量仍處在其作用域內,但就是永遠不再被訪問的情況。
Garbage collection -內存回收器
由于找出某些內存是否“不再被需要”是不可決定的,gc實現了對解決一般問題的一個限制。本章將解釋必要的概念,以理解主要的gc算法和其限制。
Memory references -內存引用
gc算法主要依賴的一個概念就是引用。
在內存管理的上下文中,說一個對象引用了另一個的意思,就是指前者直接或間接的訪問到了后者。舉例來說,一個JavaScriptobject間接引用了其原型對象,而直接引用了其屬性值。
在此上下文中,所謂“對象”的指向就比純JavaScript object更寬泛了,包括了函數作用域(或全局詞法作用域)在內。
詞法作用域定義了如何在嵌套的函數中處理變量名稱:內部函數包含了父函數的作用域,即便父函數已經return。
Reference-counting garbage collection -引用計數法
這是最簡單的一種gc算法。如果一個對象是“零引用”了,就被認為是該回收的。
看下面的代碼:
var o1 = {
o2: {
x: 1
}
};
//創建了2個對象
// 'o2'作為'o1'的屬性被其引用
//兩者都不能被回收
var o3 = o1;
//變量'o3'引用了'o1'指向的對象
o1 = 1;
//原本被'o1'引用的對象只剩下了變量‘o3'的引用
var o4 = o3.o2;
// 'o2'現在有了兩個引用
//作為父對象的屬性,以及被變量‘o4’引用
o3 = '374';
//原本被'o1'引用的對象現在是“零引用”了
//但由于其'o2'屬性仍被'o4'變量引用,所以不能被釋放
o4 = null;
//原本被'o1'引用的對象可以被gc了
Cycles are creating problems -循環引用帶來問題
循環引用會帶來問題。在下面的例子中,兩個對象被創建并互相引用,這就形成了一個循環引用。當他們都離開了所在函數的作用域后,卻因為互相有1次引用,而被引用計數算法認為不能被gc。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 references o2
o2.p = o1; // o2 references o1. This creates a cycle.
}
f();
Mark-and-sweep algorithm -標記清除法
該算法靠判斷對象是否可達,來決定對象是否是需要的。
算法由以下步驟組成:
垃圾回收器會創建一個列表,用來保存根元素,通常指的是代碼中引用到的全局變量。在JS中,’window’對象通常被作為一個根元素。
所有根元素被監視,并被標記為活躍的(也就是不作為垃圾)。所有子元素也被遞歸的如此處理。從根元素可達的每個元素都不被當成垃圾。
直到一塊內存中所有的東西都不是活躍的了,就可以被認為都是垃圾了。回收器可以釋放這塊內存并將其返還給OS。
標記清除法的運行示意圖
這個算法比引用計數法更好的地方在于:“零引用”會導致這個對象不可到達;而相反的情況并不像我們在循環引用中看到的那樣無法正確處理。
自從2012年起,所有現代瀏覽器都包含了一個標記清除法的垃圾回收器,雖然沒有改進算法本身或其判斷對象是否可達的目標,但過去一年在JS垃圾回收領域關于標記清除法取得的所有進步(分代回收、增量回收、并發回收、并行回收)都包含在其中了。
可以在這篇文章中閱讀追蹤垃圾回收算法及其優化的更多細節。 https://en.wikipedia.org/wiki/Tracing_garbage_collection
Cycles are not a problem anymore -循環引用不再是個問題
在上面的第一個例子中,當函數調用結束,兩個對象將不再被任何從跟對象可達的東西引用。
因此,它們將被垃圾回收器認定是不可達的。
盡管兩個對象相互引用,但根元素無法找到它們。
Counter intuitive behavior of Garbage Collectors -垃圾回收器中違反直覺的行為
盡管GC很方便,但也帶來一些取舍權衡。其中一點是其不可預知性。換句話說,GC是沒準兒的,無法真正的說清回收什么時候進行。這意味著有時程序使用了超過其實際需要的內存;另一些情況下,應用可能會假死。
盡管不可預知性意味著無法確定回收的執行時機,但大部分GC的實現都共享了在分配過程中才執行回收的通用模式。如果沒有執行分配,大部分GC也會保持空閑。
考慮以下場景:
.很大一組分配操作被執行。
.其中的大部分元素(或全部)被標記為不可達(假設我們對不再需要用的一個緩存設為null)。
.沒有后續的分配再被執行
在這個場景下,大部分GC不會再運行回收操作。也就是說,盡管有不可達的引用可被回收,但回收器并不工作。并不算嚴格的泄漏,但仍然導致內存實用高于正常。
What are memory leaks? -何為內存泄漏
本質上來說,內存泄漏可以定義為:不再被應用需要的內存,由于某種原因,無法返還給操作系統或空閑內存池。
內存泄漏是不好的...對吧?
編程語言喜歡用不同的方式管理內存。但是,一塊內存是否被使用確實是個無解的問題。換句話說,只有開發者能弄清一塊內存是否能被返還給操作系統。
某些編程語言提供了幫助開發者達到此目的的特性。其他一些期望當一塊內存不被使用時,開發者完全明示。
The four types of common JavaScript leaks -四種常見的JS內存泄漏
1: Global variables -全局變量
JS用一種很逗的方式處理未聲明的變量:對一個未聲明變量的引用將在global對象中創建一個新變量;在瀏覽器中就是在window對象中創建。換句話說:
function foo(arg) {
bar = "some text";
}
等價于:
function foo(arg) {
window.bar = "some text";
}
如果bar應該是所在foo函數作用域中的變量,而你忘了用var聲明它,那就會創建一個期望外的全局變量。
在這個例子中,泄漏的只是一個無害的簡單字符串,但實際情況肯定會更糟糕的。
另一種意外創建全局變量的途徑是通過‘this’ :
function foo() {
this.var1 = "potential accidental global";
}
foo();
//直接執行了構造函數,this指向了window
在JS文件開頭添加'use strict';可以防止出現這種錯誤。這將允許用一種嚴格模式來處理JS,以防意外創建全局變量。
在這里學習更多這種JS執行的模式。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode
盡管我們談論了未知的全局變量,其實代碼中也大量存在明確定義的全局變量。它們被定義為不可回收的(除非賦值為null或重新賦值)。特別是用全局變量暫存數據或處理大量的數據,也是值得注意的—如果非要這么做,記得在使用后對其賦值為null或重新指定。
2: Timers or callbacks that are forgotten -被遺忘的定時器或回調函數
在JS中使用setInterval稀松平常。
大多數庫,如果提供了觀察者之類的功能,都會有回調函數;當這些庫工具本身的實例變為不可達后,要注意使其引用的回調函數也應不可達。對于setInterval來說,下面這種代碼卻很常見:
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //大約每5秒執行一次
這個例子演示了定時器會發生什么:定時器引用了不再需要的節點或數據。
在未來的某個時刻,由renderer代表的對象可能會被移除,使得整個定時處理函數塊變為無用的。但因為定時器始終有效,處理函數又不會被回收(需要停止定時器才行)。這也意味著,那個看起來個頭也不小的serverData,同樣也不會被回收。
而對于觀察者的場景,重要的是移除那些不再有用的明確引用(或相關的對象)。
之前,這對某些無法很好的管理循環引用(見上文)的瀏覽器(IE6咯)非常關鍵。當今,即使沒有明確刪除監聽器,大部分瀏覽器都能在觀察對象不可達時回收處理函數;但在對象被去除之前,明確移除這些觀察者,始終是個好習慣。
var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
//做些什么
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
//現在,當元素離開作用域
//即便是老舊瀏覽器,也能正確回收元素和處理函數了
當前,現代瀏覽器(包括IE和Microsoft Edge)都使用了可以檢測這些循環引用并能正確處理之的現代垃圾回收器算法。也可以說,在使得節點不可達之前,不再有必要嚴格的調用removeEventListener了。
諸如jQuery等框架和庫在去除節點之前做了移除監聽工作(當調用其特定API時)。這種庫內部的處理同時確保了沒有泄露發生,即便是運行在問題頻發的瀏覽器時。。。嗯,說的就是IE6。
3: Closures -閉包
JS開發中很重要的一方面就是閉包:一個有權訪問所包含于的外層函數中變量的內部函數。歸因于JS運行時的實現細節,在如下方式中可能導致內存泄漏:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
varunused= function () {
if (originalThing) //對originalThing的引用
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);
這段代碼做了一件事:每次調用replaceThing時,theThing獲得一個包含了一個巨大數組和一個新閉包(someMethod)的新對象。同時,變量unused則指向一個引用了originalThing(其實就是前一次調用replaceThing時指定的theThing)的閉包。已經懵了,哈?關鍵之處在于:一旦同一個父作用域中的閉包們的作用域被創建了,則其作用域是共享的。
在本例中,someMethod和unused共享了作用域;而unused引用了originalThing。盡管unused從來沒被調用,但通過theThing,someMethod可能會在replaceThing外層作用域(例如全局的某處)被調用。并且因為someMethod和unused共享了閉包作用域,unused因為有對originalThing的引用,從而迫使其保持活躍狀態(被兩個閉包共享的整個作用域)。這也就阻止了其被回收。
當這段代碼被重復運行時,可以觀察到內存占用持續增長,并且在GC運行時不會變小。本質上是,創建了一個閉包的鏈表(以變量theThing為根),其中每個閉包作用域間接引用一個巨大的數組,從而導致一個超出容量的泄漏。
該問題的更多描述見Meteor團隊的這篇文章。 https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156
4: Out of DOM references -脫離DOM的引用
有時把DOM節點儲存在數據結構里是有用的。假設要一次性更新表格的多行內容,那么把每個DOM行的引用保存在一個字典或數組中是合理的;這樣做的結果是,同一個DOM元素會在DOM數和JS數據中
各有一個引用。如果未來某個時刻要刪除這些行,就得使兩種引用都不可達才行。
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
// img元素的父元素是bodydocument.body.removeChild(document.getElementById('image'));
//此時,全局對象elements中仍引用著#button
//換句話說,GC無法回收button元素
}
另外需要額外考慮的是對一個DOM樹的內部節點或葉子節點的引用。比方說JS代碼引用了表格中某個單元格(一個td標簽);一旦決定從DOM中刪除整個表格,卻保留了之前對那個單元格的引用的話,是不會想當然的回收除了那個td之外的其他東西的。實際上,因為單元格作為表格的子元素而持有對父元素的引用,所以JS中對單元格的引用導致了整個表格留在內存中。當保留對DOM元素的引用時,要格外注意這點。
-------------------------------------
長按二維碼或搜索 fewelife 關注我們哦