JavaScript 垃圾回收機制

作者:隱冬

來源:SegmentFault 思否社區

1. 概述

隨着軟件開發行業的不斷髮展,性能優化已經是一個不可避免的話題,那什麼樣的行爲才能算得上是性能優化呢?

本質上任何一種可以提高運行效率,降低運行開銷的行爲,都可以看做是一種優化操作。

這也就意味着,在軟件開放行業必然存在着很多值得優化的地方,特別是在前端開發過程中,性能優化可以認爲是無處不在的。例如請求資源時所用到的網絡,以及數據的傳輸方式,再或者開發過程中所使用到的框架等都可以進行優化。

本章探索的是JavaScript語言本身的優化,是從認知內存空間的使用到垃圾回收的方式,從而可以編寫出高效的JavaScript代碼。

2. 內存管理

隨着近些年硬件技術的不斷髮展,高級編程語言中都自帶了GC機制,讓開發者在不需要特別注意內存空間使用的情況下,也能夠正常的去完成相應的功能開發。爲什麼還要重提內存管理呢,下面就通過一段極簡單的代碼來進行說明。

首先定義一個普通的函數fn,然後在函數體內聲明一個數組,接着給數組賦值,需要注意的是在賦值的時候刻意選擇了一個比較大的數字來作爲下標。這樣做的目的就是爲了當前函數在調用的時候可以向內存儘可能多的申請一片比較大的空間。

function fn() {
    arrlist = [];
    arrlist[100000] = 'this is a lg';
}

fn()

在執行這個函數的過程中從語法上是不存在任何問題的,不過用相應的性能監控工具對內存進行監控的時候會發現,內存變化是持續程線性升高的,並且在這個過程當中沒有回落。這代表着內存泄露。如果在寫代碼的時候不夠了解內存管理的機制就會編寫出一些不容易察覺到的內存問題型代碼。

這種代碼多了以後程序帶來的可能就是一些意想不到的bug,所以掌握內存的管理是非常有必要的。因此接下來就去看一下,什麼是內存管理。

從這個詞語本身來說,內存其實就是由可讀寫的單元組成,他標識一片可操作的空間。而管理在這裏刻意強調的是由人主動去操作這片空間的申請、使用和釋放,即使藉助了一些API,但終歸可以自主的來做這個事。所以內存管理就認爲是,開發者可以主動的向內存申請空間,使用空間,並且釋放空間。因此這個流程就顯得非常簡單了,一共三步,申請,使用和釋放。

回到JavaScript中,其實和其他的語言一樣,JavaScript中也是分三步來執行這個過程,但是由於ECMAScript中並沒有提供相應的操作API。所以JavaScript不能像C或者C++那樣,由開發者主動調用相應的 API 來完成內存空間的管理。

不過即使如此也不能影響我們通過JavaScript腳本來演示一個空間的生命週期是怎樣完成的。過程很簡單首先要去申請空間,第二個使用空間,第三個釋放空間。

JavaScript中並沒有直接提供相應的API,所以只能在JavaScript執行引擎遇到變量定義語句的時候自動分配一個相應的空間。這裏先定義一個變量obj,然後把它指向一個空對象。對它的使用其實就是一個讀寫的操作,直接往這個對象裏面寫入一個具體的數據就可以了比如寫上一個yd。最後可以對它進行釋放,同樣的JavaScript裏面並沒有相應的釋放API,所以這裏可以採用一種間接的方式,比如直接把他設置爲null

let obj = {}

obj.name = 'yd'

obj = null

這個時候就相當於按照內存管理的一個流程在JavaScript當中實現了內存管理。後期在這樣性能監控工具當中看一下內存走勢就可以了。

3. 垃圾回收

首先在JavaScript中什麼樣的內容會被當中是垃圾看待。在後續的GC算法當中,也會存在的垃圾的概念,兩者其實是完全一樣的。所以在這裏統一說明。

JavaScript中的內存管理是自動的。每創建一個對象、數組或者函數的時候,就會自動的分配相應的內存空間。等到後續程序代碼在執行的過程中如果通過一些引用關係無法再找到某些對象的時候那麼這些對象就會被看作是垃圾。再或者說這些對象其實是已經存在的,但是由於代碼中一些不合適的語法或者說結構性的錯誤,沒有辦法再去找到這些對象,那麼這種對象也會被稱之是垃圾。

發現垃圾之後JavaScript執行引擎就會出來工作,把垃圾所佔據的對象空間進行回收,這個過程就是所謂的垃圾回收。在這裏用到了幾個小的概念,第一是引用,第二是從根上訪問,這個操作在後續的GC裏面也會被頻繁的提到。

在這裏再提一個名詞叫可達對象,首先在JavaScript中可達對象理解起來非常的容易,就是能訪問到的對象。至於訪問,可以是通過具體的引用也可以在當前的上下文中通過作用域鏈。只要能找得到,就認爲是可達的。不過這裏邊會有一個小的標準限制就是一定要是從根上出發找得到才認爲是可達的。所以又要去討論一下什麼是根,在JavaScript裏面可以認爲當前的全局變量對象就是根,也就是所謂的全局執行上下文。

簡單總結一下就是JavaScript中的垃圾回收其實就是找到垃圾,然後讓JavaScript的執行引擎來進行一個空間的釋放和回收。

這裏用到了引用和可達對象,接下來就儘可能的通過代碼的方式來看一下在JavaScript中的引用與可達是怎麼體現的。

首先定義一個變量,爲了後續可以修改值採用let關鍵字定一個obj讓他指向一個對象,爲了方便描述給他起一個名字叫xiaoming

let obj = {name: 'xiaoming'}

寫完這行代碼以後其實就相當於是這個空間被當前的obj對象引用了,這裏就出現了引用。站在全局執行上下文下obj是可以從根上來被找到的,也就是說這個obj是一個可達的,這也就間接地意味着當前xiaoming的對象空間是可達的。

接着再重新再去定義一個變量,比如ali讓他等於obj,可以認爲小明的空間又多了一次引用。這裏存在着一個引用數值變化的,這個概念在後續的引用計數算法中是會用到的。

let obj = {name: 'xiaoming'}

let ali = obj

