深入理解JavaScript記憶體管理和GC演演算法

2022-07-26 14:00:32
本篇文章給大家帶來了關於的相關知識,主要介紹了深入理解JavaScript記憶體管理和GC演演算法,主要講解JavaScript的垃圾回收機制以及常用的垃圾回收演演算法;還講解了V8引擎中的記憶體管理,希望對大家有幫助。

【相關推薦:、】

前言

JavaScript在建立變數(陣列、字串、物件等)是自動進行了分配記憶體,並且在不使用它們的時候會「自動」的釋放分配的內容;JavaScript語言不像其他底層語言一樣,例如C語言,他們提供了記憶體管理的介面,比如malloc()用於分配所需的記憶體空間、free()釋放之前所分配的記憶體空間。

我們將釋放記憶體的過程稱為垃圾回收,像JavaScript這種高階語言提供了記憶體自動分配和自動回收,因為這個自動就導致許多開發者不會去關心記憶體管理。

即使高階語言提供了自動記憶體管理,但是我們也需要對內管管理有一下基本的理解,有時候自動記憶體管理出現了問題,我們可以更好的去解決它,或者說使用代價最小的方法解決它。

記憶體的生命週期

其實不管是什麼語言,記憶體的宣告週期大致分為如下幾個階段:

下面我們對每一步進行具體說明:

  • 記憶體分配:當我們定義變數時,系統會自動為其分配記憶體,它允許在程式中使用這塊記憶體。
  • 記憶體使用:在對變數進行讀寫的時候發生
  • 記憶體回收:使用完畢後,自動釋放不需要記憶體,也就是由垃圾回收機制自動回收不再使用的記憶體

JavaScript中的記憶體分配

為了保護開發人員的頭髮,JavaScript在定義變數時就自動完成了記憶體分配,範例程式碼如下:

let num = 123 // 給數值變數分配記憶體
let str = '一碗周' // 給字串分配記憶體

let obj = {
  name: '一碗周',
  age: 18,
} // 給物件及其包含的值分配記憶體

// 給陣列及其包含的值分配記憶體(類似於物件)
let arr = [1, null, 'abc']

function fun(a) {
  return a + 2
} // 給函數(可呼叫的物件)分配記憶體

// 函數表示式也能分配一個物件
Element.addEventListener(
  'click',
  event => {
    console.log(event)
  },
  false,
)

有些時候並不會重新分配記憶體,如下面這段程式碼:

// 給陣列及其包含的值分配記憶體(類似於物件)
let arr = [1, null, 'abc']

let arr2 = [arr[0], arr[2]]
// 這裡並不會重新對分配記憶體,而是直接儲存原來的那份記憶體

在JavaScript中使用記憶體

JavaScript中使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。這裡的讀取與寫入可能是寫入一個變數、讀取某個變數的值、寫入一個物件的屬性值以及為函數傳遞引數等。

釋放記憶體

JavaScript中的記憶體釋放是自動的,釋放的時機就是某些值(記憶體地址)不在使用了,JavaScript就會自動釋放其佔用的記憶體。

其實大多數記憶體管理的問題都在這個階段。在這裡最艱難的任務就是找到那些不需要的變數。

雖然說現在打高階語言都有自己垃圾回收機制,雖然現在的垃圾回收演演算法很多,但是也無法智慧的回收所有的極端情況,這就是我們為什麼要學習記憶體管理以及垃圾回收演演算法的理由了。

接下來我們來討論一下JavaScript中的垃圾回收以及常用的垃圾回收演演算法。

JavaScript中的垃圾回收

前面我們也說了,JavaScript中的記憶體管理是自動的,在建立物件時會自動分配記憶體,當物件不在被參照或者不能從根上存取時,就會被當做垃圾給回收掉。

JavaScript中的可達物件簡單的說就是可以存取到的物件,不管是通過參照還是作用域鏈的方式,只要能存取到的就稱之為可達物件。可達物件的可達是有一個標準的,就是必須從根上出發是否能被找到;這裡的根可以理解為JavaScript中的全域性變數物件,在瀏覽器環境中就是window、在Node環境中就是global

為了更好的理解除參照的概念,看下面這一段程式碼:

let person = {
  name: '一碗周',
}
let man = person
person = null

圖解如下:

根據上面那個圖我們可以看到,最終這個{ name: '一碗周' }是不會被當做垃圾給回收掉的,因為還具有一個參照。

現在我們來理解一下可達物件,程式碼如下:

function groupObj(obj1, obj2) {
  obj1.next = obj2
  obj2.prev = obj1

  return {
    obj1,
    obj2,
  }
}
let obj = groupObj({ name: '大明' }, { name: '小明' })

