【進階1-4期】JavaScript深入之帶你走進記憶體機制

木易楊說發表於2018-11-22

本期的主題是呼叫堆疊,本計劃一共28期,每期重點攻克一個面試重難點,如果你還不瞭解本進階計劃,文末點選檢視全部文章。

如果覺得本系列不錯,歡迎點贊、評論、轉發,您的支援就是我堅持的最大動力。


JS記憶體空間分為棧(stack)堆(heap)池(一般也會歸類為棧中)。 其中存放變數,存放複雜物件,存放常量,所以也叫常量池。

昨天文章介紹了堆和棧,小結一下:

  • 基本型別:–> 記憶體(不包含閉包中的變數)
  • 引用型別:–> 記憶體

今日補充一個知識點,就是閉包中的變數並不儲存中棧記憶體中,而是儲存在堆記憶體中,這也就解釋了函式之後之後為什麼閉包還能引用到函式內的變數。

function A() {
  let a = 1
  function B() {
      console.log(a)
  }
  return B
}
複製程式碼

閉包的簡單定義是:函式 A 返回了一個函式 B,並且函式 B 中使用了函式 A 的變數,函式 B 就被稱為閉包。

函式 A 彈出呼叫棧後,函式 A 中的變數這時候是儲存在堆上的,所以函式B依舊能引用到函式A中的變數。現在的 JS 引擎可以通過逃逸分析辨別出哪些變數需要儲存在堆上,哪些需要儲存在棧上。

閉包的介紹點到為止,【進階2期】 作用域閉包會詳細介紹,敬請期待。

今天文章的重點是記憶體回收記憶體洩漏

記憶體回收

JavaScript有自動垃圾收集機制,垃圾收集器會每隔一段時間就執行一次釋放操作,找出那些不再繼續使用的值,然後釋放其佔用的記憶體。

  • 區域性變數和全域性變數的銷燬
    • 區域性變數:區域性作用域中,當函式執行完畢,區域性變數也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收。
    • 全域性變數:全域性變數什麼時候需要自動釋放記憶體空間則很難判斷,所以在開發中儘量避免使用全域性變數。
  • 以Google的V8引擎為例,V8引擎中所有的JS物件都是通過來進行記憶體分配的
    • 初始分配:當宣告變數並賦值時,V8引擎就會在堆記憶體中分配給這個變數。
    • 繼續申請:當已申請的記憶體不足以儲存這個變數時,V8引擎就會繼續申請記憶體,直到堆的大小達到了V8引擎的記憶體上限為止。
  • V8引擎對堆記憶體中的JS物件進行分代管理
    • 新生代:存活週期較短的JS物件,如臨時變數、字串等。
    • 老生代:經過多次垃圾回收仍然存活,存活週期較長的物件,如主控制器、伺服器物件等。

垃圾回收演算法

對垃圾回收演算法來說,核心思想就是如何判斷記憶體已經不再使用,常用垃圾回收演算法有下面兩種。

  • 引用計數(現代瀏覽器不再使用)
  • 標記清除(常用)
引用計數

引用計數演算法定義“記憶體不再使用”的標準很簡單,就是看一個物件是否有指向它的引用。如果沒有其他物件指向它了,說明該物件已經不再需要了。

// 建立一個物件person,他有兩個指向屬性age和name的引用
var person = {
    age: 12,
    name: `aaaa`
};

person.name = null; // 雖然name設定為null,但因為person物件還有指向name的引用,因此name不會回收

var p = person; 
person = 1;         //原來的person物件被賦值為1,但因為有新引用p指向原person物件,因此它不會被回收

p = null;           //原person物件已經沒有引用,很快會被回收
複製程式碼

引用計數有一個致命的問題,那就是迴圈引用

如果兩個物件相互引用,儘管他們已不再使用,但是垃圾回收器不會進行回收,最終可能會導致記憶體洩露。

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1; 

    return "cycle reference!"
}

cycle();
複製程式碼

cycle函式執行完成之後,物件o1o2實際上已經不再需要了,但根據引用計數的原則,他們之間的相互引用依然存在,因此這部分記憶體不會被回收。所以現代瀏覽器不再使用這個演算法。

但是IE依舊使用。

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};
複製程式碼

上面的寫法很常見,但是上面的例子就是一個迴圈引用。

變數div有事件處理函式的引用,同時事件處理函式也有div的引用,因為div變數可在函式內被訪問,所以迴圈引用就出現了。

標記清除(常用)