再來做一個事情,直接找到obj然後把它重新賦值爲null。這個操作做完之後就可以思考一下了。本身小明這對象空間是有兩個引用的。隨着null賦值代碼的執行,obj到小明空間的引用就相當於是被切斷了。現在小明對象是否還是可達呢?必然是的。因爲ali還在引用着這樣的一個對象空間,所以說他依然是一個可達對象。

這就是一個引用的主要說明,順帶也看到了一個可達。

接下來再舉一個示例,說明一下當前JavaScript中的可達操作,不過這裏面需要提前說明一下。

爲了方便後面GC中的標記清除算法,所以這個實例會稍微麻煩一些。

首先定義一個函數名字叫objGroup,設置兩個形參obj1obj2,讓obj1通過一個屬性指向obj2,緊接着再讓obj2也通過一個屬性去指向obj1。再通過 return 關鍵字直接返回一個對象,obj1通過o1進行返回,再設置一個o2讓他找到obj2。完成之後在外部調用這個函數,設置一個變量進行接收,obj等於objGroup調用的結果。傳兩個參數分別是兩個對象obj1obj2

function objGroup(obj1, obj2) {
    obj1.next = obj2;
    obj2.prev = obj1;
}

let obj = objGroup({name: 'obj1'}{name: 'obj2'});

console.log(obj);

運行可以發現得到了一個對象。對象裏面分別有obj1obj2,而obj1obj2他們內部又各自通過一個屬性指向了彼此。

{
    o1: {name: 'obj1', next: {name: 'obj2', prev: [Circular]}},
    o2: {name: 'obj2', next: {name: 'obj1', next: [Circular]}}
}

分析一下代碼,首先從全局的根出發,是可以找到一個可達的對象obj,他通過一個函數調用之後指向了一個內存空間,他的裏面就是上面看到的o1o2。然後在o1o2的裏面剛好又通過相應的屬性指向了一個obj1空間和obj2空間。obj1obj2之間又通過nextprev做了一個互相的一個引用,所以代碼裏面所出現的對象都可以從根上來進行查找。不論找起來是多麼的麻煩,總之都能夠找到,繼續往下來再來做一些分析。

如果通過delete語句把obj身上o1的引用以及obj2obj1的引用直接delete掉。此時此刻就說明了現在是沒有辦法直接通過什麼樣的方式來找到obj1對象空間,那麼在這裏他就會被認爲是一個垃圾的操作。最後JavaScript引擎會去找到他,然後對其進行回收。

這裏說的比較麻煩,簡單來說就是當前在編寫代碼的時候會存在的一些對象引用的關係,可以從根的下邊進行查找,按照引用關係終究能找到一些對象。但是如果找到這些對象路徑被破壞掉或者說被回收了,那麼這個時候是沒有辦法再找到他,就會把他視作是垃圾,最後就可以讓垃圾回收機制把他回收掉。

4. GC 算法介紹

GC可以理解爲垃圾回收機制的簡寫,GC工作的時候可以找到內存當中的一些垃圾對象,然後對空間進行釋放還可以進行回收,方便後續的代碼繼續使用這部分內存空間。至於什麼樣的東西在GC裏邊可以被當做垃圾看待,在這裏給出兩種小的標準。

第一種從程序需求的角度來考慮,如果說某一個數據在使用完成之後上下文裏邊不再需要去用到他了就可以把他當做是垃圾來看待。

例如下面代碼中的name,當函數調用完成以後已經不再需要使用name了,因此從需求的角度考慮,他應該被當做垃圾進行回收。至於到底有沒有被回收現在先不做討論。

function func() {
    name = 'yd';
    return `${name} is a coder`
}

func()

第二種情況是當前程序運行過程中,變量能否被引用到的角度去考慮,例如下方代碼依然是在函數內部放置一個name,不過這次加上了一個聲明變量的關鍵字。有了這個關鍵字以後,當函數調用結束後,在外部的空間中就不能再訪問到這個name了。所以找不到他的時候,其實也可以算作是一種垃圾。

function func() {
    const name = 'yd';
    return `${name} is a coder`
}

func()

說完了GC再來說一下GC算法。我們已經知道GC其實就是一種機制,它裏面的垃圾回收器可以完成具體的回收工作,而工作的內容本質就是查找垃圾釋放空間並且回收空間。在這個過程中就會有幾個行爲:查找空間,釋放空間,回收空間。這樣一系列的過程裏面必然有不同的方式,GC的算法可以理解爲垃圾回收器在工作過程中所遵循的一些規則,好比一些數學計算公式。

常見的GC算法有引用計數,可以通過一個數字來判斷當前的這個對象是不是一個垃圾。標記清除,可以在GC工作的時候給那些活動對象添加標記,以此判斷它是否是垃圾。標記整理,與標記清除很類似,只不過在後續回收過程中,可以做出一些不一樣的事情。分代回收,V8中用到的回收機制。

5. 引用計數算法

引用計數算法的核心思想是在內部通過引用計數器來維護當前對象的引用數,從而判斷該對象的引用數值是否爲0來決定他是不是一個垃圾對象。當這個數值爲0的時候GC就開始工作,將其所在的對象空間進行回收和釋放。

引用計數器的存在導致了引用計數在執行效率上可能與其它的GC算法有所差別。

引用的數值發生改變是指某一個對象的引用關係發生改變的時候,這時引用計數器會主動的修改當前這個對象所對應的引用數值。例如代碼裏有一個對象空間,有一個變量名指向他,這個時候數值+1,如果又多了一個對象還指向他那他再+1,如果是減小的情況就-1。當引用數字爲0的時候,GC就會立即工作,將當前的對象空間進行回收。

通過簡單的代碼來說明一下引用關係發生改變的情況。首先定義幾個簡單的 user 變量,把他作爲一個普通的對象,再定義一個數組變量,在數組的裏存放幾個對象中的age屬性值。再定義一個函數,在函數體內定義幾個變量數值num1num2,注意這裏是沒有const的。在外層調用函數。

const user1 = {age: 11};
const user2 = {age: 22};
const user3 = {age: 33};

const nameList = [user1.age, user2.age, user3.age,];

function fn() {
    num1 = 1;
    num2 = 2;
}

fn();

首先從全局的角度考慮會發現window的下邊是可以直接找到user1user2user3以及nameList,同時在fn函數里面定義的num1num2由於沒有設置關鍵字,所以同樣是被掛載在window對象下的。這時候對這些變量而言他們的引用計數肯定都不是0

