JavaScript如何工做:內存管理+如何處理4個常見的內存泄漏

阿里雲最近在作活動,低至2折,有興趣能夠看看:
https://promotion.aliyun.com/...

爲了保證的可讀性,本文采用意譯而非直譯。javascript

本系列的第一篇文章簡單介紹了引擎、運行時間和堆棧的調用。第二篇文章研究了谷歌V8 JavaScript引擎的內部機制,並介紹了一些編寫JavaScript代碼的技巧。html

在這第三篇文章中,咱們將討論另外一個重要主題——內存管理,這是因爲平常使用的編程語言愈來愈成熟和複雜,開發人員容易忽視這一問題。咱們還將提供一些有關如何處理JavaScript中的內存泄漏的技巧,在SessionStack中遵循這些技巧,既能確保SessionStack 不會致使內存泄漏,也不會增長咱們集成的Web應用程序的內存消耗。前端

想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等着你!java

概述

像 C 這樣的編程語言,具備低級內存管理原語,如malloc()和free()。開發人員使用這些原語顯式地對操做系統的內存進行分配和釋放。git

而JavaScript在建立對象(對象、字符串等)時會爲它們分配內存,再也不使用對時會「自動」釋放內存,這個過程稱爲垃圾收集。這種看「自動」似釋放資源的的特性是形成混亂的根源,由於這給JavaScript(和其餘高級語言)開發人員帶來一種錯覺,覺得他們能夠不關心內存管理的錯誤印象,這是想法一個大錯誤。github

即便在使用高級語言時,開發人員也應該瞭解內存管理(或者至少懂得一些基礎知識)。有時候,自動內存管理存在一些問題(例如垃圾收集器中的bug或實現限制等),開發人員必須理解這些問題,以即可以正確地處理它們(或者找到一個適當的解決方案,以最小代價來維護代碼)。算法

內存的生命週期

不管使用哪一種編程語言,內存的生命週期都是同樣的:編程

圖片描述

這裏簡單介紹一下內存生命週期中的每個階段:segmentfault

  • 分配內存 —  內存是由操做系統分配的,它容許您的程序使用它。在低級語言(例如C語言)中,這是一個開發人員須要本身處理的顯式執行的操做。然而,在高級語言中,系統會自動爲你分配內在。
  • 使用內存 — 這是程序實際使用以前分配的內存,在代碼中使用分配的變量時,就會發生讀和寫操做。
  • 釋放內存 — 釋放全部再也不使用的內存,使之成爲自由內存,並能夠被重利用。與分配內存操做同樣,這一操做在低級語言中也是須要顯式地執行。

內存是什麼?

在介紹JavaScript中的內存以前,咱們將簡要討論內存是什麼以及它是如何工做的。數組

硬件層面上,計算機內存由大量的觸發器緩存的。每一個觸發器包含幾個晶體管,可以存儲一位,單個觸發器均可以經過惟一標識符尋址,所以咱們能夠讀取和覆蓋它們。所以,從概念上講,能夠把的整個計算機內存看做是一個能夠讀寫的巨大數組。

做爲人類,咱們並不擅長用比特來思考和計算,因此咱們把它們組織成更大的組,這些組一塊兒能夠用來表示數字。8位稱爲1字節。除了字節,還有字(有時是16位,有時是32位)。

不少東西都存儲在內存中:

  1. 程序使用的全部變量和其餘數據。
  2. 程序的代碼,包括操做系統的代碼。

編譯器和操做系統一塊兒爲你處理大部份內存管理,可是你仍是須要了解一下底層的狀況,對內在管理概念會有更深刻的瞭解。

在編譯代碼時,編譯器能夠檢查基本數據類型,並提早計算它們須要多少內存。而後將所需的大小分配給調用堆棧空間中的程序,分配這些變量的空間稱爲堆棧空間。由於當調用函數時,它們的內存將被添加到現有內存之上,當它們終止時,它們按照後進先出(LIFO)順序被移除。例如:

圖片描述

編譯器可以當即知道所需的內存:4 + 4×4 + 8 = 28字節。

這段代碼展現了整型和雙精度浮點型變量所佔內存的大小。可是大約20年前,整型變量一般佔2個字節,而雙精度浮點型變量佔4個字節。你的代碼不該該依賴於當前基本數據類型的大小。