標記清除演算法將“不再使用的物件”定義為“無法到達的物件”。即從根部(在JS中就是全域性物件)出發定時掃描記憶體中的物件,凡是能從根部到達的物件,保留。那些從根部出發無法觸及到的物件被標記為不再使用,稍後進行回收。

無法觸及的物件包含了沒有引用的物件這個概念,但反之未必成立。

所以上面的例子就可以正確被垃圾回收處理了。

所以現在對於主流瀏覽器來說,只需要切斷需要回收的物件與根部的聯絡。最常見的記憶體洩露一般都與DOM元素繫結有關:

email.message = document.createElement(“div”);
displayList.appendChild(email.message);

// 稍後從displayList中清除DOM元素
displayList.removeAllChildren();
複製程式碼

上面程式碼中,div元素已經從DOM樹中清除,但是該div元素還繫結在email物件中,所以如果email物件存在,那麼該div元素就會一直儲存在記憶體中。

記憶體洩漏

對於持續執行的服務程式(daemon),必須及時釋放不再用到的記憶體。否則,記憶體佔用越來越高,輕則影響系統效能,重則導致程式崩潰。 對於不再用到的記憶體,沒有及時釋放,就叫做記憶體洩漏(memory leak)

記憶體洩漏識別方法

1、瀏覽器方法
  1. 開啟開發者工具,選擇 Memory
  2. 在右側的Select profiling type欄位裡面勾選 timeline
  3. 點選左上角的錄製按鈕。
  4. 在頁面上進行各種操作,模擬使用者的使用情況。
  5. 一段時間後,點選左上角的 stop 按鈕,皮膚上就會顯示這段時間的記憶體佔用情況。
2、命令列方法

使用 Node 提供的 process.memoryUsage 方法。

console.log(process.memoryUsage());

// 輸出
{ 
  rss: 27709440,		// resident set size,所有記憶體佔用,包括指令區和堆疊
  heapTotal: 5685248,   // "堆"佔用的記憶體,包括用到的和沒用到的
  heapUsed: 3449392,	// 用到的堆的部分
  external: 8772 		// V8 引擎內部的 C++ 物件佔用的記憶體
}
複製程式碼

判斷記憶體洩漏,以heapUsed欄位為準。

詳細的JS記憶體分析將在【進階20期】效能優化詳細介紹,敬請期待。

WeakMap

ES6 新出的兩種資料結構:WeakSetWeakMap,表示這是弱引用,它們對於值的引用都是不計入垃圾回收機制的。

const wm = new WeakMap();
const element = document.getElementById(`example`);

wm.set(element, `some information`);
wm.get(element) // "some information"
複製程式碼

先新建一個 Weakmap 例項,然後將一個 DOM 節點作為鍵名存入該例項,並將一些附加資訊作為鍵值,一起存放在 WeakMap 裡面。這時,WeakMap 裡面對element的引用就是弱引用,不會被計入垃圾回收機制。

昨日思考題解答

昨天文章留了一道思考題,群裡討論很熱烈,大家應該都知道原理了,現在來簡單解答下。

var a = {n: 1};
var b = a;
a.x = a = {n: 2};

a.x 	// --> undefined
b.x 	// --> {n: 2}
複製程式碼

答案已經寫上面了,這道題的關鍵在於

  • 1、優先順序。.的優先順序高於=,所以先執行a.x,堆記憶體中的{n: 1}就會變成{n: 1, x: undefined},改變之後相應的b.x也變化了,因為指向的是同一個物件。
  • 2、賦值操作是從右到左,所以先執行a = {n: 2}a的引用就被改變了,然後這個返回值又賦值給了a.x需要注意的是這時候a.x是第一步中的{n: 1, x: undefined}那個物件,其實就是b.x,相當於b.x = {n: 2}
【進階1-4期】JavaScript深入之帶你走進記憶體機制

今日份思考題

問題一

從記憶體來看 null 和 undefined 本質的區別是什麼?

問題二

ES6語法中的 const 宣告一個只讀的常量,那為什麼下面可以修改const的值?

const foo = {}; 
foo = {}; // TypeError: "foo" is read-only
foo.prop = 123;
foo.prop // 123
複製程式碼

問題三

哪些情況下容易產生記憶體洩漏?

參考

JavaScript 記憶體機制

MDN之運算子優先順序

由ES規範學JavaScript(二):深入理解“連等賦值”問題

InterviewMap

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總:github.com/yygmind/blo…,內有優質前端資料,歡迎領取,覺得不錯點個star。

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階1-4期】JavaScript深入之帶你走進記憶體機制

相關文章