接着在函數內直接把num1num2加上關鍵字的聲明,就意味着當前這個num1num2只能在作用域內起效果。所以,一旦函數調用執行結束之後,從外部全局的地方出發就不能找到num1num2了,這個時候num1num2身上的引用計數就會回到0。此時此刻只要是0的情況下,GC就會立即開始工作,將num1num2當做垃圾進行回收。也就是說這個時候函數執行完成以後內部所在的內存空間就會被回收掉。

const user1 = {age: 11};
const user2 = {age: 22};
const user3 = {age: 33};

const nameList = [user1.age, user2.age, user3.age,];

function fn() {
    const num1 = 1;
    const num2 = 2;
}

fn();

那麼緊接着再來看一下其他的比如說user1user2user3以及nameList。由於userList,裏面剛好都指向了上述三個對象空間,所以腳本即使執行完一遍以後user1user2user3他裏邊的空間都還被人引用着。所以此時的引用計數器都不是0,也就不會被當做垃圾進行回收。這就是引用計數算法實現過程中所遵循的基本原理。簡單的總結就是靠着當前對象身上的引用計數的數值來判斷是否爲0,從而決定他是不是一個垃圾對象。

1. 引用計數優缺點

引用計數算法的優點總結出兩條。

第一是引用計數規則會在發現垃圾的時候立即進行回收,因爲他可以根據當前引用數是否爲0來決定對象是不是垃圾。如果是就可以立即進行釋放。

第二就是引用計數算法可以最大限度的減少程序的暫停,應用程序在執行的過程當中,必然會對內存進行消耗。當前執行平臺的內存肯定是有上限的,所以內存肯定有佔滿的時候。由於引用計數算法是時刻監控着內存引用值爲0的對象,舉一個極端的情況就是,當他發現內存即將爆滿的時候,引用計數就會立馬找到那些數值爲0的對象空間對其進行釋放。這樣就保證了當前內存是不會有佔滿的時候,也就是所謂的減少程序暫停的說法。

引用計數的缺點同樣給出兩條說明。

第一個就是引用計數算法沒有辦法將那些循環引用的對象進行空間回收的。通過代碼片段演示一下,什麼叫做循環引用的對象。

定義一個普通的函數fn在函數體的內部定義兩個變量,對象obj1obj2,讓obj1下面有一個name屬性然後指向obj2,讓obj2有一個屬性指向obj1。在函數最後的地方return返回一個普通字符,當然這並沒有什麼實際的意義只是做一個測試。接着在最外層調用一下函數。

function fn() {
    const obj1 = {};
    const obj2 = {};

    obj1.name = obj2;
    obj2.name = obj1;

    return 'yd is a coder';
}

那麼接下來分析還是一樣的道理,函數在執行結束以後,他內部所在的空間肯定需要有涉及到空間回收的情況。比如說obj1obj2,因爲在全局的地方其實已經不再去指向他了,所以這個時候他的引用計數應該是爲0的。

但是這個時候會有一個問題,在裏邊會發現,當GC想要去把obj1刪除的時候,會發現obj2有一個屬性是指向obj1的。換句話講就是雖然按照之前的規則,全局的作用域下找不到obj1obj2了,但是由於他們兩者之間在作用域範圍內明顯還有着一個互相的指引關係。這種情況下他們身上的引用計數器數值並不是0GC就沒有辦法將這兩個空間進行回收。也就造成了內存空間的浪費,這就是所謂的對象之間的循環引用。這也是引用計數算法所面臨到的一個問題。

第二個問題就是引用計數算法所消耗的時間會更大一些,因爲當前的引用計數,需要維護一個數值的變化,在這種情況下要時刻的監控着當前對象的引用數值是否需要修改。對象數值的修改需要消耗時間,如果說內存裏邊有更多的對象需要修改,時間就會顯得很大。所以相對於其他的GC算法會覺得引用計數算法的時間開銷會更大一些。

6. 標記清除算法

相比引用計數而言標記清除算法的原理更加簡單,而且還能解決一些相應的問題。在V8中被大量的使用到。

標記清除算法的核心思想就是將整個垃圾回收操作分成兩個階段,第一個階段遍歷所有對象然後找到活動對象進行標記。活動就像跟之前提到的可達對象是一個道理,第二個階段仍然會遍歷所有的對象,把沒有標記的對象進行清除。需要注意的是在第二個階段當中也會把第一個階段設置的標記抹掉,便於GC下次能夠正常工作。這樣一來就可以通過兩次遍歷行爲把當前垃圾空間進行回收,最終再交給相應的空閒列表進行維護,後續的程序代碼就可以使用了。

這就是標記清除算法的基本原理,其實就是兩個操作,第一是標記,第二是清除。這裏舉例說明。

首先在全局global聲明ABC三個可達對象,找到這三個可達對象之後,會發現他的下邊還會有一些子引用,這也就是標記清除算法強大的地方。如果發現他的下邊有孩子,甚至孩子下邊還有孩子,這個時候他會用遞歸的方式繼續尋找那些可達的對象,比如說DE分別是AC的子引用,也會被標記成可達的。

這裏還有兩個變量a1b1,他們在函數內的局部作用域,局部作用域執行完成以後這個空間就被回收了。所以從global鏈條下是找不到a1b1的,這時候GC機制就會認爲他是一個垃圾對象,沒有給他做標記,最終在GC工作的時候就會把他們回收掉。

const A = {};

function fn1() {
    const D = 1;
    A.D = D;
}

fn1();

const B;

const C = {};

function fn2() {
    const E = 2;
    A.E = E;
}

fn2();

function fn3() {
    const a1 = 3;
    const b1 = 4;
}

fn3();

這就是標記清除所謂的標記階段和清除階段,以及這兩個階段分別要做的事情。簡單的整理可以分成兩個步驟。在第一階段要找到所有可達對象,如果涉及到引用的層次關係,會遞歸進行查找。找完以後會將這些可達對象進行標記。標記完成以後進行第二階段開始做清除,找到那些沒有做標記的對象,同時還將第一次所做的標記清除掉。這樣就完成了一次垃圾回收,同時還要留意,最終會把回收的空間直接放在一個叫做空閒列表上面。方便後續的程序可以直接在這申請空間使用。

1. 標記清除算法優缺點

相對比引用計數而言標記清除具有一個最大的優點,就是可以解決對象循環引用的回收操作。在寫代碼的時候可能會在全局定義ABC這樣的可達對象,也會有一些函數的局部作用域,比如在函數內定義了a1b1,而且讓他們互相引用。

