深入JavaScript系列(五):JS與記憶體

Logan70發表於2018-12-24

一、記憶體是什麼

我們現在常用的計算機都屬於 馮·諾依曼體系計算機, 計算機硬體由 控制器、運算器、儲存器、輸入裝置、輸出裝置 五大部分組成。

我們通常所說的記憶體就是 儲存器

常用的記憶體都是易失性儲存器(需要通過不斷加電重新整理來保持資料,一旦斷電就會導致資料丟失),所以需要一種容量大、低成本的非易失性儲存器來進行資料的儲存,這就是外存,例如磁帶、軟盤、硬碟、光碟、快閃記憶體卡、U盤等。可以將外存理解為輸入輸出裝置,因為外存是需要通過I/O介面進行資料存取的,而記憶體是由CPU直接定址的。外存中的程式需要通過I/O介面調入記憶體中才可以執行。

記憶體就是程式執行的地方,其實程式本質上就是指令和資料的集合。所以說記憶體是指令和資料的臨時儲存器,然後CPU對記憶體中的指令和資料進行處理。

二、記憶體的使用

不管什麼程式語言,其執行都依賴記憶體,記憶體生命週期基本是一致的:

  1. 分配所需要的記憶體
  2. 使用分配到的記憶體(讀、寫)
  3. 不需要時將其釋放\歸還

在JavaScript中,第一步和第三步由js引擎完成的,對於程式設計人員是隱藏的。但是這並不意味著我們不需要了解JavaScript中的記憶體機制,瞭解記憶體機制有助於我們寫出更優雅、效能更好的程式碼。

三、JavaScript的記憶體模型

JavaScript資料型別有基本型別和引用型別兩大類,基本型別有Undefined、Null、Boolean、Number、String、Symbol六中,引用型別有Object,所有的JavaScript變數值將會是七種的其中之一。這些資料型別在記憶體中是怎樣儲存的?我們來看一下JavaScript的記憶體模型。

說是JavaScript的記憶體模型其實不太準確,只是便於理解。由於JavaScript中的記憶體分配是由js引擎完成的,所以更準確的描述是js引擎的記憶體模型

一個執行中的程式總是與記憶體中的一部分空間相對應。這部分空間叫做 Resident Set (駐留集)。V8(一種JS引擎) 組織記憶體的方式如下圖:

深入JavaScript系列(五):JS與記憶體

各部分作用如下:

  • Code Segment : 存放正在被執行的程式碼
  • Stack : 棧記憶體,存放識別符號、基本型別值及引用型別變數的堆地址
  • Heap : 堆記憶體,存放引用型別值

為什麼記憶體要如此分配?

  • 基本型別變數:識別符號與值都存放在棧記憶體中(資料大小固定,由系統自動分配記憶體空間)。
  • 引用型別變數:棧記憶體中存放識別符號與指向堆記憶體中值的地址,堆記憶體中存放具體值(資料大小可變,例如物件可隨意增刪屬性,分配記憶體的大小取決於程式碼)。

深入JavaScript系列(五):JS與記憶體

四、變數傳遞

看到有些文章中說基本型別變數複製按值傳遞,引用型別變數複製按引用傳遞,又有的說引用型別變數複製按共享傳遞。總之對新手不太友好,這裡我們站在記憶體層面來解釋就比較好解釋了。

我們可以理解為JavaScript變數的拷貝都是按棧記憶體內的值傳遞,這裡棧記憶體內的值對於基本型別變數來說就是其值,對於引用型別來說就是一個指向堆記憶體中實際值的地址。

我們來看一個簡單的例子理解一下:

let p1 = {name: 'logan'}
let p2 = p1
// p1 和 p2 在棧記憶體中存放的引用地址相同,都指向堆記憶體中存放物件 {name: 'logan'}
// 但是這兩個引用地址卻是相互獨立的,並不存在引用關係
複製程式碼

深入JavaScript系列(五):JS與記憶體

// 本質上是對堆記憶體中的物件進行修改,所以會同時影響p1和p2
p2.name = 'jason'
console.log(p1) // 輸出:{name: 'jason'}
console.log(p2) // 輸出:{name: 'jason'}
複製程式碼

深入JavaScript系列(五):JS與記憶體

// 這一步是直接修改了棧記憶體內識別符號p2對應值,並不會影響p1
p2 = 3
console.log(p1) // 輸出:{name: 'jason'}
複製程式碼

深入JavaScript系列(五):JS與記憶體

函式的引數傳遞與變數複製傳遞表現一致,也是按棧記憶體內的值進行傳遞,因為本質上來說,函式傳參就是把傳入的實參拷貝賦值給形參。

五、垃圾回收

垃圾回收是一種記憶體管理機制,就是將不再用到的記憶體及時釋放,以防記憶體佔用越來越高,導致卡頓甚至程式崩潰。

在JavaScript中記憶體垃圾回收是由js引擎自動完成的。實現垃圾回收的關鍵在於如何確定記憶體不再使用,也就是確定物件是否無用。主要有兩種方式:引用計數標記清除

1. 引用計數(reference counting)

這是IE6、7採用的一種比較老的垃圾回收機制。引用計數確定物件是否無用的方法是物件是否被引用。如果沒有引用指向物件,物件就可以被回收。我們結合程式碼來理解:

