V8的垃圾回收機制

垃圾回收策略

一般狀況下,垃圾數據回收分爲手動回收和自動回收兩種策略。算法

  • 手動回收 -- 什麼時候分配內存、什麼時候銷燬內存都是由代碼控制的。若是數據已經再也不須要了,可是又沒有主動銷燬,那麼這種狀況就被稱爲內存泄漏。
  • 自動回收 -- 產生的垃圾數據是由垃圾回收器來釋放的,並不須要手動經過代碼來釋放。如 JavaScript、Java、Python 等語言

數據存儲

咱們知道JavaScript中原始數據類型是存儲在棧空間中的,引用類型的數據是存儲在堆空間中的。經過這種分配方式,咱們解決了數據的內存分配的問題。
不過有些數據被使用以後,可能就再也不須要了,咱們把這種數據稱爲垃圾數據。若是這些垃圾數據一直保存在內存中,那麼內存會越用越多,因此咱們須要對這些垃圾數據進行回收,以釋放有限的內存空間。接下來咱們就分別說說棧數據和堆數據的回收函數

調用棧數據回收

咱們經過如下代碼舉例來講性能

function outterFn(){
    var a = 'out'
    var b = {value:"out"}
    function innerFn(){
      var c = "in"
      var d = {value:"in"}
    }
    innerFn()
}
foo()

當程序運行到innerFn(),這時候調用棧和堆空間的快照是下圖這樣。
從圖中能夠看出,原始類型的數據被分配到棧中,引用類型的數據會被分配到堆中。當 outterFn 函數執行結束以後,outterFn 函數的執行上下文會從堆中被銷燬掉,那麼它是怎麼被銷燬的呢?下面咱們就來分析一下。學習

image.png

在以前的文章中,咱們介紹過,若是執行到函數時,那麼 JavaScript 引擎會建立函數的執行上下文,並將函數的執行上下文壓入到調用棧中,其調用棧就如上圖所示。與此同時,還有一個記錄當前執行狀態的指針(稱爲 ESP),指向調用棧中當前函數innerFn。當innerFn函數執行完成以後,函數執行流程就進入了下一個函數outterFn,那這時就須要銷燬innerFn函數的執行上下文了。ESP 這時候就幫上忙了,JavaScript 會將 ESP 下移到outterFn 函數的執行上下文,這個下移操做就是銷燬 innerFn 函數執行上下文的過程。動畫

堆數據回收

經過上面的分析咱們知道,當上面那段代碼的 outterFn 函數執行結束以後,ESP 應該是指向全局執行上下文的,那這樣的話,outterFn 函數和 innerFn 函數的執行上下文就處於無效狀態了,不過保存在堆中的兩個對象依然佔用着空間。spa

要回收堆中的垃圾數據,就須要用到 JavaScript 中的垃圾回收器了。因此,接下來咱們就來分析下堆中的垃圾數據是如何回收的。線程

代際假說

不過在正式介紹回收以前,咱們須要學習下代際假說(The Generational Hypothesis)的內容,這是垃圾回收領域中一個重要的術語,後續垃圾回收的策略都是創建在該假說的基礎之上的,因此非常重要。
代際假說有如下兩個特色:3d

  • 第一個是大部分對象在內存中存在的時間很短,簡單來講,就是不少對象一經分配內存,很快就變得不可訪問;
  • 第二個是不死的對象,會活得更久。

有了代際假說的基礎,咱們就能夠來探討 V8 是如何實現垃圾回收的了。在 V8 中會把堆分爲新生代和老生代兩個區域指針

  • 新生代中存放生存時間短的對象,只支持 1~8M 的容量。使用副垃圾回收器回收
  • 老生代中存放生存時間久的對象。相對新生代區容量大不少,使用主垃圾回收器回收

工做流程

其實不論什麼類型的垃圾回收器,它們都有一套共同的執行流程。code

  • 第一步是標記空間中活動對象和非活動對象。所謂活動對象就是還在使用的對象,非活動對象就是能夠進行垃圾回收的對象。
  • 第二步是回收非活動對象所佔據的內存。就是在全部的標記完成以後,統一清理內存中全部被標記爲可回收的對象。
  • 第三步是作內存整理。通常來講,頻繁回收對象後,內存中就會存在大量不連續空間,咱們把這些不連續的內存空間稱爲內存碎片。當內存中出現了大量的內存碎片以後,若是須要分配較大連續內存的時候,就有可能出現內存不足的狀況。因此最後一步須要整理這些內存碎片,但這步實際上是可選的,由於有的垃圾回收器不會產生內存碎片,好比接下來咱們要介紹的副垃圾回收器。