const A = {};

const B;

const C = {};

function fn() {
    const a1 = {};
    const b1 = {};
    a1.value = b1;
    b1.value = a1;
}

fn();

函數的調用在結束之後必然要去釋放他們內部的空間,在這種情況下一旦當某一個函數調用結束之後他局部空間中的變量就失去了與全局global作用域上的鏈接。這個時候a1b1global根下邊就沒辦法訪問到了,就是一個不可達的對象。不可達對象在做標記階段的時候不能夠完成標記,在第二個階段回收的時候就直接進行釋放了。

這是標記清除可以做到的,但是在引用計數里面,函數調用結束同時也沒有辦法在全局進行訪問。可是由於當前判斷的標準是引用數字是否爲0,在這種情況下,就沒有辦法釋放a1b1空間,這就是標記清除算法的最大優點,當然這是相對於引用計數算法而言的。

同時標記清除算法也會有一些缺點。比如模擬一個內存的存儲情況,從根進行查找,在下方有一個可達對象A對象, 左右兩側有一個從跟下無法直接查找的一個區域,BC。這種情況下在進行第二輪清除操作的時候,就會直接將 B 和 C 所對應的空間進行回收。然後把釋放的空間添加到空閒列表上,後續的程序可以直接從空閒列表上申請相應的一個空間地址,進行使用。在這種情況下就會有一個問題。

function fn() {
    const B = '兩個';
}
fn();

const A = '四個文字';

function fn2() {
    const C = '一個';
}
fn2();

比如我們認爲,任何一個空間都會有兩部分組成,一個用來存儲空間一些元信息比如他的大小,地址,稱之爲頭。還有一部分是專門用於存放數據的叫做域,BC空間認爲B對象有2個字的空間,C對象有1個字的空間。這種情況下,雖然對他進行了回收,加起來好像是釋放了3個字的空間,但是由於它們中間被 A 對象去分割着。所以在釋放完成之後其實還是分散的也就是地址不連續。

這點很重要,後續想申請的空間地址大小剛好1.5個字。這種情況下,如果直接找到 B 釋放的空間會發現是多了的,因爲還多了0.5個,如果直接去找C釋放的空間又發現不夠,因爲是1個。所以這就帶來了標記清除算法中最大的問題,空間的碎片化。

所謂的空間碎片化,就是由於當前所回收的垃圾對象在地址上本身是不連續的,由於這種不連續從而造成了回收之後分散在各個角落,後續要想去使用的時候,如果新的生成空間剛好與他們的大小匹配,就能直接用。一旦是多了或是少了就不太適合使用了。

這就是標記清除算法優點和缺點,簡單的整理一下就是優點是可以解決循環引用不能回收的問題,缺點是說會產生空間碎片化的問題,不能讓空間得到最大化的使用。

7. 標記整理算法

V8中標記整理算法會被頻繁的使用到,下面來看一下是如何實現的。

首先認爲標記整理算法是標記清除的增強操作,他們在第一個階段是完全一樣的,都會去遍歷所有的對象,然後將可達活動對象進行標記。第二階段清除時,標記清除是直接將沒有標記的垃圾對象做空間回收,標記整理則會在清除之前先執行整理操作,移動對象的位置,讓他們能夠在地址上產生連續。

假設回收之前有很多的活動對象和非活動對象,以及一些空閒的空間,當執行標記操作的時候,會把所有的活動對象進行標記,緊接着會進行整理的操作。整理其實就是位置上的改變,會把活動對象先進行移動,在地址上變得連續。緊接着會將活動對象右側的範圍進行整體的回收,這相對標記清除算法來看好處是顯而易見的。

因爲在內存裏不會大批量出現分散的小空間,從而回收到的空間都基本上都是連續的。這在後續的使用過程中,就可以儘可能的最大化利用所釋放出來的空間。這個過程就是標記整理算法,會配合着標記清除,在V8引擎中實現頻繁的 GC 操作。

8. 執行時機

首先是引用計數,他的可以及時回收垃圾對象,只要數值0的就會立即讓GC找到這片空間進行回收和釋放。正是由於這個特點的存在,引用計數可以最大限度的減少程序的卡頓,因爲只要這個空間即將被佔滿的時候,垃圾回收器就會進行工作,將內存進行釋放,讓內存空間總有一些可用的地方。

標記清除不能立即回收垃圾對象,而且他去清除的時候當前的程序其實是停止工作的。即便第一階段發現了垃圾,也要等到第二階段清除的時候纔會回收掉。

標記整理也不能立即回收垃圾對象。

9. V8 引擎

衆所周知V8引擎是目前市面上最主流的JavaScript執行引擎,日常所使用的chrome瀏覽器以及NodeJavaScript平臺都在採用這個引擎去執行JavaScript代碼。對於這兩個平臺來看JavaScript之所以能高效的運轉,也正是因爲V8的存在。V8的速度之所以快,除了有一套優秀的內存管理機制之外,還有一個特點就是採用及時編譯。

之前很多的JavaScript引擎都需要將源代碼轉成字節碼才能執行,而V8可以將源碼翻譯成直接執行的機器碼。所以執行速度是非常快的。

V8還有一個比較大的特點就是他的內存是有上限的,在 64 位操作系統下,上限是不超過1.5G,在32位的操作系統中數值是不超過800M

爲什麼V8要採用這樣的做法呢,原因基本上可以從兩方面進行說明。

第一V8本身就是爲了瀏覽器製造的,所以現有的內存大小足夠使用了。再有V8內部所實現的垃圾回收機制也決定了他採用這樣一個設置是非常合理的。因爲官方做過一個測試,當垃圾內存達到1.5G的時候,V8去採用增量標記的算法進行垃圾回收只需要消耗50ms,採用非增量標記的形式回收則需要1s。從用戶體驗的角度來說1s已經算是很長的時間了,所以就以1.5G爲界了。

1. 垃圾回收策略

在程序的使用過程中會用到很多的數據,數據又可以分爲原始的數據和對象類型的數據。基礎的原始數據都是由程序的語言自身來進行控制的。所以這裏所提到的回收主要還是指的是存活在堆區裏的對象數據,因此這個過程是離不開內存操作的。

