視覺化分析js的記憶體分配與回收

lucefer發表於2017-07-29

之前寫了一篇文章瀏覽器是怎麼看閉包的,發現有些讀者對js記憶體分配與回收懵懵懂懂,理解文章的配圖有些困難,我想主要是因為配圖省略了一些細節。今天專門寫一篇關於js記憶體分配回收的文章,幫助大家理解js程式碼的記憶體表示。原文備份在這裡

資料型別

先嘮叨些基本知識:

  • javascript的資料型別分為基本型別和引用型別(物件)。基本型別分為如下幾種:
    • 數字字面量
    • 字串字面量
    • 布林字面量
    • undefined
    • null
  • 引用型別分為如下幾種
    • 通過new的方式生成的物件
      • new Object()
      • new Array()
      • new RegExp()
      • new String()
      • new Number()
      • new Bollean()
      • new 自定義物件()
    • {},[],正則字面量,函式。

簡單物件的記憶體表示

我們都知道的是,javascript中值型別是在變數所在的記憶體單元中存放的,而對於引用型別的物件,變數所在的記憶體單元存放的是堆空間中物件的記憶體地址。我們還應該知道的是,函式在執行時,區域性變數是在棧空間中建立,引用物件是在堆空間中建立的。

我們還是從程式碼入手:

var a = 'abc'
var b = 123
var c = true
var d = undefined
var e = null
var f = {
  n: 'test'
}複製程式碼

這段程式碼我們定義了六個全域性變數,每個變數賦予不同型別的值,我們發現,a、b、c、d、e基本型別的值佔據一個記憶體單元,而變數f記憶體儲的是堆中物件的地址。如下圖表示:

變數f中儲存的0x00012345是堆中物件的記憶體地址。

一切都很容易理解。細心的同學也許會指出,null也是物件,通過typeof null 表示式得到的結果是'object'。關於這個,我想說的是typeof null = 'object' 這個現象是歷史遺留bug。事實上null是空值,並不是物件。

js的型別值有1-3位是表示型別,其它位表示真實值。
000: object. The data is a reference to an object.
也就是說,000開頭的被認為是指向物件引用。由於js中的null是空指標,在大多數平臺上空指標的前兩位是0x00,再加上null的數值表示是0,所以null的前三位是0x000,js引擎會認為它是指向物件的引用,這是一個歷史遺留bug。但事實上,null是空值。詳細解釋參見這裡

說到null,我們還要用圖形表示一下null所起到的作用。對於上面的程式碼,我們將引用型別f置為null,該變數將不再指向堆中物件。圖形表示如下:

你會發現,原本f指向堆物件的線消失了,堆中對應的物件不再被f引用。

看到這裡,你也許會問:咦,那沒有任何物件指向那個堆物件了,它還佔據記憶體嗎?如果還佔據的話,豈不是佔著茅坑不那啥嗎?我想,如果能想到這一點,說明你是一個有追求的js開發者。

是的,原本堆空間中的那個物件確實沒有引用了,js引擎會在下一個垃圾回收節點將它回收掉。

為了幫助大家更好的理解記憶體的分配與釋放,建議大家在看配圖的時候,一定要謹記箭頭的走向,認真看箭頭從哪個物件出發,又是指向哪個物件的。因為箭頭指向代表變數引用,而引用是垃圾回收器辨別記憶體垃圾的依據。什麼是垃圾呢?按照垃圾回收器的理解是,從根物件觸發,沿著箭頭指向,能夠找到的物件,都不應該判定為垃圾。相反,從根物件觸發,沿著箭頭指向,不能夠找到的物件,被判定為垃圾,將在下一個垃圾回收節點回收掉。
那麼,與之相伴的是記憶體洩漏,什麼叫記憶體洩漏呢?通俗一點講,就是某個物件已經不會再被我們用到,但是垃圾回收器卻發現從根物件仍然能夠找到它,所以不認為它是垃圾,因此不會回收它,但是它確實對我們沒有用處。這樣就造成了記憶體的浪費,這種現象稱作記憶體洩漏。

理解了判定記憶體垃圾的方式以及記憶體洩漏,我們就可以通過畫圖的方式來檢驗程式碼是否存在記憶體洩漏,程式碼是否健壯。

複雜物件的記憶體表示

上面程式碼中,f指向的物件是一個簡單物件,只包含一個屬性,如果堆中的是一個複雜物件,又該如何表示呢?讓我們繼續看程式碼

var a = {
  b: 123,
  c: 'abc',
  d: true,
  e: null,
  f: {
    h: 'test',
    j: {
      k: 567
    }
  }
}複製程式碼

我們定義了一個全域性變數a,指向堆記憶體中的一個複雜物件。如下圖:

全域性定義的變數是常駐記憶體的,為什麼常駐記憶體?我們從垃圾回收的角度分析一下:

  • 從根物件window沿著箭頭尋找,首先找到a。
  • 通過a能找到堆中最左側的大物件。
  • 通過最左側的物件中的變數f,能找到右側下方的物件。
  • 通過右側下方物件的變數j,能找到右側上方物件。

所以,全域性定義的變數a所關聯的物件是常駐記憶體的。
再次思考一下,我們如何讓垃圾回收器回收堆空間右側的那兩個物件呢?聰明的你也許想到了

a.f = null複製程式碼

是的,將a.f的指標置為null就可以了。我們從垃圾回收的角度分析一下,a.f = null這段程式碼執行以後,f變數中儲存的值變成了null,不再指向右側的兩個物件,按照我們之前的方法,從根物件window開始,沿著箭頭尋找,找不到右側物件,所以右側兩個物件成為記憶體垃圾,將被GC(垃圾回收器)回收掉。這就是當我們為一個變數賦值null之後,變數在記憶體中的變化。如下圖所示:

當然,我們也可以為變數賦予其他基礎型別的值,斷開和堆中物件的聯絡。

函式定義的記憶體表示

對於再複雜的物件,大家可以舉一反三。接下來,我們看一下函式的定義:

function say() {
  var a = '測試'
  var b = {
    c: 123
  }
}複製程式碼

可以看到函式物件指標在全域性變數區,函式本身在堆中存放,函式我只畫了了幾個常見的屬性。細心的你也許發現有個[[Socpes]]的屬性,以後講閉包的時候再對它作詳細介紹,這裡只大概介紹一下。

[[Scopes]]屬性是在函式建立的時候附加的屬性,代表該函式的作用域鏈。

函式執行時的記憶體表示

繼續看一段程式碼:

function say() {
  var a = '測試'
  var b = {
    c: 123
  }
}
say()複製程式碼

很簡單,我們定義一個函式,並執行它,變數的記憶體圖如下:

函式執行時,區域性變數分配在棧空間,此時,外部window物件與棧空間中的變數沒有引用關係。區域性變數a是值型別,在棧中存放,區域性變數b是引用型別,棧中存放物件在堆中的記憶體地址。

函式執行結束後的圖示如下:

函式執行結束,區域性變數由於沒有外部引用,所以全部釋放,同時堆中的物件也失去了引用,成為記憶體垃圾,被GC回收掉。

結語

至此,關於js程式碼的記憶體表示就先告一段落,通過畫圖的方式,希望大家能對程式的執行有感性的理解,也希望能幫助大家通過畫圖的方式去判定記憶體垃圾。另外,大家在看下一篇文章瀏覽器是怎麼看閉包的的時候,會發現有一些細節沒有表示,大家不要太過於糾結,只需注意箭頭走向即可。

相關文章