呼叫groupObj()函數的的結果obj是一個包含兩個物件的一個物件,其中obj.obj1next屬性指向obj.obj2;而obj.obj2prev屬性又指向obj.obj2。最終形成了一個無限套娃。

如下圖:

現在來看下面這段程式碼:

delete obj.obj1
delete obj.obj2.prev

我們刪除obj物件中的obj1物件的參照和obj.obj2中的prev屬性對obj1的參照。

圖解如下:

此時的obj1就被當做垃圾給回收了。

GC演演算法

GC是Garbage collection的簡寫,也就是垃圾回收。當GC進行工作的時候,它可以找到記憶體中的垃圾、並釋放和回收空間,回收之後方便我們後續的進行使用。

在GC中的垃圾包括程式中不在需要使用的物件以及程式中不能再存取到的物件都會被當做垃圾。

GC演演算法就是工作時查詢和回收所遵循的規則,常見的GC演演算法有如下幾種:

  • 參照計數:通過一個數位來記錄參照次數,通過判斷當前數位是不是0來判斷物件是不是一個垃圾。
  • 標記清除:在工作時為物件新增一個標記來判斷是不是垃圾。
  • 標記整理:與標記清除類似。
  • 分代回收:V8中使用的垃圾回收機制。

參照計數演演算法

參照計數演演算法的核心思想就是設定一個參照計數器,判斷當前參照數是否為0 ,從而決定當前物件是不是一個垃圾,從而垃圾回收機制開始工作,釋放這塊記憶體。

參照計數演演算法的核心就是參照計數器 ,由於參照計數器的存在,也就導致該演演算法與其他GC演演算法有所差別。

參照計數器的改變是在參照關係發生改變時就會發生變化,當參照計數器變為0的時候,該物件就會被當做垃圾回收。

現在我們通過一段程式碼來看一下:

// { name: '一碗周' } 的參照計數器 + 1
let person = {
  name: '一碗周',
}
// 又增加了一個參照,參照計數器 + 1
let man = person
// 取消一個參照,參照計數器 - 1
person = null
// 取消一個參照,參照計數器 - 1。此時 { name: '一碗周' } 的記憶體就會被當做垃圾回收
man = null

參照計數演演算法的優點如下:

  • 發現垃圾時立即回收;
  • 最大限度減少程式暫停,這裡因為發現垃圾就立刻回收了,減少了程式因記憶體爆滿而被迫停止的現象。

缺點如下:

  • 無法回收迴圈參照的物件;

就比如下面這段程式碼:

function fun() {
  const obj1 = {}
  const obj2 = {}
  obj1.next = obj2
  obj2.prev = obj1
  return '一碗周'
}
fun()

上面的程式碼中,當函數執行完成之後函數體的內容已經是沒有作用的了,但是由於obj1obj2都存在不止1個參照,導致兩種都無法被回收,就造成了空間記憶體的浪費。

  • 時間開銷大,這是因為參照計數演演算法需要時刻的去監控參照計數器的變化。

標記清除演演算法

標記清除演演算法解決了參照計數演演算法的⼀些問題, 並且實現較為簡單, 在V8引擎中會有被⼤量的使⽤到。

在使⽤標記清除演演算法時,未參照物件並不會被立即回收.取⽽代之的做法是,垃圾物件將⼀直累計到記憶體耗盡為⽌.當記憶體耗盡時,程式將會被掛起,垃圾回收開始執行.當所有的未參照物件被清理完畢 時,程式才會繼續執行.該演演算法的核心思想就是將整個垃圾回收操作分為標記和清除兩個階段完成。

第一個階段就是遍歷所有物件,標記所有的可達物件;第二個階段就是遍歷所有物件清除沒有標記的物件,同時會抹掉所有已經標記的物件,便於下次的工作。

為了區分可用物件與垃圾物件,我們需要在每⼀個物件中記錄物件的狀態。 因此, 我們在每⼀個物件中加⼊了⼀個特殊的布林型別的域, 叫做marked。 預設情況下, 物件被建立時處於未標記狀態。 所以, marked 域被初始化為false

進行垃圾回收完畢之後,將回收的記憶體放在空閒連結串列中方便我們後續使用。

標記清除演演算法最大的優點就是解決了參照計數演演算法無法回收迴圈參照的物件的問題 。就比如下面這段程式碼:

function fun() {
  const obj1 = {},
    obj2 = {},
    obj3 = {},
    obj4 = {},
    obj5 = {},
    obj6 = {}
  obj1.next = obj2

  obj2.next = obj3
  obj2.prev = obj6

  obj4.next = obj6
  obj4.prev = obj1

  obj5.next = obj4
  obj5.prev = obj6
  return obj1
}
const obj = fun()

當函數執行完畢後obj4的參照並不是0,但是使用參照計數演演算法並不能將其作為垃圾回收掉,而使用標記清除演演算法就解決了這個問題。