V8採用的是分代回收的思想,把內存空間按照一定的規則分成兩類,新生代存儲區和老生代存儲區。有了分類後,就會針對不同代採用最高效的GC算法,從而對不同的對象進行回收操作。這也就意味着V8回收會使用到很多的GC算法。

首先,分代回收算法肯定是要用到的,因爲他必須要做分代。緊接着會用到空間的複製算法。除此以外還會用到標記清除和標記整理。最後爲了去提高效率,又用到了標記增量。

2. 回收新生代對象

首先是要說明一下V8內部的內存分配。因爲他是基於分代的垃圾回收思想,所以在V8內部是把內存空間分成了兩個部分,可以理解成一個存儲區域被分成了左右兩個區域。左側的空間是專門用來存放新生代對象,右側專門存放老生代對象。新生代對象空間是有一定設置的,在 64 位操作系統中大小是32M,在32位的操作系統中是16M

新生代對象其實指的就是存活時間較短的。比如說當前代碼內有個局部的作用域,作用域中的變量在執行完成過後就要被回收,在其他地方比如全局也有一個變量,而全局的變量肯定要等到程序退出之後纔會被回收。所以相對來說新生代就指的是那些存活時間比較短的那樣一些變量對象。

針對新生代對象回收所採用到的算法主要是複製算法和標記整理算法,首先會將左側一部分小空間也分成兩個部分,叫做FromTo,而且這兩個部分的大小是相等的,將 From 空間稱爲使用狀態,To空間叫做空閒狀態。有了這樣兩個空間之後代碼執行的時候如果需要申請空間首先會將所有的變量對象都分配至From空間。也就是說在這個過程中To是空閒的,一旦From空間應用到一定的程度之後,就要觸發GC操作。這個時候就會採用標記整理對From空間進行標記,找到活動對象,然後使用整理操作把他們的位置變得連續,便於後續不會產生碎片化空間。

做完這些操作以後,將活動對象拷貝至To空間,也就意味着From空間中的活動對象有了一個備份,這時候就可以考慮回收了。回收也非常簡單,只需要把From空間完全釋放就可以了,這個過程也就完成了新生代對象的回收操作。

總結一下就是新生代對象的存儲區域被一分爲二,而且是兩個等大的,在這兩個等大的空間中,起名FromTo,當前使用的是From,所有的對象聲明都會放在這個空間內。觸發GC機制的時候會把活動對象全部找到進行整理,拷貝到To空間中。拷貝完成以後我們讓FromTo進行空間交換 (也就是名字的交換),原來的To就變成了From,原來的From就變成了To。這樣就算完成了空間的釋放和回收。

接下來針對過程的細節進行說明。首先在這個過程中肯定會想到的是,如果在拷貝時發現某一個變量對象所指的空間,在當前的老生代對象裏面也會出現。這個時候就會出現一個所謂的叫晉升的操作,就是將新生代的對象,移動至老生代進行存儲。

至於什麼時候觸發晉升操作一般有兩個判斷標準,第一個是如果新生代中的某些對象經過一輪GC之後他還活着。這個時候就可以把他拷貝至老年代存儲區,進行存儲。除此之外如果當前拷貝的過程中,發現To空間的使用率超過了25%,這個時候也需要將這一次的活動對象都移動至老生代中存放。

爲什麼要選擇25%呢?其實也很容易想得通,因爲將來進行回收操作的時候,最終是要把From空間和To空間進行交換的。也就是說以前的To會變成From,而以前的From要變成To,這就意味着To如果使用率達到了80%,最終變成活動對象的存儲空間後,新的對象好像存不進去了。簡單的說明就是To空間的使用率如果超過了一定的限制,將來變成使用狀態時,新進來的對象空間好像不那麼夠用,所以會有這樣的限制。

簡單總結一下就是當前內存一分爲二,一部分用來存儲新生代對象,至於什麼是新生代對象可以認爲他的存活時間相對較短。然後可以去採用標記整理的算法,對From空間進行活動對象的標記和整理操作,接着把他們拷貝To空間。最後再置換一下兩個空間的狀態,那此時也就完成了空間的釋放操作。

3. 回收老生代對象

老生代對象存放在內存空間的右側,在V8中同樣是有內存大小的限制,在64位操作系統中大小是1.4G, 在32位操作系統中是700M

老生代對象指的是存活時間較長的對象,例如之前所提到的在全局對象中存放的一些變量,或者是一些閉包裏面放置的變量有可能也會存活很長的時間。針對老生代垃圾回收主要採用的是標記清除,標記整理和增量標記三個算法。

使用時主要採用的是標記清除算法完成垃圾空間的釋放和回收,標記清除算法主要是找到老生代存儲區域中的所有活動對象進行標記,然後直接釋放掉那些垃圾數據空間就可以了。顯而易見這個地方會存在一些空間碎片化的問題,不過雖然有這樣的問題但是V8的底層主要使用的還是標記清除的算法。因爲相對空間碎片來說他的提升速度是非常明顯的。

在什麼情況下會使用到標記整理算法呢?當需要把新生代裏的內容向老生代中移動的時候,而且這個時間節點上老生代存儲區域的空間又不足以存放新生代存儲區移過來的對象。這種情況下就會觸發標記整理,把之前的一些鎖片空間進行整理回收,讓程序有更多的空間可以使用。最後還會採用增量標記的方式對回收的效率進行提升。

這裏來對比一下新老生代垃圾回收。

新生代的垃圾回收更像是在用空間換時間,因爲他採用的是複製算法,這也就意味着每時每刻他的內部都會有一個空閒空間的存在。但是由於新生代存儲區本身的空間很小,所以分出來的空間更小,這部分的空間浪費相比帶來的時間上的一個提升當然是微不足道的。

在老生代對象回收過程中爲什麼不去採用這種一分二位的做法呢?因爲老生代存儲空間是比較大的,如果一分爲二就有幾百兆的空間浪費,太奢侈了。第二就是老生代存儲區域中所存放的對象數據比較多,所以在賦值的過程中消耗的時間也就非常多,因此老生代的垃圾回收是不適合使用複製算法來實現的。

至於之前所提到的增量標記算法是如何優化垃圾回收操作的呢?首先分成兩個部分,一個是程序執行,另一個是垃圾回收。