那麼接下來,咱們就按照這個流程來分析新生代垃圾回收器(副垃圾回收器)和老生代垃圾回收器(主垃圾回收器)是如何處理垃圾回收的。

副垃圾回收器

一般狀況下,大多數小的對象都會被分配到新生區,因此說這個區域雖然不大,可是垃圾回收仍是比較頻繁的。新生代中用 Scavenge 算法來處理。所謂 Scavenge 算法,是把新生代空間對半劃分爲兩個區域,一半是對象區域,一半是空閒區域。
新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就須要執行一次垃圾清理操做。在垃圾回收過程當中,首先要對對象區域中的垃圾作標記;標記完成以後,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象複製到空閒區域中,同時它還會把這些對象有序地排列起來,因此這個複製過程,也就至關於完成了內存整理操做,複製後空閒區域就沒有內存碎片了。完成複製後,對象區域與空閒區域進行角色翻轉,也就是原來的對象區域變成空閒區域,原來的空閒區域變成了對象區域。這樣就完成了垃圾對象的回收操做,同時這種角色翻轉的操做還能讓新生代中的這兩塊區域無限重複使用下去。因爲新生代中採用的 Scavenge 算法,因此每次執行清理操做時,都須要將存活的對象從對象區域複製到空閒區域。但複製操做須要時間成本,若是新生區空間設置得太大了,那麼每次清理的時間就會太久,因此爲了執行效率,通常新生區的空間會被設置得比較小。也正是由於新生區的空間不大,因此很容易被存活的對象裝滿整個區域。爲了解決這個問題,JavaScript 引擎採用了對象晉升策略,也就是通過兩次垃圾回收依然還存活的對象,會被移動到老生區中。

主垃圾回收器

除了新生區中晉升的對象,一些大的對象會直接被分配到老生區。所以老生區中的對象有兩個特色,一個是對象佔用空間大,另外一個是對象存活時間長。因爲老生區的對象比較大,若要在老生區中使用 Scavenge 算法進行垃圾回收,複製這些大的對象將會花費比較多的時間,從而致使回收執行效率不高,同時還會浪費一半的空間。於是,主垃圾回收器是採用標記 - 清除(Mark-Sweep)的算法進行垃圾回收的。
首先是標記過程階段。標記階段就是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程當中,能到達的元素稱爲活動對象,沒有到達的元素就能夠判斷爲垃圾數據

從以前那副圖中咱們能夠大體看到垃圾數據的標記過程,當 innerFn函數執行結束以後,ESP 向下移動,指向了outterFn函數的執行上下文,這時候若是遍歷調用棧,是不會找到引用 1003 地址的變量,也就意味着 1003 這塊數據爲垃圾數據。因爲 1005 這塊數據被變量 b 引用了,因此這塊數據會被標記爲活動對象。這就是大體的標記過程。

接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程徹底不一樣,可參考下圖大體理解下其清除過程:標記過程和清除過程就是標記 - 清除算法,不過對一塊內存屢次執行標記 - 清除算法後,會產生大量不連續的內存碎片。而碎片過多會致使大對象沒法分配到足夠的連續內存,因而又產生了另一種算法——標記 - 整理(Mark-Compact),這個標記過程仍然與標記 - 清除算法裏的是同樣的,但後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存。
image.png

全停頓

如今咱們知道了 V8 是使用副垃圾回收器和主垃圾回收器處理垃圾回收的,不過因爲 JavaScript 是運行在主線程之上的,一旦執行垃圾回收算法,都須要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行。咱們把這種行爲叫作全停頓(Stop-The-World)。好比堆中的數據有 1.5GB,V8 實現一次完整的垃圾回收須要 1 秒以上的時間,這也是因爲垃圾回收而引發 JavaScript 線程暫停執行的時間,如果這樣的時間花銷,那麼應用的性能和響應能力都會直線降低。
在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,因此全停頓的影響不大,但老生代就不同了。若是在執行垃圾回收的過程當中,佔用主線程時間太久,主線程是不能作其餘事情的。好比頁面正在執行一個 JavaScript 動畫,由於垃圾回收器在工做,就會致使這個動畫在這期間沒法執行,這將會形成頁面的卡頓現象。爲了下降老生代的垃圾回收而形成的卡頓,V8 將標記過程分爲一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,咱們把這個算法稱爲增量標記(Incremental Marking)算法。以下圖所示:

image.png

使用增量標記算法,能夠把一個完整的垃圾回收任務拆分爲不少小的任務,這些小的任務執行時間比較短,能夠穿插在其餘的 JavaScript 任務中間執行,這樣當執行上述動畫效果時,就不會讓用戶由於垃圾回收任務而感覺到頁面的卡頓了。

相關文章
相關標籤/搜索