// 堆記憶體建立了一個物件{a: 1},我們記為ObjA,變數obj1指向ObjA,ObjA引用次數為1
let obj1 = {
    a: 1
}
// obj2 拷貝 obj1 的地址,也指向ObjA,ObjA引用次數為2
let obj2 = obj1
// 解除obj1對ObjA的引用,ObjA引用次數減一,為1
obj1 = 3
// 解除obj2對ObjA的引用,ObjA引用次數減一,為0,可以被回收
obj2 = 'logan'
複製程式碼

缺點:無法處理迴圈引用

什麼意思呢,我們結合程式碼理解,先看正常情況下引用計數的工作:

function func() {
    // 堆記憶體建立物件{a: 1},記為ObjA,變數foo指向ObjA,ObjA引用次數為1
    let foo = {a: 1}
    // 堆記憶體建立空物件,記為ObjB,變數bar指向ObjB,ObjB引用次數為1
    let bar = {}
    // 其屬性x指向ObjA,ObjA引用次數為2
    bar.x = foo
    
    // 當函式執行完畢返回時
    // 變數bar生命週期結束,ObjB引用次數減一,為0,可被回收,故對其內部進行回收
    // bar.x生命週期結束,ObjA引用次數減一,為1
    // 變數foo生命週期結束,ObjA引用次數減一,為0,可被回收
}
複製程式碼

但是如果兩個物件之間存在迴圈引用,引用計數就會無法處理:

function func() {
    // 堆記憶體建立物件{a: 1},記為ObjA,變數foo指向ObjA,ObjA引用次數為1
    let foo = {a: 1}
    // 堆記憶體建立空物件,記為ObjB,變數bar指向ObjB,ObjB引用次數為1
    let bar = {}
    // 變數foo屬性x指向ObjB,ObjB引用次數為2
    foo.x = bar
    // 變數bar屬性x指向ObjA,ObjA引用次數為2
    bar.x = foo
    
    // 當函式執行完畢返回時
    // 變數bar生命週期結束,ObjB引用次數減一,為1,不可被回收
    // 變數foo生命週期結束,ObjA引用次數減一,為1,不可被回收
}
複製程式碼

優點:確定性

引用計數其實也是有優點的,那就是物件一定會在最後一個引用失效的時候銷燬,也就是說垃圾回收的時機在程式碼內是可控的,所以對於對延時比較敏感的場合比較適用。

2. 標記清除(mark and sweep)

從 2012 年起,所有現代瀏覽器都使用了標記清除的垃圾回收方法。

標記清除的工作原理簡化後就是:從垃圾收集根(root)物件(在JavaScript中為全域性環境記錄)開始,標記出所有可以獲得的物件,然後清除掉所有未標記的不可獲得的物件。

也就是說,標記清除確定物件是否無用的方法是物件是否可以被獲得

深入JavaScript系列(五):JS與記憶體

現代瀏覽器對JavaScript垃圾回收演算法的改進都是基於標記清除演算法的改進,並沒有改進標記清除演算法本身和它對“物件是否可以被獲得”的簡化定義。

關於垃圾回收的更多內容,可閱讀淺談V8引擎中的垃圾回收機制

六、記憶體洩漏

記憶體洩漏(Memory Leak) 是指程式中己動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。

說到記憶體洩漏,不得不提一些文章內說閉包會造成記憶體洩漏,要儘量少用。其實這個觀點是錯誤的,我們運用閉包說到底就兩點目的:一是變數私有化,二是延長變數生命週期。 所以說 閉包並不會造成記憶體洩漏,而是正常的記憶體使用。

如何避免記憶體洩漏?一句話:及時解除無用引用。 例如不再需要的閉包、定時器及全域性變數等。說到底還是個人程式設計習慣的好壞,多說無益,列太多的條條框框反而顯得繁瑣。

識別記憶體洩漏

  1. 開啟Chrome瀏覽器開發者工具的Performance皮膚
  2. 選項欄中勾選Memory選項
  3. 點選左上角錄製按鈕(實心圓狀按鈕)
  4. 在頁面上進行正常操作
  5. 一段時間後,點選Stop,觀察皮膚上的資料

深入JavaScript系列(五):JS與記憶體

如圖所示,記憶體佔用如果整體平穩,說明不存在記憶體洩漏。

深入JavaScript系列(五):JS與記憶體

如果記憶體佔用只升不降,或者整體呈一直升高的趨勢,說明存在記憶體洩漏。

記憶體洩漏定位

如果發現頁面存在記憶體洩漏,我們可以在下方記憶體圖點選對應的記憶體異常處,然後點選下方皮膚內的Event Log皮膚,可以檢視程式碼內具體發生了什麼,見下圖:

深入JavaScript系列(五):JS與記憶體

我們發現原來是呼叫了grow函式

let x = []
function grow() {
    x.push(new Array(1000000).join('x'))
}
document.getElementsByClassName('title-h2')[0].addEventListener('click', grow)
複製程式碼

當然,上面的程式碼只是為了模擬,究竟是否為記憶體洩漏要看變數x我們是否需要用到,一旦不需要,我們應該解除其引用。

系列文章

深入ECMAScript系列目錄地址(持續更新中...)

歡迎前往閱讀系列文章,如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

菜鳥一枚,如果有疑問或者發現錯誤,可以在相應的 issues 進行提問或勘誤,與大家共同進步。

相關文章