首先明確垃圾回收進行工作的時候是會阻塞當前JavaScript程序執行的,也就是會出現一個空檔期,例如程序執行完成之後會停下來執行垃圾回收操作。所謂的標記增量簡單來講就是將整段的垃圾回收操作拆分成多個小步驟,組分片完成整個回收,替代之前一口氣做完的垃圾回收操作。

這樣做的好處主要是實現垃圾回收與程序執行交替完成,帶來的時間消耗會更加的合理一些。避免像以前那樣程序執行的時候不能做垃圾回收,程序做垃圾回收的時候不能繼續運行程序。

簡單的舉個例子說明一下增量標記的實現原理。

程序首先運行的時候是不需要進行垃圾回收的,一旦當他觸發了垃圾回收之後,無論採用的是何種算法,都會進行遍歷和標記操作,這裏針對的是老生代存儲區域,所以存在遍歷操作。在遍歷的過程中需要做標記,標記之前也提到過可以不一口氣做完,因爲存在直接可達和間接可達操作,也就是說如果在做的時候,第一步先找到第一層的可達對象。然後就可以停下來,讓程序再去執行一會。如果說程序執行了一會以後,再繼續讓GC機做第二步的標記操作,比如下面還有一些子元素也是可達的,那就繼續做標記。標記一輪之後再讓 GC 停下來,繼續回到程序執行,也就是交替的去做標記和程序執行。

最後標記操作完成以後再去完成垃圾回收,這段時間程序就要停下來,等到垃圾回收操作完成纔會繼續執行。雖然這樣看起來程序停頓了很多次,但是整個V8最大的垃圾回收也就是當內存達到1.5G的時候,採用非增量標記的形式進行垃圾回收時間也不超過1s,所以這裏程序的間斷是合理的。而且這樣一來最大限度的把以前很長的一段停頓時間直接拆分成了更小段,針對用戶體驗會顯得更加流程一些。

4. V8 垃圾回收總結

首先要知道V8引擎是當前主流的JavaScript執行引擎,在V8的內部內存是設置上限的,這麼做的原因是第一他本身是爲瀏覽器而設置的,所以在web應用中這樣的內存大小是足夠使用的。第二就是由他內部的垃圾回收機制來決定的,如果把內存設置大一些這個時候回收時間最多可能就超過了用戶的感知,所以這裏就設置了上限數值。

V8採用的是分代回收的思想,將內存分成了新生代和老生代。關於新生代和老生代在空間和存儲數據類型是不同的。新生代如果在64位操作系統下空間是32M32位的系統下就是16M

V8對不同代對象採用的是不同的GC算法來完成垃圾回收操作,具體就是針對新生代採用複製算法和標記整理算法,針對老生代對象主要採用標記清除,標記整理和增量標記這樣三個算法。

10. Performance 工具介紹

GC工作目的就是爲了讓內存空間在程序運行的過程中,出現良性的循環使用。所謂良性循環的基礎其實就是要求開發者在寫代碼的時候能夠對內存空間進行合理的分配。但是由於ECMAScript中並沒有給程序員提供相應的操作內存空間的API,所以是否合理好像也不知道,因爲他都是由 GC 自動完成的。

如果想判斷整個過程內存使用是否合理,必須想辦法能夠時刻關注到內存的變化。所以就有了這樣一款工具可以提供給開發者更多的監控方式,在程序運行過程中幫助開發者完成對內存空間的監控。

通過使用Performance可以對程序運行過程內存的變化實時的監控。這樣就可以在程序的內存出現問題的時候直接想辦法定位到出現問題的代碼快。下面來看一下Performance工具的基本使用步驟。

首先打開瀏覽器,在地址欄輸入網址。輸入完地址之後不建議立即進行訪問,因爲想把最初的渲染過程記錄下來,所以只是打開界面輸入網址即可。緊接着打開開發人員工具面板 (F12),選擇性能選項。開啓錄製功能,開啓之後就可以訪問目標網址了。在這個頁面上進行一些操作,過一段時間後停止錄製。

就可以得到一個報告,在報告當中就可以分析跟內存相關的信息了。錄製後會有一些圖表的展示,信息也非常的多,看起來比較麻煩。這裏主要關注與內存相關的信息,有一個內存的選項 (Memory)。默認情況下如果沒有勾選需要將它勾選。頁面上可以看到一個藍色的線條。屬於整個過程中我內存所發生的變化,可以根據時序,來看有問題的地方。如果某個地方有問題可以具體觀察,比如有升有降就是沒問題的。

1. 內存問題的體現

當程序的內存出現問題的時候,具體會表現出什麼樣的形式。

首先第一條,界面如果出現了延遲加載或者說經常性的暫停,首先限定一下網絡環境肯定是正常的,所以出現這種情況一般都會去判定內存是有問題的,而且與GC存在着頻繁的垃圾回收操作是相關的。也就是代碼中肯定存在瞬間讓內存爆炸的代碼。這樣的代碼是不合適的需要去進行定位。

第二個就是當界面出現了持續性的糟糕性能表現,也就是說在使用過程中,一直都不是特別的好用,這種情況底層一般會認爲存在着內存膨脹。所謂的內存膨脹指的就是,當前界面爲了達到最佳的使用速度,可能會申請一定的內存空間,但是這個內存空間的大小,遠超過了當前設備本身所能提供的大小,這個時候就會感知到一段持續性的糟糕性能的體驗,同樣肯定是假設當前網絡環境是正常的。

最後,當使用一些界面的時候,如果感知到界面的使用流暢度,隨着時間的加長越來越慢,或者說越來越差,這個過程就伴隨着內存泄露,因爲在這種情況下剛開始的時候是沒有問題的,由於我們某些代碼的出現,可能隨着時間的增長讓內存空間越來越少,這也就是所謂的內存泄漏,因此,出現這種情況的時候界面會隨着使用時間的增長表現出性能越來越差的現象。

這就是關於應用程序在執行過程中如果遇到了內存出現問題的情況,具體的體現可以結合Performance進行內存分析操作,從而定位到有問題的代碼,修改之後讓應用程序在執行的過程中顯得更加流暢。

2. 監控內存的幾種方式

內存出現的問題一般歸納爲三種:內存泄露,內存膨脹,頻繁的垃圾回收。當這些內容出現的時候,該以什麼樣的標準來進行界定呢?