而標記清除演演算法的缺點也是有的,這種演演算法會導致記憶體碎片化,地址不連續;還有就是使用標記清除演演算法即使發現了垃圾物件不裡能立刻清除,需要到第二次去清除。

標記整理演演算法

標記整理演演算法可以看做是標記清除演演算法的增強型,其步驟也是分為標記和清除兩個階段。

但是標記整理演演算法那的清除階段會先進行整理,移動物件的位置,最後進行清除。

步驟如下圖:

V8中的記憶體管理

V8是什麼

V8是一款主流的JavaScript執行引擎,現在的Node.js和大多數瀏覽器都採用V8作為JavaScript的引擎。V8的編譯功能採用的是及時編譯,也稱為動態翻譯或執行時編譯,是一種執行計算機程式碼的方法,這種方法涉及在程式執行過程中(在執行期)而不是在執行之前進行編譯。

V8引擎對記憶體是設有上限的,在64位元作業系統下上限是1.5G的,而32位元作業系統的上限是800兆的。至於為什麼設定記憶體上限主要是內容V8引擎主要是為瀏覽器而準備的,不適合太大的空間;還有一點就是這個大小的垃圾回收是很快的,使用者幾乎沒有感覺,從而增加使用者體驗。

V8垃圾回收策略

V8引擎採用的是分代回收的思想,主要是將我們的記憶體按照一定的規則分成兩類,一個是新生代儲存區,另一個是老生代儲存區。

新生代的物件為存活時間較短的物件,簡單來說就是新產生的物件,通常只支援一定的容量(64位元作業系統32兆、32位元作業系統16兆),而老生代的物件為存活事件較長或常駐記憶體的物件,簡單來說就是經歷過新生代垃圾回收後還存活下來的物件,容量通常比較大。

下圖展示了V8中的記憶體:

V8引擎會根據不同的物件採用不同的GC演演算法,V8中常用的GC演演算法如下:

  • 分代回收
  • 空間複製
  • 標記清除
  • 標記整理
  • 標記增量

新生代物件垃圾回收

上面我們也介紹了,新生代中存放的是存活時間較短的物件。新生代物件回收過程採用的是複製演演算法和標記整理演演算法。

複製演演算法將我們的新生代記憶體區域劃分為兩個相同大小的空間,我們將當前使用狀態的空間稱之為From狀態,空間狀態的空間稱之為To狀態,

如下圖所示:

我們將活動的物件全部儲存到From空間,當空間接近滿的時候,就會觸發垃圾回收。

首先需要將新生代From空間中的活動物件做標記整理,標記整理完成之後將標記後的活動物件拷貝到To空間並將沒有進行標記的物件進行回收;最後將From空間和To空間進行交換。

還有一點需要說的就是在進行物件拷貝的時候,會出現新生代物件移動至老生代物件中。

這些被移動的物件是具有指定條件的,主要有兩種:

  • 經過一輪垃圾回收還存活的新生代物件會被移動到老生代物件中
  • 在To空間佔用率超過了25%,這個物件也會被移動到老生代物件中(25%的原因是怕影響後續記憶體分配)

如此可知,新生代物件的垃圾回收採用的方案是空間換時間。

老生代物件垃圾回收

老生代區域存放的物件就是存活時間長、佔用空間大的物件。也正是因為其存活的時間長且佔用空間大,也就導致了不能採用複製演演算法,如果採用複製演演算法那就會造成時間長和空間浪費的現象。

老生代物件一般採用標記清除、標記整理和增量標記演演算法進行垃圾回收。

在清除階段主要才採用標記清除演演算法來進行回收,當一段時候後,就會產生大量不連續的記憶體碎片,過多的碎片無法分配足夠的記憶體時,就會採用標記整理演演算法來整理我們的空間碎片。

老生代物件的垃圾回收會採用增量標記演演算法來優化垃圾回收的過程,增量標記演演算法如下圖所示:

由於JavaScript是單執行緒,所以程式執行和垃圾回收同時只能執行一個,這就會導致在執行垃圾回收的時候程式卡頓,這樣給使用者的體驗肯定是不好的。

所以提出了增量標記,在程式執行時,程式先跑一段時間,然後進行進行初步的標記,這個標記有可能只標記直接可達的物件,然後程式繼續跑一段時間,在進行增量標記 ,也即是標記哪些間接可達的物件。由此反覆,直至結束。

Performance工具

由於JavaScript沒有給我們提供操作記憶體的API,只能靠本身提供的記憶體管理,但是我們並不知道實際上的記憶體管理是什麼樣的。而有時我們需要時刻關注記憶體的使用情況,Performance工具提供了多種監控記憶體的方式。

Performance使用步驟

