從一個閉包談垃圾回收

前言

其實大多數的時候做爲javascript開發者不須要太關心內存的使用和釋放,由於全部的javascript環境都實現了各自的垃圾回收機制(garbage collector(GC)),可是隨着如今的SPA愈來愈多也愈來愈大,愈來愈追求極致的性能漸漸也要求開發者可以適當的瞭解一些垃圾回收機制內部的實現原理,在性能優化和追蹤內存泄漏的時候都可以起到一點幫助。看一段內存泄漏的代碼javascript

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var c = 'a'
  function unused() {
    if (originalThing) {
      console.log("hi");
    }
  };

  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('1111');
    }
  };
};

setInterval(replaceThing,1000)

複製代碼

最先想要去深刻了解javacript GC是看到這道找內存泄漏的題目(具體怎麼內存泄漏,咱們後面在分析).任何一種GC管理都須要作這幾步:java

  1. 識別哪些對象須要被回收。
  2. 回收/重複使用須要被回收對象的內存。
  3. 壓縮/整理內存(有些可能沒有)

而常見的識別對象是否須要回收的機制有下面幾種:node

  • 引用計數 (Python)
  • 逃逸分析 (Java)
  • Tracing/Reachable 追蹤分析 (javascript)

今天就主要看一下V8中GC的具體實現方式web

Tracing/Reachable 追蹤分析

GC的第一步就是要找出哪些對象須要被回收,哪些不須要。在追蹤分析(Tracing/Reachable)中,認爲能夠被追蹤到(reachability)的對象認爲是不能被回收的對象,剩下的不能被追蹤到的對象就是要回收的對象。 在V8中,追蹤分析會從根對象開始(GC Root)根據指針將全部被能被追蹤到的對象標記爲reachable,javascript中根對象包括調用堆棧和global對象。算法

The Generational Hypothesis

Generational Hypothesis的意思是大部分的對象在早期就須要被回收。基於這樣的一個假設,有不少的編程語言的垃圾回收機制在設計的時候都是將內存分代,年輕代(young generation),和老代(old generation)。 這裏的代其實就是開闢兩塊space分別存儲剛被分配的對象和通過兩次GC仍是沒有被回收的對象。在V8中有兩個垃圾回收器分別對年輕代和老代進行垃圾回收,Scavenger針對年輕代進行垃圾回收,Major GC針對老代進行垃圾回收,他們的算法也是不一樣的。編程

Scavenger

V8在年輕代的內存space使用的是semi-space算法,也就是說將內存分爲兩半,同時只有一塊的內存能被使用,另一半是徹底空的(或者說這一半內存都是能夠被分配的)。在程序開始執行的時候,將全部的變量都分配能夠被使用的一半內存中(叫作from-space)。當第一次GC開始的時候根據追蹤分析結果,將全部能夠reachable的對象(不能被釋放的對象),所有轉移到剩餘一半能夠被分配的內存中(to-space),這樣from-space中的內存又所有能夠被分配了,這個時候若是又有新申明的對象須要分配內存,就會分配到這一塊內存當中了,最後在轉移完不能被釋放的對象以後,還須要更新引用指針,指向在to-space中最新的地址。瀏覽器

第一次GC

第二次GC開始的時候,在本來的to-space中仍然不能被釋放的對象首先轉移到老代(old generation)的space中,這時候to-space中又所有能夠被分配,重複以前的操做。從from-space中將不能被釋放的對象轉移過來。完成2次GC以後,存貨了兩次的對象如今就在老代裏面了,而存活一次GC的對象如今就在to-space中了,這個to-space也被叫作intermediate generation(中生代).在Scavenger中回收內存有三個過程:標記(追蹤分析),轉移(from-space to to-space),更新指針地址。性能優化

第二次GC
第二次GC

在這種內存回收的機制中,其中一個問題就是轉移對象的時候是會消耗必定性能的,可是根據Generational Hypothesis的假設大部分的對象在早期就會被回收了,這也就意味着只有少部分不能被回收的對象須要被移動,這也意味着若是這個假設不成立,好比咱們的代碼中有不少的閉包致使不少的做用域不能被釋放,那麼將會有大量的對象須要在space之間轉移,是比較浪費性能的。可是相反的,基於大部分對象均可以在早期被回收的假設,若是大部分的對象在早期就能夠被釋放,這種機制的內存回收對這須要在早期就回收的對象實際上是什麼都不須要作的,只須要把不能釋放的少部分對象進行轉移(from-space to to-space),而後在下次分配內存的時候把這部分須要釋放的對象所佔的內存直接覆蓋就能夠了(rewrite dead object)。數據結構

Parallel