內存泄露其實就是內存持續升高,這個很好判斷,當前已經有很多種方式可以獲取到應用程序執行過程中內存的走勢圖。如果發現內存一直持續升高的,整個過程沒有下降的節點,這也就意味着程序代碼中是存在內存泄露的。這個時候應該去代碼裏面定位相應的模塊。

內存膨脹相對的模糊,內存膨脹的本意指的是應用程序本身,爲了達到最優的效果,需要很大的內存空間,在這個過程中也許是由於當前設備本身的硬件不支持,才造成了使用過程中出現了一些性能上的差異。想要判定是程序問題還是設備問題,應該多做一些測試。這個時候可以找到那些深受用戶喜愛的設備,在他們上面運行應用程序,如果整個過程中所有的設備都表現出了很糟糕的性能體驗。這就說明程序本身是有問題的,而不是設備有問題。這種情況就需要回到代碼裏面,定位到內存出現問題的地方。

具體有哪些方式來監控內存的變化,主要還是採用瀏覽器所提供的一些工具。

瀏覽器所帶的任務管理器,可以直接以數值的方式將當前應用程序在執行過程中內存的變化體現出來。第二個是藉助於Timeline時序圖,直接把應用程序執行過程中所有內存的走勢以時間點的方式呈現出來,有了這張圖就可以很容易的做判斷了。再有瀏覽器中還會有一個叫做堆快照的功能,可以很有針對性的查找界面對象中是否存在一些分離的DOM,因爲分離DOM的存在也就是一種內存上的泄露。

至於怎樣判斷界面是否存在着頻繁的垃圾回收,這就需要藉助於不同的工具來獲取當前內存的走勢圖,然後進行一個時間段的分析,從而得出判斷。

3. 任務管理器監控內存

一個web應用在執行的過程中,如果想要觀察他內部的一個內存變化,是可以有多種方式的,這裏通過一段簡單的demo來演示一下,可以藉助瀏覽器中自帶的任務管理器監控腳本運行時內存的變化。

在界面中放置一個元素,添加一個點擊事件,事件觸發的時候創建一個長度非常長的一個數組。這樣就會產生內存空間上的消耗。

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');
        oBtn.onclick = function() {
            let arrList = new Array(1000000)
        }
    </script>
</body>

完成之後打開瀏覽器運行,在右上角的更多中找到更多工具找到任務管理器打開。

這個時候就可以在任務管理器中定位到當前正在執行的腳本,默認情況下是沒有JavaScript內存列的,如果需要可以直接右擊找到JavaScript內存展示出來。這裏最關注的是內存和JavaScript內存這兩列。

第一列內存表示的是原生內存,也就是當前界面會有很多DOM節點,這個內存指的就是DOM節點所佔據的內存,如果這個數值在持續的增大,就說明界面中在不斷的創建 DOM 元素。

JavaScript內存表示的是JavaScript的堆,在這列當中需要關注的是小括號裏面的值,表示的是界面中所有可達對象正在使用的內存大小,如果這個數值一直在增大,就意味着當前的界面中要麼在創建新對象,要麼就是現有對象在不斷的增長。

以這個界面爲例,可以發現小括號的值一直是個穩定的數字沒有發生變化,也就意味着當前頁面是沒有內存增長的。此時可以再去觸發一下click事件 (點擊按鈕),多點幾次,完成以後就發現小括號裏面的數值變大了。

通過這樣的過程就可以藉助當前的瀏覽器任務管理器來監控腳本運行時整個內存的變化。如果當前JavaScript內存列小括號裏面的數值一直增大那就意味着內存是有問題的,當然這個工具是沒有辦法定位的,他只能發現問題,無法定位問題。

4. TimeLine 記錄內容

在之前已經可以使用瀏覽器自帶的任務管理器對腳本執行中內存的變化去進行監控,但是在使用的過程中可以發現,這樣的操作更多的是用於判斷當前腳本的內存是否存在問題。如果想要定位問題具體和什麼樣的腳本有關,任務管理器就不是那麼好用了。

這裏再介紹一個通過時間線記錄內存變化的方式來演示一下怎樣更精確的定位到內存的問題跟哪一塊代碼相關,或者在什麼時間節點上發生的。

首先放置一個DOM節點,添加點擊事件,在事件中創建大量的DOM節點來模擬內存消耗,再通過數組的方式配合着其他的方法形成一個非常長的字符串,模擬大量的內存消耗。

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');

        const arrList = [];

        function test () {
            for (let i = 0; i < 100000; i++) {
                document.body.appendChild(document.createElement('p'))
            }
            arrList.push(new Array(1000000).join('x'))
        }
        oBtn.onclick = test;
    </script>
</body>

先打開瀏覽器的控制檯工具,選擇性能面板,默認是沒有運行的,也就是沒有記錄,需要先點擊計時操作。點完以後就開始錄製了,點擊幾次add按鈕,稍等幾秒後,點擊停止按鈕。完成以後就生成了一個圖表,密密麻麻的東西看起來可能會有些頭疼,只關注下想要看到的信息就可以了。

內存如果沒有勾選的話是不會監控內存變化的,需要先勾選內存,勾選之後頁面上就出現了內存的走勢曲線圖。裏面會包含很多信息,給出來了幾中顏色的解釋。藍色的是JavaScript堆,紅色表示當前的文檔,綠色是DOM節點,棕色是監聽器,紫色是CPU內存。

爲了便於觀察可以只保留JavaScript堆,其他的取消勾選隱藏掉。可以看到這個腳本運行過程中到目前爲止他的JavaScript堆的情況走勢。當前這個工具叫時序圖,也就是在第一欄,以毫秒爲單位,記錄了整個頁面從空白到渲染結束到最終停狀態,這個過程中整個界面的變化。如果願意,可以點進去看一下當前的界面形態,如果只是關注內存,只看內存的曲線圖就可以了。

當這個頁面最開始打開的時候其實很長一段時間都是平穩的狀態,沒有太多的內存消耗。原因在根本沒有點擊add。然後緊接着在某一個時間點上突然之間內存就上去了,上去之後是一段平穩的狀態,這是因爲點擊了add之後這裏的內存肯定是瞬間暴漲的,然後緊接着暴漲之後我們任何操作,所以這時候肯定是平穩。

