深入瞭解 JavaScript 內存泄漏

語言: CN / TW / HK

作者:京東零售 謝天

在任何語言開發的過程中,對於內存的管理都非常重要,JavaScript 也不例外。

然而在前端瀏覽器中,用户一般不會在一個頁面停留很久,即使有一點內存泄漏,重新加載頁面內存也會跟着釋放。而且瀏覽器也有自己的自動回收內存的機制,所以前端並沒有特別關注內存泄漏的問題。

但是如果我們對內存泄漏沒有什麼概念,有時候還是有可能因為內存泄漏,導致頁面卡頓。瞭解內存泄漏,如何避免內存泄漏,都是不可缺少的。

什麼是內存

在硬件級別上,計算機內存由大量觸發器組成。每個觸發器包含幾個晶體管,能夠存儲一個位。單個觸發器可以通過唯一標識符尋址,因此我們可以讀取和覆蓋它們。因此,從概念上講,我們可以把我們的整個計算機內存看作是一個巨大的位數組,我們可以讀和寫。

這是內存的底層概念,JavaScript 作為一個高級語言,不需要通過二進制進行內存的讀寫,而是相關的 JavaScript 引擎做了這部分的工作。

內存的生命週期

內存也會有生命週期,不管什麼程序語言,一般可以按照順序分為三個週期:

  • 分配期:分配所需要的內存

  • 使用期:使用分配的內存進行讀寫

  • 釋放期:不需要時將其釋放和歸還

內存分配 -> 內存使用 -> 內存釋放

什麼是內存泄漏

在計算機科學中,內存泄漏指由於疏忽或錯誤造成程序未能釋放已經不再使用的內存。內存泄漏並非指內存在物理上的消失,而是應用程序分配某段內存後,由於設計錯誤,導致在釋放該段內存之前就失去了對該段內存的控制,從而造成了內存的浪費。

如果內存不需要時,沒有經過生命週期的的釋放期,那麼就存在內存泄漏

內存泄漏的簡單理解:無用的內存還在佔用,得不到釋放和歸還。比較嚴重時,無用的內存會持續遞增,從而導致整個系統的卡頓,甚至崩潰。

JavaScript 內存管理機制

像 C 語言這樣的底層語言一般都有底層的內存管理接口,但是 JavaScript 是在創建變量時自動進行了內存分配,並且在不使用時自動釋放,釋放的過程稱為“垃圾回收”。然而就是因為自動回收的機制,讓我們錯誤的感覺開發者不必關心內存的管理。

JavaScript 內存管理機制和內存的生命週期是一致的,首先需要分配內存,然後使用內存,最後釋放內存。絕大多數情況下不需要手動釋放內存,只需要關注對內存的使用(變量、函數、對象等)。

內存分配

JavaScript 定義變量就會自動分配內存,我們只需要瞭解 JavaScript 的內存是自動分配的就可以了。

let num = 1;
const str = "名字";
const obj = {
  a: 1,
  b: 2
}
const arr = [1, 2, 3];
function func (arg) { ... }



內存使用

使用值的過程實際上是對分配的內存進行讀寫的操作,讀取和寫入的操作可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。

// 繼續上部分
// 寫入內存
num = 2;
// 讀取內存,寫入內存
func(num);



內存回收

垃圾回收被稱為GC(Garbage Collection)

內存泄漏一般都是發生在這一步,JavaScript 的內存回收機制雖然可以回收絕大部分的垃圾內存,但是還是存在回收不了的情況,如果存在這些情況,需要我們自己手動清理內存。

以前一些老版本的瀏覽器的 JavaScript 回收機制沒有那麼完善,經常出現一些 bug 的內存泄漏,不過現在的瀏覽器一般都沒有這個問題了。

這裏瞭解下現在 JavaScript 的垃圾內存的兩種回收方式,熟悉一下這兩種算法可以幫助我們理解一些內存泄漏的場景。

引用計數

這是最初級的垃圾收集算法。此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。

// “對象”分配給 obj1
var obj1 = {
  a: 1,
  b: 2
}
// obj2 引用“對象”
var obj2 = obj1;
// “對象”的原始引用 obj1 被 obj2 替換
obj1 = 1;



當前執行環境中,“對象”內存還沒有被回收,需要手動釋放“對象”的內存(在沒有離開當前執行環境的前提下)

obj2 = null;
// 或者 obj2 = 1;
// 只要替換“對象”就可以了



這樣引用的“對象”內存就被回收了。

ES6 中把引用分為強引用弱引用,這個目前只有在 Set 和 Map 中才存在。

