原文地址: How JavaScript works: memory management + how to handle 4 common memory leaks
本文永久鏈接: https://didiheng.com/front/2019-04-01.html
有部分的刪減和修改,不過大部分是參照原文來的,翻譯的目的主要是弄清JavaScript的垃圾回收機制,覺得有問題的歡迎指正。
#JavaScript 中的內存分配
現在我們將解釋第一步(分配內存)是如何在JavaScript中工作的。
JavaScript 減輕了開發人員處理內存分配的責任 - JavaScript自己執行了內存分配,同時聲明了值。
var n = 374; // 為number分配內存 var s = 'sessionstack'; // 為string分配內存 var o = { a: 1, b: null }; //為對象及屬性分配內存 function f(a) { return a + 3; } // 為函數分配內存 // 函數表達式分配內存 someElement.addEventListener('click', function() { someElement.style.backgroundColor = 'blue'; }, false);
#在 JavaScript 中使用內存
基本上在 JavaScript 中使用分配的內存,意味着在其中讀寫。
這可以通過讀取或寫入變量或對象屬性的值,甚至傳遞一個變量給函數來完成。
#垃圾回收機制
由於發現一些內存是否“不再需要”事實上是不可判定的,所以垃圾收集在實施一般問題解決方案時具有局限性。下面將解釋主要垃圾收集算法及其局限性的基本概念。
#內存引用
如果一個對象可以訪問另一個對象(可以是隱式的或顯式的),則稱該對象引用另一個對象。例如, 一個 JavaScript 引用了它的 prototype (隱式引用)和它的屬性值(顯式引用)。
在這種情況下,“對象”的概念擴展到比普通JavaScript對象更廣泛的范圍,並包含函數作用域(或全局詞法范圍)。
詞法作用域定義了變量名如何在嵌套函數中解析:即使父函數已經返回,內部函數仍包含父函數的作用域。
#引用計數垃圾收集
這是最簡單的垃圾收集算法。 如果有零個指向它的引用,則該對象被認為是“可垃圾回收的”。 請看下面的代碼:
var o1 = { o2: { x: 1 } }; // 兩個對象被創建。 // ‘o1’對象引用‘o2’對象作為其屬性。 // 不可以被垃圾收集 var o3 = o1; // ‘o3’變量是第二個引用‘o1‘指向的對象的變量. o1 = 1; // 現在,在‘o1’中的對象只有一個引用,由‘o3’變量表示 var o4 = o3.o2; // 對象的‘o2’屬性的引用. // 此對象現在有兩個引用:一個作為屬性、另一個作為’o4‘變量 o3 = '374'; // 原來在“o1”中的對象現在為零,對它的引用可以垃圾收集。 // 但是,它的‘o2’屬性存在,由‘o4’變量引用,因此不能被釋放。 o4 = null; // ‘o1’中最初對象的‘o2’屬性對它的引用為零。它可以被垃圾收集。
#周期產生問題
在周期循環中有一個限制。在下面的例子中,兩個對象被創建並相互引用,這就創建了一個循環。在函數調用之后,它們會超出界限,所以它們實際上是無用的,並且可以被釋放。然而,引用計數算法認為,由於兩個對象中的每一個都被至少引用了一次,所以兩者都不能被垃圾收集。
function f() { var o1 = {}; var o2 = {}; o1.p = o2; // ‘o1’ 應用 ‘02’ o1 references o2 o2.p = o1; // ‘o2’ 引用 ‘o2’ . 一個循環被創建 } f();
#標記和掃描算法
為了確定是否需要某個對象,本算法判斷該對象是否可訪問。
標記和掃描算法經過這 3 個步驟:
1.根節點:一般來說,根是代碼中引用的全局變量。例如,在 JavaScript 中,可以充當根節點的全局變量是“window”對象。Node.js 中的全局對象被稱為“global”。完整的根節點列表由垃圾收集器構建。
2.然后算法檢查所有根節點和他們的子節點並且把他們標記為活躍的(意思是他們不是垃圾)。任何根節點不能訪問的變量將被標記為垃圾。
3.最后,垃圾收集器釋放所有未被標記為活躍的內存塊,並將這些內存返回給操作系統。
標記和掃描算法行為的可視化。
因為“一個對象有零引用”導致該對象不可達,所以這個算法比前一個算法更好。我們在周期中看到的情形恰巧相反,是不正確的。 截至 2012 年,所有現代瀏覽器都內置了標記掃描式的垃圾回收器。去年在 JavaScript 垃圾收集(通用/增量/並發/並行垃圾收集)領域中所做的所有改進都是基於這種算法(標記和掃描)的實現改進,但這不是對垃圾收集算法本身的改進,也不是對判斷一個對象是否可訪問這個目標的改進。
#周期不再是問題
在上面的例子中,函數調用返回后,兩個對象不再被全局對象中的變量引用。因此,垃圾收集器會認為它們不可訪問。
即使兩個對象之間有引用,根節點它們不在被訪問。
#統計垃圾收集器的直觀行為
盡管垃圾收集器很方便,但他們也有自己的一套策略。其中之一是不確定性。換句話說,GC(垃圾收集器)是不可預測的。你不能確定一個垃圾收集器何時會執行收集。這意味着在某些情況下,程序其實需要更多的內存。其他情況下,在特別敏感的應用程序中,短暫和卡頓可能是明顯的。盡管不確定性意味着不能確定一個垃圾收集器何時執行收集,大多數 GC 共享分配中的垃圾收集通用模式。如果沒有執行分配,大多數 GC 保持空閑狀態。考慮如下場景:
1.大量的分配被執行。
2.大多數這些元素(或全部)被標記為不可訪問(假設我們廢除一個指向我們不再需要的緩存的引用)。
3.沒有執行更深的內存分配。
在這種情況下,大多數 GC 不會運行任何更深層次的收集。換句話說,即使存在引用可用於收集,收集器也不會收集這些引用。這些並不是嚴格的泄漏,但仍會導致高於日常的內存使用率。
#什么是內存泄漏?
內存泄漏是應用程序過去使用,但不再需要的尚未返回到操作系統或可用內存池的內存片段。由於沒有被釋放而導致的,它將可能引起程序的卡頓和崩潰。
#JavaScript 常見的四種內存泄漏
#1:全局變量
function foo(arg) { bar = "some text"; // window.bar = "some text"; }
假設 bar 的目的只是引用 foo 函數中的一個變量。然而不使用 var 來聲明它,就會創建一個冗余的全局變量。
你可以通過在 JavaScript 文件的開頭添加 'use strict'; 來避免這些后果,這將開啟一種更嚴格的 JavaScript 解析模式,從而防止意外創建全局變量。
意外的全局變量當然是個問題,然而更常出現的情況是,你的代碼會受到顯式的全局變量的影響,而這些全局變量無法通過垃圾收集器收集。需要特別注意用於臨時存儲和處理大量信息的全局變量。如果你必須使用全局變量來存儲數據,當你這樣做的時候,要保證一旦完成使用就把他們賦值為 null 或重新賦值 。
#2:被忘記的定時器或者回調函數
我們以經常在 JavaScript 中使用的 setInterval 為例。
var serverData = loadData(); setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); } }, 5000); //每5秒執行一次.
上面的代碼片段顯示了使用定時器引用節點或無用數據的后果。它既不會被收集,也不會被釋放。無法被垃圾收集器收集,頻繁的被調用,占用內存。
而正確的使用方法是,確保一旦依賴於它們的事件已經處理完成,就通過明確的調用來刪除它們。
#3:閉包
閉包是JavaScript開發的一個關鍵點:一個內部函數可以訪問外部(封閉)函數的變量。
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) // originalThing 被引用 console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("message"); } }; }; setInterval(replaceThing, 1000);
一旦調用了 replaceThing 函數,theThing 就得到一個新的對象,它由一個大數組和一個新的閉包(someMethod)組成。然而 originalThing 被一個由 unused 變量(這是從前一次調用 replaceThing 變量的 Thing 變量)所持有的閉包所引用。需要記住的是一旦為同一個父作用域內的閉包創建作用域,作用域將被共享。
在個例子中,someMethod 創建的作用域與 unused 共享。unused 包含一個關於 originalThing 的引用。即使 unused 從未被引用過,someMethod 也可以通過 replaceThing 作用域之外的 theThing 來使用它(例如全局的某個地方)。由於 someMethod 與 unused 共享閉包范圍,unused 指向 originalThing 的引用強制它保持活動狀態(兩個閉包之間的整個共享范圍)。這阻止了它們的垃圾收集。
在上面的例子中,為閉包 someMethod 創建的作用域與 unused 共享,而 unused 又引用 originalThing。someMethod 可以通過 replaceThing 范圍之外的 theThing 來引用,盡管 unused 從來沒有被引用過。事實上,unused 對 originalThing 的引用要求它保持活躍,因為 someMethod 與 unused 的共享封閉范圍。
所有這些都可能導致大量的內存泄漏。當上面的代碼片段一遍又一遍地運行時,您可以預期到內存使用率的上升。當垃圾收集器運行時,其大小不會縮小。一個閉包鏈被創建(在例子中它的根就是 theThing 變量),並且每個閉包作用域都包含對大數組的間接引用。
#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() { // image 元素是body的直接子元素。 document.body.removeChild(document.getElementById('image')); // 我們仍然可以在全局元素對象中引用button。換句話說,button元素仍在內存中,無法由GC收集 }
在涉及 DOM 樹內的內部節點或子節點時,還有一個額外的因素需要考慮。如果你在代碼中保留對table表格單元格(td 標記)的引用,並決定從 DOM 中刪除該table表格但保留對該特定單元格td的引用,則可以預見到嚴重的內存泄漏。你可能會認為垃圾收集器會釋放除了那個單元格td之外的所有東西。但情況並非如此。由於單元格td是table表格的子節點,並且子節點保持對父節點的引用,所以對table表格對單元格td的這種單引用會把整個table表格保存在內存中。
我們在 SessionStack 嘗試遵循這些最佳實踐,編寫正確處理內存分配的代碼,原因如下:
一旦將 SessionStack 集成到你的生產環境的 Web 應用程序中,它就會開始記錄所有的事情:所有的 DOM 更改,用戶交互,JavaScript 異常,堆棧跟蹤,失敗網絡請求,調試消息等。
通過 SessionStack web 應用程序中的問題,並查看所有的用戶行為。所有這些都必須在您的網絡應用程序沒有性能影響的情況下進行。
由於用戶可以重新加載頁面或導航你的應用程序,所有的觀察者,攔截器,變量分配等都必須正確處理,這樣它們才不會導致任何內存泄漏,也不會增加我們正在整合的Web應用程序的內存消耗。
這里有一個免費的計划所以你可以試試看.
#Resources
How JavaScript works: memory management + how to handle 4 common memory leaks
ps: 順便推一下自己的個人公眾號:Yopai,有興趣的可以關注,每周不定期更新,分享可以增加世界的快樂