編譯器將插入與操做系統交互的代碼,並申請存儲變量所需的堆棧字節數。

在上面的例子中,編譯器知道每一個變量的確切內存地址。事實上,每當咱們寫入變量 n 時,它就會在內部被轉換成相似「內存地址4127963」這樣的信息。

注意,若是咱們嘗試訪問 x[4],將訪問與m關聯的數據。這是由於訪問數組中一個不存在的元素(它比數組中最後一個實際分配的元素x[3]多4字節),可能最終讀取(或覆蓋)一些 m 位。這確定會對程序的其他部分產生不可預知的結果。

圖片描述

當函數調用其餘函數時,每一個函數在調用堆棧時得到本身的塊。它保存全部的局部變量,但也會有一個程序計數器來記住它在執行過程當中的位置。當函數完成時,它的內存塊將再次用於其餘地方。

動態分配

不幸的是,當編譯時不知道一個變量須要多少內存時,事情就有點複雜了。假設咱們想作以下的操做:

圖片描述

在編譯時,編譯器不知道數組須要使用多少內存,由於這是由用戶提供的值決定的。

所以,它不能爲堆棧上的變量分配空間。相反,咱們的程序須要在運行時顯式地向操做系統請求適當的空間,這個內存是從堆空間分配的。靜態內存分配和動態內存分配的區別總結以下表所示:

靜態內存分配 動態內存分配
大小必須在編譯時知道 大小不須要在編譯時知道
在編譯時執行 在運行時執行
分配給堆棧 分配給堆
FILO (先進後出) 沒有特定的分配順序

要徹底理解動態內存分配是如何工做的,須要在指針上花費更多的時間,這可能與本文的主題有太多的偏離,這裏就不太詳細介紹指針的相關的知識了。

在JavaScript中分配內存

如今將解釋第一步:如何在JavaScript中分配內存。

JavaScript爲讓開發人員免於手動處理內存分配的責任——JavaScript本身進行內存分配同時聲明值。

圖片描述

某些函數調用也會致使對象的內存分配:

圖片描述

方法能夠分配新的值或對象:

圖片描述

在JavaScript中使用內存

在JavaScript中使用分配的內存意味着在其中讀寫,這能夠經過讀取或寫入變量或對象屬性的值,或者將參數傳遞給函數來實現。

當內存再也不須要時進行釋放

大多數的內存管理問題都出如今這個階段

這裏最困難的地方是肯定什麼時候再也不須要分配的內存,它一般要求開發人員肯定程序中哪些地方再也不須要內存的並釋放它。

高級語言嵌入了一種稱爲垃圾收集器的機制,它的工做是跟蹤內存分配和使用,以便發現任什麼時候候一塊再也不須要已分配的內在。在這種狀況下,它將自動釋放這塊內存。

不幸的是,這個過程只是進行粗略估計,由於很難知道某塊內存是否真的須要 (不能經過算法來解決)。

大多數垃圾收集器經過收集再也不被訪問的內存來工做,例如,指向它的全部變量都超出了做用域。可是,這是能夠收集的內存空間集合的一個不足估計值,由於在內存位置的任何一點上,仍然可能有一個變量在做用域中指向它,可是它將永遠不會被再次訪問。

垃圾收集

因爲沒法肯定某些內存是否真的有用,所以,垃圾收集器想了一個辦法來解決這個問題。本節將解釋理解主要垃圾收集算法及其侷限性。

內存引用

垃圾收集算法主要依賴的是引用。

在內存管理上下文中,若是對象具備對另外一個對象的訪問權(能夠是隱式的,也能夠是顯式的),則稱對象引用另外一個對象。例如,JavaScript對象具備對其原型(隱式引用)和屬性值(顯式引用)的引用。

在此上下文中,「對象」的概念被擴展到比常規JavaScript對象更普遍的範圍,而且還包含函數範圍(或全局詞法做用域)。

詞法做用域定義瞭如何在嵌套函數中解析變量名:即便父函數已經返回,內部函數也包含父函數的做用

引用計數垃圾收集算法

這是最簡單的垃圾收集算法。若是沒有指向對象的引用,則認爲該對象是「垃圾可回收的」,以下代碼:

圖片描述

循環會產生問題

