摘要:是如何工作的內存管理以及如何處理四種常見的內存泄漏原文譯者幾個禮拜之前我們開始一系列對于以及其本質工作原理的深入挖掘我們認為通過了解的構建方式以及它們是如何共同合作的,你就能夠寫出更好的代碼以及應用。
JavaScript是如何工作的:內存管理以及如何處理四種常見的內存泄漏
原文:How JavaScript works: memory management + how to handle 4 common memory leaks
譯者:neal1991
welcome to star my articles-translator , providing you advanced articles translation. Any suggestion, please issue or contact me
LICENSE: MIT
幾個禮拜之前我們開始一系列對于JavaScript以及其本質工作原理的深入挖掘:我們認為通過了解JavaScript的構建方式以及它們是如何共同合作的,你就能夠寫出更好的代碼以及應用。
這個系列的第一篇博客專注于介紹對于引擎,運行時以及調用棧的概述(譯者注:第一篇博客翻譯版)。第二篇博客近距離地檢測了Google V8 引擎的內部并且提供了一些如何寫出更好的JavaScript代碼的建議。
在第三篇博客中,我們將會討論另外一個關鍵的話題。這個話題由于隨著編程語言的逐漸成熟和復雜化,越來越被開發者所忽視,這個話題就是在日常工作中使用到的——內存管理。我們還將提供一些有關如何處理我們在SessionStack中的JavaScript中的內存泄漏的建議,因為我們需要確保SessionStack不會導致內存泄漏或者增加我們集成的Web應用程序的內存消耗。
概述語言,比如C,具有低層次的內存管理方法,比如malloc()以及free()。開發者利用這些方法精確地為操作系統分配以及釋放內存。
同時,JavaScript會在創建一些變量(對象,字符串等等)的時候分配內存,并且會在這些不被使用之后“自動地”釋放這些內存,這個過程被稱為垃圾收集。這個看起來“自動化的”特性其實就是產生誤解的原因,并且給JavaScript(以及其他高層次語言)開發者一個假象,他們不需要關心內存管理。大錯特錯。
即使是使用高層次語言,開發者應該對于內存管理有一定的理解(或者最基本的理解)。有時候自動的內存管理會存在一些問題(比如一些bug或者垃圾收集器的一些限制等等),對于這些開發者必須能夠理解從而能夠合適地處理(或者使用最小的代價以及代碼債務去繞過這個問題)。
內存生命周期不管你在使用什么編程語言,內存的生命周期基本上都是一樣的:
下面是對于周期中每一步所發生的情況的概述:
分配內存——操作系統為你的程序分配內存并且允許其使用。在低層次語言中(比如C),這正是開發者應該處理的操作。在高層次的語言,然而,就由語言幫你實現了。
使用內存——當你的程序確實在使用之前分配的內存的階段。當你在使用你代碼里面分配的變量的時候會發生讀以及寫操作。
釋放內存——這個階段就是釋放你不再需要的內存,從而這些內存被釋放并且能夠再次被使用。和分配內存操作一樣,這在低層次的語言也是開發者需要明確的操作。
對于調用棧以及內存堆有一個快速的概念認識,你可以閱讀我們關于這個話題的第一篇博客。
什么是內存?在我們講述JavaScript內存之前,我們將簡要地討論一下內存是什么以及它們是如何在 nutshell 中工作的。
在硬件層次上,計算機內存由大量的 寄存器 組成。每一個寄存器都包含一些晶體管并且能夠存儲一比特。多帶帶的寄存器可以通過獨特的標識符去訪問,因此我們能夠讀取以及重寫它們。因此,從概念上來說,我們可以認為我們的整個計算機內存就是一個我們能夠讀寫的大型比特數組。
因為作為人類,我們不擅長直接基于比特進行思考以及算術,我們將它們組織成大規模群組,它們在一起可以代表一個數字。8個比特稱為一個字節。除了字節,還有詞(有時候是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 字節
那就是它如何對于現有的整形以及雙浮點型工作。大約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 將開發者從內存分配的處理中解放出來——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
能夠分配新的值或者對象的方法:
var s1 = "sessionstack"; var s2 = s1.substr(0, 3); // s2 is a new string // Since strings are immutable, // JavaScript may decide to not allocate memory, // but just store the [0, 3] range. 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在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 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.循環在產生問題
當遇到循環的時候就會有一個限制。在下面的實例之中,創建兩個對象,并且互相引用,因此就會產生一個循環。當函數調用結束之后它們會走出作用域之外,因此它們就沒什么用并且可以被釋放。但是,基于引用計數的算法認為這兩個對象都會被至少引用一次,所以它倆都不會被垃圾收集器收集。
function f() { var o1 = {}; var o2 = {}; o1.p = o2; // o1 references o2 o2.p = o1; // o2 references o1. This creates a cycle. } f();標記-清除算法
為了決定哪個對象是需要的,算法會決定是否這個對象是可訪問的。
這個算法由以下步驟組成:
這個垃圾收集器構建一個“roots”列表。Root是全局變量,被代碼中的引用所保存。在 JavaScript中,“window”就是這樣的作為root的全局變量的例子。
所有的root都會被監測并且被標志成活躍的(比如不是垃圾)。所有的子代也會遞歸地被監測。所有能夠由root訪問的一切都不會被認為是垃圾。
所有不再被標志成活躍的內存塊都被認為是垃圾。這個收集器現在就可以釋放這些內存并將它們返還給操作系統。
這個算法要優于之前的因為“一個具有0引用的對象”可以讓一個對象不能夠再被訪問。但是相反的卻不一定成立,比如我們遇到循環的時候。
在2012年,所有的現代瀏覽器都使用標記-清除垃圾收集器。過去幾年,JavaScript垃圾收集(代數/增量/并行/并行垃圾收集)領域的所有改進都是對該算法(標記和掃描)的實現進行了改進,但并沒有對垃圾收集算法本身的改進, 其目標是確定一個對象是否可達。
在這篇文章中,你可以得到更多關于垃圾收集追蹤并且也覆蓋到了關于標記-清除算法的優化。
循環不再是一個問題在上述的第一個例子中,在函數調用返回之后,這兩個對象不能夠被全局對象所訪問。因此,垃圾收集器就會發現它們不能夠被訪問了。
即使在這兩個對象之間存在著引用,它們再也不能從root訪問了。
列舉垃圾收集器的直觀行為雖然垃圾收集器很方便,但它們自己也有自己的代價。 其中一個是非確定論。 換句話說,GC是不可預測的。 你不能真正地告訴你什么時候會收集。 這意味著在某些情況下,程序會使用實際需要的更多內存。 在其他情況下,特別敏感的應用程序可能會引起短暫暫停。 雖然非確定性意味著在執行集合時無法確定,但大多數GC實現共享在分配期間執行收集遍歷的常見模式。 如果沒有執行分配,大多數GC保持空閑狀態。 考慮以下情況:
執行相當大的一組分配。
這些元素中的大多數(或全部)被標記為不可訪問(假設我們將指向我們不再需要的緩存的引用置空)。
不再執行分配。
在這種情況下,大多數GC不會再運行收集處理。換句話說,即使存在對于收集器來說不可訪問的引用,它們也不會被收集器所認領。嚴格意義來說這并不是泄露,但是依然會導致比平常更多的內存使用。
什么是內存泄露?實質上,內存泄漏可以被定義為應用程序不再需要的內存,但是由于某些原因不會返回到操作系統或可用內存池。
編程語言有支持管理內存的不同方法。 然而,某塊內存是否被使用實際上是一個不可判定的問題。 換句話說,只有開發人員可以清楚一個內存是否可以返回到操作系統。
某些編程語言提供了幫助開發者執行此操作的功能。其他的則期望開發人員能夠完全明確何時使用一塊內存。 維基百科有關于手動和自動內存管理的好文章。
四種常見的JavaScript泄露 1: 全局變量JavaScript 使用一種有趣的方式處理未聲明的變量:一個未聲明變量的引用會在全局對象內部產生一個新的變量。在瀏覽器的情況,這個全局變量就會是window。換句話說:
function foo(arg) { bar = "some text"; }
等同于:
function foo(arg) { window.bar = "some text"; }
如果bar被期望僅僅在foo函數作用域內保持對變量的引用,并且你忘記使用var去聲明它,一個意想不到的全局變量就產生了。
在這個例子中,泄露就僅僅是一個字符串并不會帶來太多危害,但是它可能會變得更糟。
另外一種可能產生意外的全局變量的方式是:
function foo() { this.var1 = "potential accidental global"; } // Foo called on its own, this points to the global object (window) // rather than being undefined. foo();
為了阻止這些錯誤的發生,可以在js文件頭部添加"use strict"。這將會使用嚴格模式來解析 JavaScript 從而阻止意外的全局變量。了解更多關于JavaScript執行的模式。
即使我們討論了未預期的全局變量,但仍然有很多代碼用顯式的全局變量填充。 這些定義是不可收集的(除非分配為null或重新分配)。 特別是,用于臨時存儲和處理大量信息的全局變量值得關注。 如果你必須使用全局變量來存儲大量數據,請確保在完成之后將其分配為null或重新分配。
2: 被遺忘的計時器和回調setInterval 在 JavaScript 中是經常被使用的。
大多數提供觀察者和其他模式的回調函數庫都會在調用自己的實例變得無法訪問之后對其任何引用也設置為不可訪問。 但是在setInterval的情況下,這樣的代碼很常見:
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所代表的對象在未來可能被移除,讓部分interval 處理器中代碼變得不再被需要。然而,這個處理器不能夠被收集因為interval依然活躍的(這個interval需要被停止從而表面這種情況)。如果這個interval處理器不能夠被收集,那么它的依賴也不能夠被收集。這意味這存儲大量數據的severData也不能夠被收集。
在這種觀察者的情況下,做出準確的調用從而在不需要它們的時候立即將其移除是非常重要的(或者相關的對象被置為不可訪問的)。
過去,以前特別重要的是某些瀏覽器(好的老IE 6)無法管理好循環引用(有關更多信息,請參見下文)。 如今,大多數瀏覽器一旦觀察到的對象變得無法訪問,就能收集觀察者處理器,即使偵聽器沒有被明確刪除。 但是,在處理對象之前,明確刪除這些觀察者仍然是一個很好的做法。 例如:
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.
當今,現在瀏覽器(報錯IE和Edge)都使用了現代的垃圾收集算法,其能夠檢測到這些循環并且進行適宜的處理。換句話說,再也不是嚴格需要在將節點置為不可訪問之前調用removeEventListener 。
框架和庫(如jQuery)在處理節點之前(在為其使用特定的API時)會刪除偵聽器。 這是由庫內部處理的,這也確保沒有泄漏,即使在有問題的瀏覽器下運行,如...是的,IE 6。
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)。同時,unused 會保持一個指向originalThing引用的閉包(從上一個調用的theThing到replaceThing)。可能已經很迷惑了,是不是?重要的事情是一旦在相同的父級作用域為閉包產生作用域,這個作用域就會被共享。
在這種情況下,為someMethod閉包產生的作用域就會被unused 所共享。unused 具有對于originaThing的引用。即使 unused 不再被使用,someMethod依然可以通過replaceThing作用域之外的theThing來使用。并且由于somethod和unused 共享閉包作用域,unused指向originalThing的引用強迫其保持活躍(兩個閉包之間的整個共享作用域)。這將會阻止垃圾手機。
當這個代碼段重復運行時,可以觀察到內存使用量的穩定增長。 當GC運行時,這不會變小。 實質上,創建了一個關閉的鏈接列表(其root以TheThing變量的形式),并且這些閉包的范圍中的每一個都對大數組進行間接引用,導致相當大的泄漏。
這個問題由Meteor團隊發現,他們有一篇很好的文章,詳細描述了這個問題。
4: DOM 之外的引用有時將DOM節點存儲在數據結構中可能是有用的。 假設要快速更新表中的幾行內容。 存儲對字典或數組中每個DOM行的引用可能是有意義的。 當發生這種情況時,會保留對同一DOM元素的兩個引用:一個在DOM樹中,另一個在字典中。 如果將來某個時候您決定刪除這些行,則需要使兩個引用置為不可訪問。
var elements = { button: document.getElementById("button"), image: document.getElementById("image") }; function doStuff() { 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代碼中保留對表格特定單元格(
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/91912.html
摘要:這是因為我們訪問了數組中不存在的數組元素它超過了最后一個實際分配到內存的數組元素字節,并且有可能會讀取或者覆寫的位。包含個元素的新數組由和數組元素所組成中的內存使用中使用分配的內存主要指的是內存讀寫。 原文請查閱這里,本文有進行刪減,文后增了些經驗總結。 本系列持續更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第三章。 我們將會討論日常使用中另一個被開發...
摘要:本文作為第三篇,將會討論另一個開發者容易忽視的重要主題內存管理。我們也會提供一些關于如何處理內存泄露的技巧。這是當前整型和雙精度的大小。然而,這是一組可以收集的內存空間的近似值。 本文轉載自:眾成翻譯譯者:Leslie Wang審校: 為之漫筆鏈接:http://www.zcfy.cc/article/4211原文:https://blog.sessionstack.com/how-j...
摘要:本系列的第一篇文章簡單介紹了引擎運行時間和堆棧的調用。編譯器將插入與操作系統交互的代碼,并申請存儲變量所需的堆棧字節數。當函數調用其他函數時,每個函數在調用堆棧時獲得自己的塊。因此,它不能為堆棧上的變量分配空間。 本系列的第一篇文章簡單介紹了引擎、運行時間和堆棧的調用。第二篇文章研究了谷歌V8 JavaScript引擎的內部機制,并介紹了一些編寫JavaScript代碼的技巧。 在這第...
摘要:本文將會討論中的內存泄漏以及如何處理,方便大家在使用編碼時,更好的應對內存泄漏帶來的問題。當內存不再需要時進行釋放大部分內存泄漏問題都是在這個階段產生的,這個階段最難的問題就是確定何時不再需要已分配的內存。中的相同對象稱為全局。 隨著現在的編程語言功能越來越成熟、復雜,內存管理也容易被大家忽略。本文將會討論JavaScript中的內存泄漏以及如何處理,方便大家在使用JavaScript...
閱讀 2039·2023-04-25 23:30
閱讀 1462·2021-11-24 10:18
閱讀 3098·2021-10-09 09:54
閱讀 2024·2021-10-08 10:05
閱讀 3449·2021-09-23 11:21
閱讀 3170·2019-08-30 15:52
閱讀 1569·2019-08-30 13:05
閱讀 1068·2019-08-30 13:02