摘要:本系列的第一篇文章著重提供一個關于引擎運行時和調用棧的概述。在硬件層面,計算機內存由大量的觸發器組成。每個觸發器包含幾個晶體管能夠存儲一個比特譯注位??梢酝ㄟ^唯一標識符來訪問單個觸發器,所以可以對它們進行讀寫操作。比特稱為個字節。
原文 How JavaScript works: memory management + how to handle 4 common memory leaks
幾周前我們開始了一個系列博文旨在深入挖掘 JavaScript 并弄清楚它的工作原理:我們認為通過了解 JavaScript 的構建單元并熟悉它們是怎樣結合起來的,有助于寫出更好的代碼和應用。
本系列的第一篇文章著重提供一個關于引擎、運行時和調用棧的概述。第二篇文章深入分析了 Google 的 V8 引擎的內部實現并提供了一些編寫更優質 JavaScript 代碼的建議。
在第三篇的本文中,我們將會討論另一個非常重要的主題,由于日常使用的編程語言的逐漸成熟和復雜性,它被越來越多的開發者忽視——內存管理。我們還會提供一些在 SessionStack 中遵循的關于如何處理 JavaScript 內存泄露的方法,我們必須保證 SessionStack 不會發生內存泄漏,或導致整合進來的應用增加內存消耗。
概述像 C 這樣的語言,具有低水平的內存管理原語如 malloc() 和 free(),這些原語被開發者用來顯式地向操作系統分配和釋放內存。
同時,JavaScript 在事物(對象、字符串等)被創建時分配內存,并在它們不再需要用到時自動釋放內存,這個過程稱為垃圾收集。這個看似自動釋放資源的特性是困惑的來源,造成 JavaScript(和其他高級語言)開發者錯誤的印象,認為他們可以選擇不必關心內存管理。這是個天大的誤解。
即便在使用高級編程語言時,開發者也應該了解內存管理(至少最基本的)。有時會遇到自動內存管理的問題(如垃圾收集器的BUG和實現限制等),開發者應該了解這些問題才能合理地處理它們(或找到適當的解決方案,用最小的代價和代碼債)。
內存生命周期無論使用哪種編程語言,內存的生命周期幾乎總是相同的:
下面是周期中每個步驟發生了什么的概覽:
分配內存——內存由允許程序使用的操作系統分配。在低級編程語言(如 C)中這是一個作為開發人員應該處理的顯式操作。而在高級編程語言中是由語言本身幫你處理的。
使用內存——這是程序實際上使用之前所分配內存的階段。讀寫操作發生在使用代碼中分配的變量時。
釋放內存——現在是釋放不需要的整個內存的時候了,這樣它才能變得空閑以便再次可用。與分配內存一樣,在低級編程語言中這是一個顯式操作。
想要快速瀏覽調用棧和內存堆的概念,可以閱讀我們關于這個主題的第一篇文章。
什么是內存?在直接介紹 JavaScript 中的內存之前,我們會簡要討論一下內存是什么及它是怎樣工作的。
在硬件層面,計算機內存由大量的觸發器組成。每個觸發器包含幾個晶體管能夠存儲一個比特(譯注:1位)??梢酝ㄟ^唯一標識符來訪問單個觸發器,所以可以對它們進行讀寫操作。因此從概念上,我們可以把整個計算機內存想象成一個巨大的可讀寫的比特陣列。
作為人類,我們并不擅長使用字節進行所有的思考和算術,我們把它們組織成更大的組合,一起用來表示數字。8比特稱為1個字節。除字節之外,還有其他詞(有時是16比特、有時是32比特)。
很多東西存儲在內存中:
所有程序使用的所有變量和其他數據。
程序代碼,包括操作系統的。
編譯器和操作系統一起工作來處理大部分的內存管理,但我們還是建議你了解一下底層發生的事情。
編譯代碼時,編譯器可以檢測到原始數據類型然后提前計算出需要多少內存。隨后給??臻g中的程序分配所需額度。分配變量的空間被稱為棧空間是因為當函數調用時,它們被添加到已有內存的頂部。當它們終止時,根據后進先出的原則被移除。例如,考慮如下聲明:
int n; // 4 bytes 4字節 int x[4]; // array of 4 elements, each 4 bytes 含有四個元素的數組,每個4字節 double m; // 8 bytes 8字節
編譯器能夠立即看出這段代碼需要4+4*4+8=28字節。
這是現今處理整型和雙精度浮點數的大小。20年以前,整型通常是2字節,雙精度是4字節。代碼永遠不應該依賴當前基本數據類型的大小。
編譯器將會插入代碼與操作系統交互,請求棧上存儲變量所需的字節數。
在上面的例子中,編譯器知道每個變量的精確內存地址。實際上,每當寫入變量 n,它都會在內部被轉換成類似“內存地址4127963”的東西。
注意,如果試圖在這里訪問 x[4],將會訪問到與 m 關聯的數據。這是因為我們在訪問數組中一個不存在的元素——比數組中最后實際分配的成員 x[3] 要遠4個字節,這可能最終會讀?。ɑ驅懭耄┮恍?m 中的比特。這必將會使程序其余部分產生非常不希望得到的結果。
當函數調用其他函數時,每個函數都會在被調用時得到屬于自己的一塊棧。這里不僅保存了所有的局部變量,還保存著記錄執行位置的程序計數器。當函數結束時,它的內存單元再次變得空閑可供他用。
動態分配不幸的是,當我們在編譯時無法得知變量需要多少內存的時候事情就沒那么簡單了。假設我們要做如下的事情:
int n = readInput(); // reads input from the user ... // create an array with "n" elements
這在編譯時,編譯器無法知道數組需要多少內存,因為它取決于用戶提供的值。
因此無法為棧中的變量分配空間。相反,我們的程序需要在運行時顯式向操作系統請求合適的空間。這種內存由堆空間分配。靜態和動態內存分配的區別總結為下表:
要充分理解動態內存分配的原理,我們需要在指針上多花些時間,但這已經偏離了本文的主題。如果有興趣學習更多,請在評論里留言告訴我們,我們可以在以后的文章中討論更多關于指針的細節。
JavaScript 中的分配現在我們將解釋第一步(分配內存)如何在 JavaScript 中工作。
JavaScript 將開發者從內存分配的責任中解放出來——在聲明變量的同時它會自己處理內存分配。
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);
某些函數調用也產生對象分配:
var d = new Date(); // allocates a Date object 分配一個日期對象 var e = document.createElement("div"); // allocates a DOM element 分配一個DOM元素
方法可以分配新的值或對象:
var s1 = "sessionstack"; var s2 = s1.substr(0, 3); // s2 is a new string s2是一個新字符串 // Since strings are immutable, 由于字符串是不可變的 // JavaScript may decide to not allocate memory, JavaScript可能會決定不分配內存 // but just store the [0, 3] range. 而僅僅存儲[0, 3]這個范圍 var a1 = ["str1", "str2"]; var a2 = ["str3", "str4"]; var a3 = a1.concat(a2); // new array with 4 elements being 含有四個元素的數組 // the concatenation of a1 and a2 elements 由a1和a2的元素的結合在 JavaScript 中使用內存
在 JavaScript 中使用分配的內存基本上意味著在其中進行讀寫操作。
這可以通過讀取或寫入變量的值或對象屬性、甚至向函數傳參數的時候實現。
在不需要內存時將其釋放大多數內存管理問題出現在這個階段。
最大的難題是弄清楚何時不再需要分配的內存。通常需要開發者來決定這塊內存在程序的何處不再需要并且釋放它。
高級編程語言嵌入了一個叫做垃圾收集器軟件,它的工作是追蹤內存分配和使用以便發現分配的內存何時不再需要,并在這種情況下自動釋放它。
不幸的是這個過程只是個近似的過程,因為知道是否還需要一些內存的一般問題是不可決定的(無法靠算法解決)。
大多數垃圾收集器的工作原理是收集不能再訪問的內存,比如指向它的所有變量都超出作用域。但這也是對可收集內存空間的一種低估,因為在任何時候作用域內都仍可能有一個變量指向一個內存地址,然而它再也不會被訪問。
垃圾收集由于無法確定某些內存是否“不再需要”,垃圾收集實現了對一般解決方法的限制。這一節將會解釋理解主要的垃圾收集算法的必要概念和局限性。
內存引用垃圾收集算法依賴的主要概念之一是引用。
在內存管理的上下文中,如果一個對象可以訪問另一個對象則說成是前者引用了后者(可是隱式也可是顯式)。例如,JavaScript 對象有對其原型的引用(隱式引用)和對屬性的引用(顯式引用)。
在這個上下文中,”對象“的概念擴展到比常規 JavaScript 對象更廣泛的范圍,并且還包含函數作用域(或全局詞法作用域)。
詞法作用域規定了如何解析嵌套函數中的變量名稱:內層函數包含了父函數的作用域,即使父函數已返回。引用計數垃圾收集
這是最簡單的垃圾收集算法。如果沒有指向對象的引用,就被認為是“可收集的”。
看看如下代碼:
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 // 創建了兩個對象 // o2 被當作 o1 的屬性而引用 // 現在沒有可被收集的垃圾 var o3 = o1; // the "o3" variable is the second thing that // has a reference to the object pointed by "o1". // o3是第二個引用了o1 所指向對象的變量。 o1 = 1; // now, the object that was originally in "o1" has a // single reference, embodied by the "o3" variable // 現在,本來被 o1 指向的對象變成了單一引用,體現在 o3 上。 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 // 通過屬性 o2 建立了對它所指對象的引用 // 這個對象現在有兩個引用:一個作為屬性的o2 // 另一個是變量 o4 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. // 原本由 o1 引用的對象現在含有0個引用。 // 它可以被作為垃圾而收集 // 但是它的屬性 o2 仍然被變量 o4 引用,所以它不能被釋放。 o4 = null; // what was the "o2" property of the object originally in // "o1" has zero references to it. // It can be garbage collected. // 原本由 o1 引用的對象的屬性 o2 現在也只有0個引用,它現在可以被收集了。循環制造出問題
這在循環引用時存在限制。在下面示例中,創建了兩個互相引用的對象,從而創建了一個循環。它們在函數調用返回后超出作用域,所以實際上它們已經沒用了并應該被釋放。但引用計數算法考慮到由于它們至少被引用了一次,所以兩者都不會被當作垃圾收集。
function f() { var o1 = {}; var o2 = {}; o1.p = o2; // o1 references o2 o2.p = o1; // o2 references o1. This creates a cycle. } f();標記和清理算法
為了決定是否還需要對象,這個算法確定了對象是否可以訪問。
標記和清理算法有如下三個步驟:
根:通常,根是被代碼引用的全局變量。例如在 JavaScript 中,可以作為根的全局變量是 window 對象。同一對象在 Node.js 中被稱為 global。垃圾收集器建立了所有根的完整列表。
接著算法檢查所有根及它們的子節點,并把它們標記為活躍的(意為它們不是垃圾)。根所不能獲取到的任何東西都被標記為垃圾。
最終,垃圾收集器把未標記為活躍的所有內存片段釋放并返還給操作系統。
這個算法比之前的更好,因為“一個對象沒有引用”造成這個對象變得不可獲取,但通過循環我們看到反過來卻是不成立的。
2012年后,所有現代瀏覽器都裝載了標記和清理垃圾收集器。近年來,在 JavaScript 垃圾收集所有領域的改善(分代/增量/并發/并行垃圾收集)都是這個算法(標記和清理)的實現改進,既不是垃圾收集算法自身的改進也并非決定是否對象可獲取的目標的改進。
在這篇文章中,你可以閱讀到有關追蹤垃圾收集的大量細節,并且涵蓋了標記和清理及它的優化。
循環不再是問題在上面的第一個例子中,當函數調用返回后,兩個對象不再被全局對象的可獲取節點引用。結果是,它們會被垃圾收集齊認為是不可獲取的。
即便它們彼此間仍存在引用,它們也不能被根獲取到。
垃圾收集器與直覺相反的行為雖然垃圾收集器很方便,但它們也有自己的一套折中策略。其一是非確定性。換句話說,垃圾收集是不可預測的。你無法確切知道垃圾收集什么時候執行。這意味著在一些情況下程序會要求比實際需要更多的內存。另一些情況下,短時暫停會在一些特別敏感的應用中很明顯。雖然非確定性意味著無法確定垃圾收集執行的時間,但大多數垃圾收集的實現都共享一個通用模式:在內存分配期間進行收集。如果沒有內存分配發生,垃圾收集器就處于閑置??紤]以下場景:
執行大量內存分配。
它們大多數(或全部)被標記為不可獲取(假設我們將一個不再需要的指向緩存的引用置為null)。
不再有進一步的內存分配發生。
在這個場景下,大多數垃圾收集不會再運行收集傳遞。換言之,即時存在無法訪問的引用可以收集,它們也不會被收集器注意到。這些不是嚴格意義上的泄露,但是仍然導致了比正常更高的內存使用。
什么是內存泄露?就像內存所暗示的,內存泄露是被應用使用過的一塊內存在不需要時尚未返還給操作操作系統或由于糟糕的內存釋放未能返還。
編程語言喜歡用不同的方式進行內存管理。但一塊已知內存是否還被使用實際上是個無法決定的問題。換句話說,只有開發人員可以弄清除是否應該將一塊內存還給操作系統。
某些編程語言提供了開發人員手動釋放內存的特性。另一些則希望由開發人員完全提供顯式的聲明。維基百科上有關于手動和自動內存管理的好的文章。
四種常見 JavaScript 泄露 1:全局變量JavaScript 處理未聲明變量的方式很有趣:當引用一個還未聲明的變量時,就在全局對象上創建一個新變量。在瀏覽器中,全局對象是 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 called on its own, this points to the global object (window) // rather than being undefined. foo();
可以通過在 JavaScript 文件的開頭添加 "use strict"; 來避免這一切,這會開啟一個更加嚴格的模式來解析代碼,它可以防止意外創建全局變量。
意外的全局變量當然是個問題,但是通常情況下,你的代碼會被顯示全局變量污染,并且根據定義它們無法被垃圾收集器收集。應該尤其注意用來臨時性存儲和處理大量信息的全局變量。如果你必須使用全局變量存儲信息而當你這樣做了時,確保一旦完成之后就將它賦值為 null 或重新分配。
2:被遺忘的計時器或回調讓我們來看看 setInterval 的列子,它在 JavaScript 中經常用到。
提供觀察者模式的庫和其他接受回調函數的實現通常會在它們的實例無法獲取確保對這些回調函數的引用也變成無法獲取。同樣,下面的代碼不難找到:
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.
上面這段代碼展示了引用不再需要的節點或數據的后果。
renderer 對象可能在某個時候被覆蓋或移除,這將會導致封裝在間隔處理函數中的語句變得冗余。一旦發生這種情況,處理器和它依賴的東西必須要等到間隔器先被停止之后才能收集(記住,它依然是活躍的)。這將會導致這樣的事實:用于儲存和處理數據的 serverData 也將不會被收集。
當使用觀察者模式時,你需要在完成后確保通過顯示調用移除它們(既不再需要觀察者,對象也變成不可獲取的)。
幸運的是,大多數現代瀏覽器會為我們處理好這些事務:它們會自動收集被觀察對象變成不可獲取的觀察者處理器,即使你忘記移除這些監聽器。過去一些瀏覽器是無法做到這些的(老IE6)。
不過,符合最佳實踐的還是在對象過時時移除觀察者。來看下面的例子:
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. // 現在,當元素超出作用域之后, // 即使是不能很好處理循環的老瀏覽器也能將元素和點擊處理函數回收。
在使節點變成不可獲取之前不再需要調用 removeEventListener ,因為現代瀏覽器支持垃圾收集器可以探測這些循環并進行適當處理。
如果你利用 jQuery APIs(其他庫和框架也支持),它也可以在節點無效之前移除監聽器。這個庫也會確保沒有內存泄露發生,即使應用運行在老瀏覽器之下。
3:閉包JavaScript 開發的核心領域之一是閉包:內層函數可以訪問外層(封閉)函數的變量。 歸咎于 JavaScript 運行時的實現細節,可能發生下面這樣的內存泄露:
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 調用后,theThing 被賦值為一個對象,由一個大數組和一個新的閉包(someMethod)組成。還有,originalThing 被變量 unused 擁有的閉包所引用(值是上一次 replaceThing 調用所得到的變量 theThing )。要記住的是當一個閉包作用域被創建時,位于同一個父作用域內的其他閉包也共享這個作用域。
在這個案列中,為閉包 someMethod 創建的作用域被 unused 共享。即便 unused 從未使用,someMethod 可以通過位于 replaceThing 外層的 theThing 使用(例如,在全局中)。又因為 someMethod 與 unused 共享閉包作用域,unused 引用的 originalThing 被強制處于活躍狀態(在兩個閉包之間被共享的整個作用域)。這些妨礙了被收集。
在上述列子中,當 unused 引用了 originalThing 時,共享了為 someMethod 創建的作用域??梢酝ㄟ^ replaceThing 作用域外的 theThing 使用 someMethod,且不管其實 unused 從未使用。事實上 unused 引用了 originalThing 使其保持在活躍狀態,因為someMethod 與 unused 共享了閉包作用域。
所有的這些導致了相當大的內存泄露。你會看到在上述代碼一遍又一遍運行時內存使用量的激增。它不會在垃圾收集器運行時變小。一系列的閉包被創建(此例中根是變量 theThing),每一個閉包作用域都間接引用了大數組。
Meteor 團隊發現了這個問題,他們有一篇非常棒的文章詳細描述了這個問題。
4:外部DOM引用還有種情況是當開發人員把 DOM 節點儲存在數據結構里的時候。假設你想快速更新表格中某幾行的內容。如果把對每行的 DOM 引用存在字典中或數組中,就會存在對相同 DOM 元素的兩份引用:一份在 DOM 樹中一份在字典里。如果想移除這些行,你得記著要把這兩份引用都變成不可獲取的。
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. // 圖片是body的直接子元素 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. // 這時,全局elements對象仍有一個對#button元素的引用。換句話說,button元素 // 仍然在內存里,無法被垃圾收集器回收。 }
還有一個例外情況應該被考慮到,它出現在引用 DOM 樹的內部或葉節點時。如果你在代碼里保存了一個對表格單元(td 標簽)的引用,然后決定把表格從 DOM 中移除但保留對那個特別單元格的引用,就能預料到將會有大量的內存泄露。你可能認為垃圾收集器將釋放其他所有的東西除了那個單元格。但是,這將不會發生。因為這個單元格是表格的一個子節點,子節點保存了對它們父節點的引用,引用這一個單元格將會在內存里保存整個表格。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/106376.html
摘要:是如何工作的內存管理以及如何處理四種常見的內存泄漏原文譯者幾個禮拜之前我們開始一系列對于以及其本質工作原理的深入挖掘我們認為通過了解的構建方式以及它們是如何共同合作的,你就能夠寫出更好的代碼以及應用。 JavaScript是如何工作的:內存管理以及如何處理四種常見的內存泄漏 原文:How JavaScript works: memory management + how to han...
摘要:本文作為第三篇,將會討論另一個開發者容易忽視的重要主題內存管理。我們也會提供一些關于如何處理內存泄露的技巧。這是當前整型和雙精度的大小。然而,這是一組可以收集的內存空間的近似值。 本文轉載自:眾成翻譯譯者:Leslie Wang審校: 為之漫筆鏈接:http://www.zcfy.cc/article/4211原文:https://blog.sessionstack.com/how-j...
摘要:這是因為我們訪問了數組中不存在的數組元素它超過了最后一個實際分配到內存的數組元素字節,并且有可能會讀取或者覆寫的位。包含個元素的新數組由和數組元素所組成中的內存使用中使用分配的內存主要指的是內存讀寫。 原文請查閱這里,本文有進行刪減,文后增了些經驗總結。 本系列持續更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第三章。 我們將會討論日常使用中另一個被開發...
閱讀 717·2021-11-18 10:02
閱讀 3611·2021-09-02 10:21
閱讀 1753·2021-08-27 16:16
閱讀 2068·2019-08-30 15:56
閱讀 2394·2019-08-29 16:53
閱讀 1382·2019-08-29 11:18
閱讀 2964·2019-08-26 10:33
閱讀 2649·2019-08-23 18:34