首先我們開啟Chrome瀏覽器(這裡我們使用的是Chrome瀏覽器,其他瀏覽器也是可以的),在位址列中輸入我們的目標地址,然後開啟開發者工具,選擇【效能】面板。

選擇效能面板後開啟錄製功能,然後去存取具體介面,模仿使用者去執行一些操作,然後停止錄製,最後我們可以在分析介面中分析記錄的記憶體資訊。

記憶體問題的體現

出現記憶體的問題主要有如下幾種表現:

  • 頁面出現延遲載入或經常性暫停,它的底層就伴隨著頻繁的垃圾回收的執行;為什麼會頻繁的進行垃圾回收,可能是一些程式碼直接導致記憶體爆滿而且需要立刻進行垃圾回收。

關於這個問題我們可以通過記憶體變化圖進行分析其原因:

  • 頁面持續性出現糟糕的效能表現,也就是說在我們使用的過程中,頁面給我們的感覺就是一直不好用,它的底層我們一般認為都會存在記憶體膨脹,所謂的記憶體膨脹就是當前頁面為了達到某種速度從而申請遠大於本身需要的記憶體,申請的這個存在超過了我們裝置本身所能提供的大小,這個時候我們就能感知到一個持續性的糟糕效能的體驗。

導致記憶體膨脹的問題有可能是我們程式碼的問題,也有可能是裝置本身就很差勁,想要分析定位並解決的話需要我們在多個裝置上進行反覆的測試

  • 頁面的效能隨著時間的延長導致頁面越來越差,載入時間越來越長,出現這種問題的原因可能是由於程式碼的原因出現記憶體洩露

想要檢測記憶體是否洩漏,我們可以通過記憶體總檢視來監聽我們的記憶體,如果記憶體是持續升高的,就可能已經出現了記憶體洩露。

監控記憶體的方式

在瀏覽器中監控記憶體主要有以下幾種方式:

  • 瀏覽器提供的工作管理員
  • Timeline時序圖
  • 堆快照查詢分離DOM
  • 判斷是否存在頻繁的垃圾回收

接下來我們就分別講解這幾種方式。

工作管理員監控記憶體

在瀏覽器中按【Shift】+【ESC】鍵即可開啟瀏覽器提供的工作管理員,下圖展示了Chrome瀏覽器中的工作管理員,我們來解讀一

上圖中我們可以看到【掘金】分頁的【記憶體佔用空間】表示的的這個頁面的DOM在瀏覽器中所佔的記憶體,如果不斷增加就表示有新的DOM在建立;而後面的【JavaScript使用的記憶體】(預設不開啟,需要通過右鍵開啟)表示的是JavaScript中的堆,而括號中的大小表示JavaScript中的所有可達物件。

Timeline記錄記憶體

上面描述的瀏覽器中提供的工作管理員只能用來幫助我們判斷頁面是否存在問題,卻不能定位頁面的問題。

Timeline是Performance工具中的一個小的索引標籤,其中以毫秒為單位記錄了頁面中的情況,從而可以幫助我們更簡單的定位問題。

堆快照查詢分離DOM

堆快照是很有針對性的查詢當前的介面物件中是否存在一些分離的DOM,分離DOM的存在也就是存在記憶體漏失。

首先我們先要弄清楚DOM有幾種狀態:

  • 首先,DOM物件存在DOM樹中,這屬於正常的DOM
  • 然後,不存在DOM樹中且不存在JS參照,這屬於垃圾DOM物件,是需要被回收的
  • 最後,不存在DOM樹中但是存在JS參照,這就是分離DOM,需要我們手動進行釋放。

查詢分離DOM的步驟:開啟開發者工具→【記憶體面板】→【使用者設定】→【獲取快照】→在【過濾器】中輸入Detached來查詢分離DOM,

如下圖所示:

查詢到建立的分離DOM後,我們找到該DOM的參照,然後進行釋放。

判斷是否存在頻繁的垃圾回收

因為GC工作時應用程式是停止的,如果當前垃圾回收頻繁工作,而且時間過長的話對頁面來說很不友好,會導致應用假死說我狀態,使用者使用中會感知應用有卡頓。

我們可以通過如下方式進行判斷是否存在頻繁的垃圾回收,具體如下:

  • 通過Timeline時序圖判斷,對當前效能面板中的記憶體走勢進行監控,如果其中頻繁的上升下降,就出現了頻繁的垃圾回收。這個時候要定位程式碼,看看是執行什麼的時候造成了這種情況。
  • 使用瀏覽器工作管理員會簡單一些,工作管理員中主要是數值的變化,其資料頻繁的瞬間增加減小,也是頻繁的垃圾回收。

【相關推薦:、】

以上就是深入理解JavaScript記憶體管理和GC演演算法的詳細內容,更多請關注TW511.COM其它相關文章!