我們將討論另一個重要主題——記憶體管理,這是由於日常使用的程式語言越來越成熟和複雜,開發人員容易忽略這個問題。我們還將提供一些有關如何處理JavaScript中的記憶體洩漏的技巧,在SessionStack中遵循這些技巧,既能確保SessionStack 不會導致記憶體洩漏,也不會增加我們整合的Web應用程式的記憶體消耗。##相關免費學習推薦:javascript#(影片)
想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等著你!
概述像 C 這樣的程式語言,具有低階記憶體管理原語,如malloc()和free()。開發人員使用這些原語明確地對作業系統的記憶體進行分配和釋放。 而JavaScript在創建物件(物件、字串等)時會為它們分配內存,不再使用對時會「自動」釋放內存,這個過程稱為垃圾收集。這種看「自動」似釋放資源的特性是造成混亂的根源,因為這給JavaScript(和其他高階語言)開發人員帶來一種錯覺,以為他們可以不關心記憶體管理的錯誤印象,這是想法一個大錯誤。 即使在使用高階語言時,開發人員也應該了解記憶體管理(或至少懂得一些基礎知識)。有時候,自動記憶體管理存在一些問題(例如垃圾收集器中的bug或實現限制等),開發人員必須理解這些問題,以便可以正確地處理它們(或找到一個適當的解決方案,以最小代價來維護代碼)。 記憶體的生命週期無論使用哪一種程式語言,記憶體的生命週期都是一樣的:##這裡簡單介紹一下記憶體生命週期中的每一個階段:
硬體層面上,電腦記憶體由大量的觸發器快取的。每個觸發器包含幾個晶體管,能夠儲存一位,單一觸發器都可以透過唯一標識符定址,因此我們可以讀取和覆寫它們。因此,從概念上講,可以把的整個電腦記憶體看作是一個可以讀寫的巨大數組。
身為人類,我們並不擅長用位元來思考和計算,所以我們把它們組織成更大的群組,這些群組一起可以用來表示數字。 8位元稱為1位元組。除了字節,還有字(有時是16位,有時是32位)。
很多東西都儲存在記憶體中:
程式使用的所有變數和其他資料。在編譯程式碼時,編譯器可以檢查基本資料類型,並提前計算它們需要多少記憶體。然後將所需的大小分配給呼叫堆疊空間中的程序,分配這些變數的空間稱為堆疊空間。因為當呼叫函數時,它們的記憶體將被添加到現有記憶體之上,當它們終止時,它們按照後進先出(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物件更廣泛的範圍,並且還包含函數範圍(或全域詞法作用域)。
詞法作用域定義如何在巢狀函數中解析變數名:即使父函數已經傳回,內部函數也包含父函數的作用
#這是最簡單的垃圾收集演算法。如果沒有指向物件的引用,則認為該物件是「垃圾可回收的」,如下程式碼:
當涉及到循環時,會有一個限制。在下面的範例中,創建了兩個物件,兩個物件互相引用,從而創建了一個循環。在函數呼叫之後將超出作用域,因此它們實際上是無用的,可以被釋放。然而,引用計數演算法認為,由於每個物件至少被引用一次,所以它們都不能被垃圾收集。
該演算法能夠判斷某個物件是否可以存取,從而知道該物件是否有用,該演算法由以下步驟組成:
這個演算法比上一個演算法要好,因為「一個物件沒有被引用」就意味著這個物件無法存取。
截至2012年,所有現代瀏覽器都有標記-清除垃圾收集器。過去幾年在JavaScript垃圾收集(分代/增量/並發/平行垃圾收集)領域所做的所有改進都是對該演算法(標記-清除)的實現改進,而不是對垃圾收集演算法本身的改進,也不是它決定物件是否可存取的目標。
在這篇文章中,你可以更詳細地閱讀到有關跟踪垃圾收集的詳細信息,同時還包括了標記-清除算法及其優化。
在上面的第一個例子中,在函數呼叫返回後,這兩個物件不再被從全域物件中可存取的物件引用。因此,垃圾收集器將發現它們無法存取。
儘管物件之間存在引用,但它們對於根節點來說是不可達的。
儘管垃圾收集器很方便,但它們有一套自己的折衷方案,其中之一就是非決定論,換句話說,GC是不可預測的,你無法真正判斷何時進行垃圾收集。這意味著在某些情況下,程式會使用更多的記憶體,這實際上是必需的。在對速度特別敏感的應用程式中,可能會很明顯的感受到短時間的停頓。如果沒有分配記憶體,則大多數GC將處於空閒狀態。看看以下場景:
在這些場景中,大多數GCs 將不再繼續收集。換句話說,即使有不可訪問的引用可供收集,收集器也不會聲明這些引用。這些並不是嚴格意義上的洩漏,但仍然會導致比通常更高的記憶體使用。
從本質上說,記憶體洩漏可以定義為:不再被應用程式所需要的記憶體,出於某種原因,它不會回到操作系統或空閒記憶體池中。
程式語言支援不同的記憶體管理方式。然而,是否使用某一塊記憶體實際上是一個無法確定的問題。換句話說,只有開發人員才能明確指出一塊記憶體是否可以回到作業系統。
某些程式語言為開發人員提供了幫助,另一些則期望開發人員能清楚地了解記憶體何時不再被使用。維基百科上有一些有關人工和自動記憶體管理的很不錯的文章。
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,或在完成後將其重新賦值。
以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。
閉包是javascript開發的關鍵方面,一個內部函數使用了外部(封閉)函數的變數。由於JavaScript運行的細節,它可能以下面的方式造成記憶體洩漏:
#這段程式碼做了一件事:每次都呼叫replaceThing
#的時候,theThing
都會得到一個包含一個大數組和一個新閉包(someMethod)的新物件。同時,變數unuse
d指向一個引用了`originalThing
的閉包。
是不是有點困惑了? 重要的是,一旦具有相同父作用域的多個閉包的作用域被創建,則這個作用域就可以被共享。
在這種情況下,為閉包someMethod
而建立的作用域可以被unused
共享的。 unused
內部存在一個對originalThing
的引用。即使unused
從未使用過,someMethod
也可以在replaceThing
的作用域之外(例如在全域範圍內)透過theThing
來被調用。
由於someMethod
共享了unused
閉包的作用域,那麼unused
引用包含的originalThing
會迫使它保持活動狀態(兩個閉包之間的整個共享作用域)。這阻止了它被收集。
當這段程式碼重複運行時,可以觀察到記憶體使用在穩定增長,當GC
運行後,記憶體使用也不會變小。從本質上說,在運行過程中創建了一個閉包鍊錶(它的根是以變量theThing
的形式存在),並且每個閉包的作用域都間接引用了一個大數組,這造成了相當大的內存洩漏。
有時,將DOM節點儲存在資料結構中可能會很有用。假設你希望快速地更新表中的幾行內容,那麼你可以在一個字典或數組中保存每個DOM行的引用。這樣,同一個DOM元素就存在兩個引用:一個在DOM樹中,另一個則在字典中。如果在將來的某個時候你決定刪除這些行,那麼你需要將這兩個引用都設為不可訪問。
在引用 DOM 樹中的內部節點或葉節點時,還需要考慮另一個問題。如果在程式碼中保留對錶單元格的引用(
你可能認為垃圾收集器將釋放除該單元格之外的所有內容。然而,事實並非如此,由於單元格是表的一個子節點,而子節點保存對父節點的引用,所以對錶單元格的這個引用將使整個表保持在內存中,所以在移除有被引用的節點時候要移除其子節點。
以上是介紹JavaScript 記憶體管理+如何處理4個常見的記憶體洩漏的詳細內容。更多資訊請關注PHP中文網其他相關文章!