當涉及到循環時,會有一個限制。在下面的示例中,建立了兩個對象,兩個對象互相引用,從而建立了一個循環。在函數調用以後將超出做用域,所以它們其實是無用的,能夠被釋放。然而,引用計數算法認爲,因爲每一個對象至少被引用一次,因此它們都不能被垃圾收集。

圖片描述

圖片描述

標記-清除(Mark-and-sweep)算法

該算法可以判斷出某個對象是否能夠訪問,從而知道該對象是否有用,該算法由如下步驟組成:

  1. 垃圾收集器構建一個「根」列表,用於保存引用的全局變量。在JavaScript中,「window」對象是一個可做爲根節點的全局變量。
  2. 而後,算法檢查全部根及其子節點,並將它們標記爲活動的(這意味着它們不是垃圾)。任何根不能到達的地方都將被標記爲垃圾。
  3. 最後,垃圾收集器釋放全部未標記爲活動的內存塊,並將該內存返回給操做系統。

圖片描述

這個算法比上一個算法要好,由於「一個對象沒有被引用」就意味着這個對象沒法訪問。

截至2012年,全部現代瀏覽器都有標記-清除垃圾收集器。過去幾年在JavaScript垃圾收集(分代/增量/併發/並行垃圾收集)領域所作的全部改進都是對該算法(標記-清除)的實現改進,而不是對垃圾收集算法自己的改進,也不是它決定對象是否可訪問的目標。

在這篇文章中,你能夠更詳細地閱讀到有關跟蹤垃圾收集的詳細信息,同時還包括了標記-清除算法及其優化。

循環再也不是問題

在上面的第一個例子中,在函數調用返回後,這兩個對象再也不被從全局對象中可訪問的對象引用。所以,垃圾收集器將發現它們不可訪問。

圖片描述

儘管對象之間存在引用,但它們對於根節點來講是不可達的。

垃圾收集器的反直觀行爲

儘管垃圾收集器很方便,但它們有一套本身的折衷方案,其中之一就是非決定論,換句話說,GC是不可預測的,你沒法真正判斷什麼時候進行垃圾收集。這意味着在某些狀況下,程序會使用更多的內存,這其實是必需的。在對速度特別敏感的應用程序中,可能會很明顯的感覺到短期的停頓。若是沒有分配內存,則大多數GC將處於空閒狀態。看看如下場景:

  1. 分配一組至關大的內在。
  2. 這些元素中的大多數(或所有)被標記爲不可訪問(假設引用指向一個再也不須要的緩存)。
  3. 再也不進一步的分配

在這些場景中,大多數GCs 將再也不繼續收集。換句話說,即便有不可訪問的引用可供收集,收集器也不會聲明這些引用。這些並非嚴格意義上的泄漏,但仍然會致使比一般更高的內存使用。

內存泄漏是什麼?

從本質上說,內存泄漏能夠定義爲:再也不被應用程序所須要的內存,出於某種緣由,它不會返回到操做系統或空閒內存池中。

圖片描述

編程語言支持不一樣的內存管理方式。然而,是否使用某一塊內存其實是一個沒法肯定的問題。換句話說,只有開發人員才能明確一塊內存是否能夠返回到操做系統。

某些編程語言爲開發人員提供了幫助,另外一些則指望開發人員能清楚地瞭解內存什麼時候再也不被使用。維基百科上有一些有關人工自動內存管理的很不錯的文章。

四種常見的內存泄漏

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本身調用,它指向全局對象(window),而不是未定義。
foo();
能夠在JavaScript文件的開頭經過添加「use strict」來避免這一切,它將開啓一個更嚴格的JavaScript解析模式,以防止意外建立全局變量。

儘管咱們討論的是未知的全局變量,但仍然有不少代碼充斥着顯式的全局變量。根據定義,這些是不可收集的(除非被指定爲空或從新分配)。用於臨時存儲和處理大量信息的全局變量特別使人擔心。若是你必須使用一個全局變量來存儲大量數據,那麼請確保將其指定爲null,或者在完成後將其從新賦值。

2.被遺忘的定時器和回調

setInterval爲例,由於它在JavaScript中常用。

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每五秒會執行一次

上面的代碼片斷演示了使用定時器時引用再也不須要的節點或數據。

renderer表示的對象可能會在將來的某個時間點被刪除,從而致使內部處理程序中的一整塊代碼都變得再也不須要。可是,因爲定時器仍然是活動的,因此,處理程序不能被收集,而且其依賴項也沒法被收集。這意味着,存儲着大量數據的serverData也不能被收集。