Parallel是V8中調度線程進行內存回收的一種算法,指的是主線程和幫助線程同時進行相同工做量的內存回收,這種算法仍是會中止主線程正在進行的所有工做,可是工做量被平攤到幾個線程以後,理論上時間也被參與線程的數量整除了(加上一些同步調度的開銷)。Scavenger就是使用的這種線程調度機制,當須要進行內存回收的時候,全部的線程得到必定數量的存活的對象引用指針,開始同時將這些存活對象搬運到to-space中。不一樣的線程可能經過不一樣引用路徑訪問到同一個對象,當線程將存活對象轉移到to-space以後,更新完指針地址後,會在from-space的老對象中留下一個forwarding指針,這樣其餘線程找到這個對象以後就能夠經過這個指針來找到新的地址更新引用地址了。閉包

Scavenger平行調度
Scavenger平行調度,同時有多個幫助線程和主線程參與

Major GC

Major GC主要負責老代的內存回收,一樣也是三個過程:標記(追蹤分析),清除,整理壓縮內存。標記這一步和Scavenger同樣經過追蹤分析肯定哪些內存須要被回收,而後在對象被回收之後將被回收的內存加入到free-list這個數據結構中,free-list就像是一個個抽屜,每一個抽屜的大小表明了從這個地址開始能夠被連續分配的內存的大小,當咱們須要在老代中從新分配內存的時候就能夠快速的根據須要分配內存的大小找到一個合適的抽屜把內存進行分配。最後就是進行內存整理,這個就好像是Windows系統整理磁盤同樣,將還沒被倖存的對象利用free-list查找拷貝到其餘的已經被整理完的page中,這樣使小塊的內存碎片也被整理完以後加以利用。跟Scavenger中同樣來回拷貝對象也會有性能的消耗,在V8中只會對高度碎片化的page進行整理,對其餘的page進行清除,這樣在轉移的時候也是同樣的只須要轉移存活的對象就能夠了。

Concurrent

Concurrent一樣也是V8中進行內存回收的線程調度算法,當主線程執行Javascript的時候,幫助線程同步進行內存回收的一些工做。相比Parallel來講這個算法要複雜的多,可能前一毫秒幫助線程在進行GC操做,後一毫秒主線程就改變了這個對象。也有可能幫助線程和主線程同時讀取修改同一個對象。可是這種算法的優點就是當幫助線程進行GC工做的時候,主線程能夠繼續執行JavaScript,徹底不會受到影響。Major GC就是採用的這個算法,當老代的內存到達必定系統自動計算的閥值,就開始進行Major GC,首先每一個幫助線程都會得到必定數量的對象指針,開始針對這些對象進行標記,而後根據對象的引用指針對reachable對象都進行標記,在進行這些標記的同時,主線程仍然在執行JavaScript沒有受到影響。當幫助線程完成標記,或者老代觸及了設定的閥值,主線程也開始參與GC,他首先進行一步快速的標記確認,確保幫助線程在標記的同時主線程修改的對象標記正確(在幫助線程進行標記的時候,若是主線程執行的JavaScript修改了對象會有Write barriers,相似於有個標記)。當主線程確認全部存活的對象都被標記之後,主線程會和幾個子線程一塊兒,對一些內存page進行壓縮和更新指針的工做,不是全部的page都須要進行壓縮(只對高碎片化的進行壓縮),不須要壓縮的利用free-list進行打掃。

Major GC同步調度
Major GC同步調度

何時會執行GC

在JavaScript中咱們沒辦法用編程的方式主動觸發GC,由於涉及到複雜的線程調度,主動的觸發GC可能會影響正在執行的GC或者下次的GC。對於Scavenger來講,當在新生代中分配內存時,已經沒有空間分配內存致使分配內存失敗時,開始Scavenger垃圾回收,但願能釋放一些內存,而後在嘗試從新分配內存。對於老代來講,開啓內存回收的時機要複雜不少,簡單來講會根據老代中內存佔用的百分比和須要被分配對象的百分比計算出一個合適的閥值,觸及到這個閥值就會開啓老代的垃圾回收。

咱們能夠經過手動設置來設置新生代和老代的space大小:

node --max-old-space-size=1700 index.js
    node --max-new-space-size=1024 index.js
複製代碼

空閒時GC

雖然咱們經過JavaScript沒辦法主動觸發GC,可是在V8中還有一個空閒GC的機制,他根據被嵌入宿主來決定何時屬於空閒時來執行GC。好比V8在Chrome瀏覽器中,爲了保證動畫渲染的流暢,一秒鐘須要渲染60個幀,至關於16.6毫秒渲染一幀,在16.6毫秒之內渲染完了一幀,好比只花了10毫秒就渲染完了這一幀的動畫,那麼你就有了6.6毫秒的空閒時間能夠執行一些空閒時的GC(在許多新版本的瀏覽器中,開發者也能夠經過requestIdleCallback事件,利用瀏覽器空閒時間來提升性能,有興趣的能夠去了解React 16 fiber的實現)。

