首頁  >  文章  >  web前端  >  介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

coldplay.xixi
coldplay.xixi轉載
2020-12-09 17:12:492603瀏覽

javascript欄位將討論另一個重要主題,記憶體管理

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

##相關免費學習推薦:javascript#(影片)

我們將討論另一個重要主題——記憶體管理,這是由於日常使用的程式語言越來越成熟和複雜,開發人員容易忽略這個問題。我們還將提供一些有關如何處理JavaScript中的記憶體洩漏的技巧,在SessionStack中遵循這些技巧,既能確保SessionStack 不會導致記憶體洩漏,也不會增加我們整合的Web應用程式的記憶體消耗。


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

概述

像 C 這樣的程式語言,具有低階記憶體管理原語,如malloc()和free()。開發人員使用這些原語明確地對作業系統的記憶體進行分配和釋放。

而JavaScript在創建物件(物件、字串等)時會為它們分配內存,不再使用對時會「自動」釋放內存,這個過程稱為垃圾收集。這種看「自動」似釋放資源的特性是造成混亂的根源,因為這給JavaScript(和其他高階語言)開發人員帶來一種錯覺,以為他們可以不關心記憶體管理的錯誤印象,這是想法一個大錯誤。

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

記憶體的生命週期

無論使用哪一種程式語言,記憶體的生命週期都是一樣的:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏##這裡簡單介紹一下記憶體生命週期中的每一個階段:

    分配記憶體
  • —  記憶體是由作業系統分配的,它允許您的程式使用它。在低階語言(例如C語言)中,這是一個開發人員需要自己處理的明確執行的操作。然而,在高階語言中,系統會自動為你分配內在。
  • 使用記憶體
  • — 這是程式實際使用先前分配的內存,在程式碼中使用分配的變數時,就會發生讀取和寫入操作。
  • 釋放記憶體
  • — 釋放所有不再使用的記憶體,使之成為自由記憶體,並且可以被重複使用。與分配記憶體操作一樣,這一操作在低階語言中也是需要明確地執行。
  • 記憶體是什麼?

在介紹JavaScript中的記憶體之前,我們將簡要討論記憶體是什麼以及它是如何運作的。

硬體層面上,電腦記憶體由大量的觸發器快取的。每個觸發器包含幾個晶體管,能夠儲存一位,單一觸發器都可以透過唯一標識符定址,因此我們可以讀取和覆寫它們。因此,從概念上講,可以把的整個電腦記憶體看作是一個可以讀寫的巨大數組。

身為人類,我們並不擅長用位元來思考和計算,所以我們把它們組織成更大的群組,這些群組一起可以用來表示數字。 8位元稱為1位元組。除了字節,還有字(有時是16位,有時是32位)。

很多東西都儲存在記憶體中:

程式使用的所有變數和其他資料。
  1. 程式的程式碼,包含作業系統的程式碼。
  2. 編譯器和作業系統一起為你處理大部分記憶體管理,但你還是需要了解底層的情況,對內在管理概念會有更深入的了解。

在編譯程式碼時,編譯器可以檢查基本資料類型,並提前計算它們需要多少記憶體。然後將所需的大小分配給呼叫堆疊空間中的程序,分配這些變數的空間稱為堆疊空間。因為當呼叫函數時,它們的記憶體將被添加到現有記憶體之上,當它們終止時,它們按照後進先出(LIFO)順序被移除。例如:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏編譯器能夠立即知道所需的記憶體:4 4×4 8 = 28位元組。

這段程式碼展示了整數和雙精確度浮點型變數所佔記憶體的大小。但是大約20年前,整數變數通常佔2個位元組,而雙精確度浮點型變數佔4個位元組。你的程式碼不應該依賴目前基本資料類型的大小。

編譯器將插入與作業系統互動的程式碼,並申請儲存變數所需的堆疊位元組數。