強引用才會有引用計數疊加,只有引用計數為 0 的對象的內存才會被回收,所以一般需要手動回收內存(手動回收的前提在於標記清除法還沒執行,還處於當前的執行環境)。

而弱引用沒有觸發引用計數疊加,只要引用計數為 0,弱引用就會自動消失,無需手動回收內存。

標記清除

當變量進入執行時標記為“進入環境”,當變量離開執行環境時則標記為“離開環境”,被標記為“進入環境”的變量是不能被回收的,因為它們正在被使用,而標記為“離開環境”的變量則可以被回收。

環境可以理解為我們的執行上下文,全局作用域的變量只會在頁面關閉時才會被銷燬。

// 假設這裏是全局上下文
var b = 1; // b 標記進入環境
function func() {
  var a = 1;
  return a + b; // 函數執行時,a 被標記進入環境
}
func();
// 函數執行結束,a 被標記離開環境,被回收
// 但是 b 沒有標記離開環境



JavaScript 內存泄漏的一些場景

JavaScript 的內存回收機制雖然能回收絕大部分的垃圾內存,但是還是存在回收不了的情況。程序員要讓瀏覽器內存泄漏,瀏覽器也是管不了的。

下面有些例子是在執行環境中,沒離開當前執行環境,還沒觸發標記清除法。所以你需要讀懂上面 JavaScript 的內存回收機制,才能更好的理解下面的場景。

意外的全局變量

// 在全局作用域下定義
function count(num) {
  a = 1; // a 相當於 window.a = 1;
  return a + num;
}



不過在 eslint 幫助下,這種場景現在基本沒人會犯了,eslint 會直接報錯,瞭解下就好。

遺忘的計時器

無用的計時器忘記清理,是最容易犯的錯誤之一。

拿一個 vue 組件舉個例子。

<script>
export default {
  mounted() {
    setInterval(() => {
      this.fetchData();
    }, 2000);
  },
  methods: {
    fetchData() { ... }
  }
}
</script>



上面的組件銷燬的時候,setInterval還是在運行的,裏面涉及到的內存都是沒法回收的(瀏覽器會認為這是必須的內存,不是垃圾內存),需要在組件銷燬的時候清除計時器。

<script>
export default {
  mounted() {
    this.timer = setInterval(() => { ... }, 2000);
  },
  beforeDestroy() {
    clearInterval(this.timer);
  }
}
</script>



遺忘的事件監聽

無用的事件監聽器忘記清理也是最容易犯的錯誤之一。

還是使用 vue 組件舉個例子。

<script>
export default {
  mounted() {
    window.addEventListener('resize', () => { ... });
  }
}
</script>



上面的組件銷燬的時候,resize 事件還是在監聽中,裏面涉及到的內存都是沒法回收的,需要在組件銷燬的時候移除相關的事件。

<script>
export default {
  mounted() {
    this.resizeEvent = () => { ... };
    window.addEventListener('resize', this.resizeEvent);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.resizeEvent);
  }
}
</script>



遺忘的 Set 結構

Set 是 ES6 中新增的數據結構,如果對 Set 不熟,可以看這裏

如下是有內存泄漏的(成員是引用類型,即對象):

let testSet = new Set();
let value = { a: 1 };
testSet.add(value);
value = null;



需要改成這樣,才會沒有內存泄漏:

let testSet = new Set();
let value = { a: 1 };
testSet.add(value);

testSet.delete(value);
value = null;



有個更便捷的方式,使用 WeakSet,WeakSet 的成員是弱引用,內存回收不會考慮這個引用是否存在。

let testSet = new WeakSet();
let value = { a: 1 };
testSet.add(value);
value = null;



遺忘的 Map 結構

Map 是 ES6 中新增的數據結構,如果對 Map 不熟,可以看這裏

如下是有內存泄漏的(成員是引用類型,即對象):

let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
key = null;



需要改成這樣,才會沒有內存泄漏:

let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);

map.delete(key);
key = null;



有個更便捷的方式,使用 WeakMap,WeakMap 的鍵名是弱引用,內存回收不會考慮到這個引用是否存在。

let map = new WeakMap();
let key = [1, 2, 3];
map.set(key, 1);
key = null



遺忘的訂閲發佈

和上面事件監聽器的道理是一樣的。

建設訂閲發佈事件有三個方法,emitonoff三個方法。

還是繼續使用 vue 組件舉例子:

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {
  mounted() {
    EventEmitter.on('test', () => { ... });
  },
  methods: {
    onClick() {
      EventEmitter.emit('test');
    }
  }
}
</script>