空閒時GC
利用主線程空閒時間進行GC

Incremental

那麼在空閒的幾毫秒時間裏能完成一次GC嗎?那就是接下來就要介紹另一種調度算法Incremental了,相比較於其餘調度算法在暫停一次主線程執行一整次完成的GC,Incremental要求把一整個GC中的工做拆成一小塊,和主線程中的JS遞進的執行,或者在主線程有空閒時間的時候執行一小塊GC任務。

Incremental
將一整個GC切分紅一小塊GC任務,插入到主線程中進行

總結

不一樣JavaScript引擎實現GC都有不一樣程度的差別,本文主要以V8爲例,有不少地方沒有很是仔細的展開,好比:其實老代裏面不是隻有一塊space,而是有4塊space組成,每塊space存放着不一樣的數據(old space,large object space,matedata space,code space)。垃圾回收設計自己就是一個很複雜的程序,有了GC,讓開發者能夠徹底不用擔憂內存的管理問題。可是適當的瞭解垃圾回收的原理可以幫助咱們更加深刻的理解JavaScript的運行環境,也能夠幫助咱們寫出更高效率的代碼。

最後的最後將以前的內存泄漏代碼一步步的推演:

  1. 首先在全局做用域中聲明瞭兩個變量theThing和replaceThing,其中replaceThing被賦值爲一個方法(callable object),而後調用setInterval方法,每隔1000毫秒調用一次replaceThing。
  2. 1000毫秒到了,執行replaceThing,建立一個新的局部做用域,根據hoist,先將方法unused方法聲明,而後聲明瞭originThing和c變量。這裏特別要注意,閉包是在方法聲明的時候被建立的而不是在方法執行的時候建立的,因此當聲明瞭unused方法之後,同時建立了一個閉包,裏面包含了unused方法使用的局部做用域變量originThing。另外在V8中一旦做用域有閉包,這個上下文會被綁定到全部方法當中做爲閉包,即便這個方法沒有使用這個做用域中的任何一個變量,因此在這裏給全局做用域賦值的時候,someMethod做爲一個方法,也被綁定一個unused建立的閉包,且被賦值在全局做用域中的theThing上了。
  3. 若是這時候開始第一次GC,從全局對象進行Reachable分析:theThing(reachable),replaceThing(reachable),theThing->longStr(reachable),theThing->someMethod(reachable),execution stack -> setInterval -> closure -> originThing(reachable)。
    全部標記完成。此時:
from-space                                to-space

    theThing         (reachable)                theThing
    replaceThing     (reachable)                replaceThing
    unused                                      originThing
    originThing      (reachable)       =>       longStr  
    c                                           someMethod
    longStr          (reachable)                
    someMethod       (reachable)                
複製代碼
  1. 在過1000毫秒之後又執行replaceThing,又執行一遍步驟2
  2. 第二次GC開始
from-space                                to-space                           old-space

    theThing         (reachable)                theThing                             originThing -> theThing
    replaceThing     (reachable)                replaceThing                         theThing -> longStr
    unused                                      originThing                          theThing -> someMethod
    originThing      (reachable)       =>       longStr =>        someMethod -> originThing(closure)        
    c                                           someMethod
    longStr          (reachable)                
    someMethod       (reachable)                
複製代碼
  1. 由於閉包一直連着這originThing,致使了old-space中的originThing一直沒法釋放。隨着時間的推移,每一個1000毫秒執行一次replaceThing方法
old-space
    originThing -> theThing -> longStr & someMethod -> originThing(closure)
    originThing -> theThing -> longStr & someMethod -> originThing(closure)
    originThing -> theThing -> longStr & someMethod -> originThing(closure)
    originThing -> theThing -> longStr & someMethod -> originThing(closure)
    originThing -> theThing -> longStr & someMethod -> originThing(closure)
複製代碼

結論

主要致使內存泄漏的緣由是

閉包是在聲明的時候被建立的
閉包是在聲明的時候被建立的,而不是執行的時候被建立的。

而後致使在originalThing還引用着老的theThing,theThing中的someMethod引用着originalThing致使所有都reachable沒法釋放。

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var c = 'a'
  function unused() {
    if (originalThing) {
      console.log("hi");
    }
  };

  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('1111');
    }
  };

  originalThing = null;    //手動釋放局部做用域中的變量
};

setInterval(replaceThing,1000)

複製代碼
相關文章
相關標籤/搜索