在上面的例子中,編譯器知道每個變數的確切記憶體位址。事實上,每當我們寫入變數 n 時,它就會在內部被轉換成類似「記憶體位址4127963」這樣的資訊。

注意,如果我們嘗試存取 x[4],將存取與m關聯的資料。這是因為存取數組中一個不存在的元素(它比數組中最後一個實際分配的元素x[3]多4位元組),可能最終讀取(或覆蓋)一些 m 位元。這肯定會對程序的其餘部分產生不可預測的結果。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

當函數呼叫其他函數時,每個函數在呼叫堆疊時獲得自己的區塊。它保存所有的局部變量,但也會有一個程式計數器來記住它在執行過程中的位置。當函數完成時,它的記憶體區塊將再次用於其他地方。

動態分配

不幸的是,當編譯時不知道一個變數需要多少記憶體時,事情就有點複雜了。假設我們想做如下的操作:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

在編譯時,編譯器不知道陣列需要使用多少記憶體,因為這是由使用者提供的值決定的。

因此,它不能為堆疊上的變數分配空間。相反,我們的程式需要在運行時明確地向作業系統請求適當的空間,這個記憶體是從堆空間分配的。靜態記憶體分配與動態記憶體分配的差異總結如下表所示:

靜態記憶體分配 動態記憶體分配
#大小必須在編譯時知道 大小不需要在編譯時知道
#在編譯時執行 在執行時執行
分配給堆疊 分配給堆疊
#FILO (先進後出) #沒有特定的分配順序
#

要完全理解動態記憶體分配是如何運作的,需要在指標上花費更多的時間,這可能與本文的主題有太多的偏離,這裡就不太詳細介紹指標的相關的知識了。

在JavaScript中分配記憶體

現在將解釋第一步:如何在JavaScript中分配記憶體。

JavaScript為讓開發人員免於手動處理記憶體分配的責任-JavaScript自己進行記憶體分配同時宣告值。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

某些函數呼叫也會導致物件的記憶體分配:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

#方法可以分配新的值或對象:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

在JavaScript中使用記憶體

在JavaScript中使用分配的記憶體意味著在其中讀寫,這可以透過讀取或寫入變量或物件屬性的值,或將參數傳遞給函數來實現。

當記憶體不再需要時進行釋放

大多數的記憶體管理問題都出現在這個階段

這裡最困難的地方是確定何時不再需要分配的內存,它通常要求開發人員確定程式中哪些地方不再需要內存的並釋放它。

高階語言嵌入了一種稱為垃圾收集器的機制,它的工作是追蹤記憶體分配和使用,以便發現任何時候一塊不再需要已分配的內在。在這種情況下,它將自動釋放這塊記憶體。

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

大多數垃圾收集器透過收集不再被存取的記憶體來運作,例如,指向它的所有變數都超出了作用域。但是,這是可以收集的記憶體空間集合的一個不足估計值,因為在記憶體位置的任何一點上,仍然可能有一個變數在作用域中指向它,但是它將永遠不會再次存取。

垃圾收集

由於無法確定某些記憶體是否真的有用,因此,垃圾收集器想了一個辦法來解決這個問題。本節將解釋理解主要垃圾收集演算法及其限制。

記憶體引用

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

在記憶體管理上下文中,如果物件具有對另一個物件的存取權(可以是隱式的,也可以是顯式的),則稱物件引用另一個物件。例如,JavaScript物件具有對其原型(隱式參考)和屬性值(明確引用)的參考。

在此上下文中,「物件」的概念被擴展到比常規JavaScript物件更廣泛的範圍,並且還包含函數範圍(或全域詞法作用域)。

詞法作用域定義如何在巢狀函數中解析變數名:即使父函數已經傳回,內部函數也包含父函數的作用

引用計數垃圾收集演算法

#這是最簡單的垃圾收集演算法。如果沒有指向物件的引用,則認為該物件是「垃圾可回收的」,如下程式碼:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

#循環會產生問題