上面組件銷燬的時候,自定義 test 事件還是在監聽中,裏面涉及到的內存都是沒辦法回收的,需要在組件銷燬的時候移除相關的事件。

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {
  mounted() {
    EventEmitter.on('test', () => { ... });
  },
  methods: {
    onClick() {
      EventEmitter.emit('test');
    }
  },
  beforeDestroy() {
    EventEmitter.off('test');
  }
}
</script>



遺忘的閉包

閉包是經常使用的,閉包能提供很多的便利,

首先看下下面的代碼:

function closure() {
  const name = '名字';
  return () => {
    return name.split('').reverse().join('');
  }
}
const reverseName = closure();
reverseName(); // 這裏調用了 reverseName



上面有沒有內存泄漏?是沒有的,因為 name 變量是要用到的(非垃圾),這也是從側面反映了閉包的缺點,內存佔用相對高,數量多了會影響性能。

但是如果reverseName沒有被調用,在當前執行環境未結束的情況下,嚴格來説,這樣是有內存泄漏的,name變量是被closure返回的函數調用了,但是返回的函數沒被使用,在這個場景下name就屬於垃圾內存。name不是必須的,但是還是佔用了內存,也不可被回收。

當然這種也是極端情況,很少人會犯這種低級錯誤。這個例子可以讓我們更清楚的認識內存泄漏。

DOM 的引用

每個頁面上的 DOM 都是佔用內存的,建設有一個頁面 A 元素,我們獲取到了 A 元素 DOM 對象,然後賦值到了一個變量(內存指向是一樣的),然後移除了頁面上的 A 元素,如果這個變量由於其他原因沒有被回收,那麼就存在內存泄漏,如下面的例子:

class Test {
  constructor() {
    this.elements = {
      button: document.querySelector('#button'),
      div: document.querySelector('#div')
    }
  }
  removeButton() {
    document.body.removeChild(this.elements.button);
    // this.elements.button = null
  }
}

const test = new Test();
test.removeButton();



上面的例子 button 元素雖然在頁面上移除了,但是內存指向換成了this.elements.button,內存佔用還是存在的。所以上面的代碼還需要這麼寫:this.elements.button = null,手動釋放內存。

如何發現內存泄漏

內存泄漏時,內存一般都是週期性的增長,我們可以藉助谷歌瀏覽器的開發者工具進行判斷。

這裏針對下面的例子進行一步步的的排查和找到問題點:

<html>
  <body>
    <div id="app">
      <button id="run">運行</button>
      <button id="stop">停止</button>
    </div>
    <script>
      const arr = []
      for (let i = 0; i < 200000; i++) {
        arr.push(i)
      }
      let newArr = []

      function run() {
        newArr = newArr.concat(arr)
      }

      let clearRun

      document.querySelector('#run').onclick = function() {
        clearRun = setInterval(() => {
          run()
        }, 1000)
      }

      document.querySelector('#stop').onclick = function() {
        clearInterval(clearRun)
      }
    </script>
  </body>
</html>



確實是否是內存泄漏問題

訪問上面的代碼頁面,打開開發者工具,切換至 Performance 選項,勾選 Memory 選項。

在頁面上點擊運行按鈕,然後在開發者工具上面點擊左上角的錄製按鈕,10 秒後在頁面上點擊停止按鈕,5 秒停止內存錄制。得到內存走勢如下:

由上圖可知,10 秒之前內存週期性增長,10 秒後點擊了停止按鈕,內存平穩,不再遞增。我們可以使用內存走勢圖判斷是否存在內存泄漏。

查找內存泄漏的位置

上一步確認內存泄漏問題後,我們繼續利用開發者工具進行問題查找。

訪問上面的代碼頁面,打開開發者工具,切換至 Memory 選項。頁面上點擊運行按鈕,然後點擊開發者工具左上角的錄製按鈕,錄製完成後繼續點擊錄製,直到錄製完成三個為止。然後點擊頁面上的停止按鈕,在連續錄製三次內存(不要清理之前的錄製)。

從這裏也可以看出,點擊運行按鈕之後,內存在不斷的遞增。點擊停止按鈕之後,內存就平穩了。雖然我們也可以用這種方式來判斷是否存在內存泄漏,但是沒有第一步的方法便捷,走勢圖也更加直觀。

然後第二步的主要目的是為了記錄 JavaScript 堆內存,我們可以看到哪個堆佔用的內存更高。

從內存記錄中,發現 array 對象佔用最大,展開後發現,第一個object elements佔用最大,選擇這個 object elements 後可以在下面看到newArr變量,然後點擊後面的高亮鏈接,就可以跳轉到newArr附近。