在使用觀察者時,您須要確保在使用完它們以後進行顯式調用來刪除它們(要麼再也不須要觀察者,要麼對象將變得不可訪問)。

做爲開發者時,須要確保在完成它們以後進行顯式刪除它們(或者對象將沒法訪問)。

在過去,一些瀏覽器沒法處理這些狀況(很好的IE6)。幸運的是,如今大多數現代瀏覽器會爲幫你完成這項工做:一旦觀察到的對象變得不可訪問,即便忘記刪除偵聽器,它們也會自動收集觀察者處理程序。然而,咱們仍是應該在對象被處理以前顯式地刪除這些觀察者。例如:

圖片描述

現在,如今的瀏覽器(包括IE和Edge)使用現代的垃圾回收算法,能夠當即發現並處理這些循環引用。換句話說,在一個節點刪除以前也不是必需要調用removeEventListener。

一些框架或庫,好比JQuery,會在處置節點以前自動刪除監聽器(在使用它們特定的API的時候)。這是由庫內部的機制實現的,可以確保不發生內存泄漏,即便在有問題的瀏覽器下運行也能這樣,好比……IE 6。

3.閉包

閉包是javascript開發的一個關鍵方面,一個內部函數使用了外部(封閉)函數的變量。因爲JavaScript運行的細節,它可能如下面的方式形成內存泄漏:

圖片描述

這段代碼作了一件事:每次調用replaceThing的時候,theThing都會獲得一個包含一個大數組和一個新閉包(someMethod)的新對象。同時,變量unused指向一個引用了`originalThing的閉包。

是否是有點困惑了? 重要的是,一旦具備相同父做用域的多個閉包的做用域被建立,則這個做用域就能夠被共享。

在這種狀況下,爲閉包someMethod而建立的做用域能夠被unused共享的。unused內部存在一個對originalThing的引用。即便unused從未使用過,someMethod也能夠在replaceThing的做用域以外(例如在全局範圍內)經過theThing來被調用。

因爲someMethod共享了unused閉包的做用域,那麼unused引用包含的originalThing會迫使它保持活動狀態(兩個閉包之間的整個共享做用域)。這阻止了它被收集。

當這段代碼重複運行時,能夠觀察到內存使用在穩定增加,當GC運行後,內存使用也不會變小。從本質上說,在運行過程當中建立了一個閉包鏈表(它的根是以變量theThing的形式存在),而且每一個閉包的做用域都間接引用了了一個大數組,這形成了至關大的內存泄漏。

4.脫離DOM的引用

有時,將DOM節點存儲在數據結構中可能會頗有用。假設你但願快速地更新表中的幾行內容,那麼你能夠在一個字典或數組中保存每一個DOM行的引用。這樣,同一個DOM元素就存在兩個引用:一個在DOM樹中,另外一個則在字典中。若是在未來的某個時候你決定刪除這些行,那麼你須要將這兩個引用都設置爲不可訪問。

圖片描述

在引用 DOM 樹中的內部節點或葉節點時,還須要考慮另一個問題。若是在代碼中保留對錶單元格的引用(<td>標記),並決定從 DOM 中刪除表,同時保留對該特定單元格的引用,那麼可能會出現內存泄漏。

你可能認爲垃圾收集器將釋放除該單元格以外的全部內容。然而,事實並不是如此,因爲單元格是表的一個子節點,而子節點保存對父節點的引用,因此對錶單元格的這個引用將使整個表保持在內存中,因此在移除有被引用的節點時候要移除其子節點。

編輯中可能存在的bug無法實時知道,過後爲了解決這些bug,花了大量的時間進行log 調試,這邊順便給你們推薦一個好用的BUG監控工具Fundebug

原文:https://blog.sessionstack.com...

你的點贊是我持續分享好東西的動力,歡迎點贊!

交流

乾貨系列文章彙總以下,以爲不錯點個Star,歡迎 加羣 互相學習。

https://github.com/qq44924588...

我是小智,公衆號「大遷世界」做者,對前端技術保持學習愛好者。我會常常分享本身所學所看的乾貨,在進階的路上,共勉!

關注公衆號,後臺回覆福利,便可看到福利,你懂的。

clipboard.png

相關文章
相關標籤/搜索