當涉及到循環時,會有一個限制。在下面的範例中,創建了兩個物件,兩個物件互相引用,從而創建了一個循環。在函數呼叫之後將超出作用域,因此它們實際上是無用的,可以被釋放。然而,引用計數演算法認為,由於每個物件至少被引用一次,所以它們都不能被垃圾收集。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

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

該演算法能夠判斷某個物件是否可以存取,從而知道該物件是否有用,該演算法由以下步驟組成:

  1. 垃圾收集器建立一個「根」列表,用於保存引用的全域變數。在JavaScript中,「window」物件是一個可作為根節點的全域變數。
  2. 然後,演算法檢查所有根及其子節點,並將它們標記為活動的(這意味著它們不是垃圾)。任何根不能到達的地方都會被標記為垃圾。
  3. 最後,垃圾收集器釋放所有未標記為活動的記憶體區塊,並將該記憶體傳回給作業系統。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

這個演算法比上一個演算法要好,因為「一個物件沒有被引用」就意味著這個物件無法存取。

截至2012年,所有現代瀏覽器都有標記-清除垃圾收集器。過去幾年在JavaScript垃圾收集(分代/增量/並發/平行垃圾收集)領域所做的所有改進都是對該演算法(標記-清除)的實現改進,而不是對垃圾收集演算法本身的改進,也不是它決定物件是否可存取的目標。

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

循環不再是問題

在上面的第一個例子中,在函數呼叫返回後,這兩個物件不再被從全域物件中可存取的物件引用。因此,垃圾收集器將發現它們無法存取。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

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

垃圾收集器的反直觀行為

儘管垃圾收集器很方便,但它們有一套自己的折衷方案,其中之一就是非決定論,換句話說,GC是不可預測的,你無法真正判斷何時進行垃圾收集。這意味著在某些情況下,程式會使用更多的記憶體,這實際上是必需的。在對速度特別敏感的應用程式中,可能會很明顯的感受到短時間的停頓。如果沒有分配記憶體,則大多數GC將處於空閒狀態。看看以下場景:

  1. 分配一組相當大的內在。
  2. 這些元素中的大多數(或全部)被標記為不可存取(假設引用指向一個不再需要的快取)。
  3. 不再進一步的分配

在這些場景中,大多數GCs 將不再繼續收集。換句話說,即使有不可訪問的引用可供收集,收集器也不會聲明這些引用。這些並不是嚴格意義上的洩漏,但仍然會導致比通常更高的記憶體使用。

記憶體洩漏是什麼?

從本質上說,記憶體洩漏可以定義為:不再被應用程式所需要的記憶體,出於某種原因,它不會回到操作系統或空閒記憶體池中。

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

程式語言支援不同的記憶體管理方式。然而,是否使用某一塊記憶體實際上是一個無法確定的問題。換句話說,只有開發人員才能明確指出一塊記憶體是否可以回到作業系統。

某些程式語言為開發人員提供了幫助,另一些則期望開發人員能清楚地了解記憶體何時不再被使用。維基百科上有一些有關人工和自動記憶體管理的很不錯的文章。

四種常見的記憶體洩漏

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)。幸運的是,現在大多數現代瀏覽器會為幫你完成這項工作:一旦觀察到的物件變得不可訪問,即使忘記刪除偵聽器,它們也會自動收集觀察者處理程序。然而,我們還是應該在物件被處理之前明確地刪除這些觀察者。例如:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

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

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

3.閉包

閉包是javascript開發的關鍵方面,一個內部函數使用了外部(封閉)函數的變數。由於JavaScript運行的細節,它可能以下面的方式造成記憶體洩漏:

介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏

#這段程式碼做了一件事:每次都呼叫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 樹中的內部節點或葉節點時,還需要考慮另一個問題。如果在程式碼中保留對錶單元格的引用(

標記),並決定從 DOM 中刪除表,同時保留對該特定單元格的引用,那麼可能會出現記憶體洩漏。

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

以上是介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除