然後緊接着平穩之後又下降了,這就是之前所提到的,瀏覽器本身也是具有垃圾回收機制的,當的腳本運行穩定之後,GC可能在某個時間點上就開始工作了,會發現有一些對象是非活動的,就開始進行回收,所以一段平穩之後就降下去了。降下去之後又會有一些小的浮動,屬於正常的活動開銷。後來又有幾次連續的點擊,這個連續的點擊行爲可能又造成內存的飆升,然後不操作之後又往下降。

通過這樣一張內存走勢圖,可以得出的結論是,腳本里面內存是非常穩定的,整個過程有漲有降,漲是申請內存,降是用完之後我GC在正常的回收內存。

一旦看到內存的走勢是直線向上走,也就意味着他只有增長而沒有回收,必然存在着內存消耗,更有可能是內存泄漏。可以通過上面的時序圖定位問題,當發現某一個節點上有問題的時候,可以直接在這裏面定位到那個時間節點,可以在時序圖上進行拖動查看每一個時間節點上的內存消耗。還可以看到界面上的變化,就可以配合着定位到是哪一塊產生了這樣一個內存的問題。

所以相對任務管理器來說會更好用,不但可以看當前內存是否有問題,還可以幫助定位問題在哪個時候發生的,然後再配合當前的界面展示知道做了什麼樣的操作纔出現了這個問題,從而間接地可以回到代碼中定位有問題的代碼塊。

5. 堆快照查找分離 DOM

這裏簡單說明一下堆快照功能工作的原理,首先他相當於找到JavaScript堆,然後對它進行照片的留存。有了照片以後就可以看到它裏面的所有信息,這也就是監控的由來。堆快照在使用的時候非常的有用,因爲他更像是針對分離 DOM 的查找行爲。

界面上看到的很多元素其實都是DOM節點,而這些DOM節點本應該存在於一顆存活的 DOM 樹上。不過 DOM 節點會有幾種形態,一種是垃圾對象,一種是分離DOM。簡單的說就是如果這個節點從DOM樹上進行了脫離,而且在JavaScript代碼當中沒有再引用的DOM節點,他就成爲了一個垃圾。如果DOM節點只是從DOM樹上脫離了,但是在JavaScript代碼中還有引用,就是分離DOM。分離DOM在界面上是看不見的,但是在內存中是佔據着空間的。

這種情況就是一種內存泄露,可以通過堆快照的功能把他們找出來,只要能找得到,就可以回到代碼裏,針對這些代碼進行清除從而讓內存得到一些釋放,腳本在執行的時候也會變得更加迅速。

html裏面放入btn按鈕,添加點擊事件,點擊按鈕的時候,通過JavaScript語句去模擬相應的內存變化,比如創建DOM節點,爲了看到更多類型的分離DOM,採用ul包裹liDOM節點創建。先在函數中創建ul節點,然後使用循環的方式創建多個li放在ul裏面,創建之後不需要放在頁面上,爲了讓代碼引用到這個DOM使用變量tmpEle指向ul

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');

        var tmpEle;

        function fn () {
            var ul = document.createElement('ul');
            for (var i = 0; i < 10; i++) {
                var li = document.createElement('li');
                ul.appendChild(li);
            }
            tmpEle = ul;
        }

        oBtn.addEventListener('click', fn);

    </script>
</body>

簡單說明就是創建了ulli節點,但是並沒有將他們放在頁面中,只是通過JavaScript變量引用了這個節點,這就是分離DOM

打開瀏覽器調試工具,選中內存面板。進入以後可以發現堆快照的選項。這裏做兩個行爲的測試,第一個是在沒有點擊按鈕的情況下,直接獲取當前的快照,在這個快照裏面就是當前對象的具體展示,這裏有一個篩選的操作,直接檢索deta關鍵字,可以發現沒有內容。

回到界面中做另外一個操作,對按鈕進行點擊,點完以後我再拍攝一張快照 (點擊左側的配置文件文字,出現拍照界面),還是做和之前一樣的操作檢索deta

這次就會發現,快照2裏面搜索到了,很明顯這幾個就是代碼中所創建的 DOM 節點,並沒有添加到界面中,但是他的確存在於堆中。這其實就是一種空間上的浪費,針對這樣的問題在代碼中對使用過後的 DOM 節點進行清空就可以了。

function fn () {
    var ul = document.createElement('ul');
    for (var i = 0; i < 10; i++) {
        var li = document.createElement('li');
        ul.appendChild(li);
    }
    tmpEle = ul;
    // 清空DOM
    ul = null;
}

在這裏我們簡單的總結就是,我們可以利用瀏覽器當中提供的一個叫做堆快照的功能,然後去把我們當前的堆進行拍照,拍照過後我們要找一下這裏面是否存在所謂的分離DOM

因爲分離DOM在頁面中不體現,在內存中的確存在,所以這個時候他是一種內存的浪費,那麼我們要做的就是定位到我們代碼裏面那些個分離DOM所在的位置,然後去想辦法把他給清除掉。

6. 判斷是否存在頻繁 GC

這裏說一下如何確定當前 web 應用在執行過程中是否存在着頻繁的垃圾回收。當GC去工作的時候應用程序是停止的。所以GC頻繁的工作對web應用很不友好,因爲會處於死的狀態,用戶會感覺到卡頓。

這個時候就要想辦法確定當前的應用在執行時是否存在頻繁的垃圾回收。

這裏給出兩種方式,第一種是可以通過timeline時序圖的走勢來判斷,在性能工具面板中對當前的內存走勢進行監控。如果發現藍色的走勢條頻繁的上升下降。就意味着在頻繁的進行垃圾回收。出現這樣的情況之後必須定位到相應的時間節點,然後看一下具體做了什麼樣的操作,才造成這樣現象的產生,接着在代碼中進行處理就可以了。

任務管理器在做判斷的時候會顯得更加簡單一些,因爲他就是一個數值的變化,正常當界面渲染完成之後,如果沒有其他額外的操作,那麼無論是DOM節點內存,還是我們JavaScript內存,都是一個不變化的數值,或者變化很小。如果這裏存在頻繁的GC操作時,這個數值的變化就是瞬間增大,瞬間減小,這樣的節奏,所以看到這樣的過程也意味着代碼存在頻繁的垃圾回收操作。

頻繁的垃圾回收操作表象上帶來的影響是讓用戶覺得應用在使用的時候非常卡頓,從內部看就是當前代碼中存在對內存操作不當的行爲讓GC不斷的工作,來回收釋放相應的空間。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/jyGTGXeCOxZW7Yxub26cBA