深入瞭解 JavaScript 記憶體洩漏

京東雲開發者發表於2023-03-27
作者:京東零售 謝天

在任何語言開發的過程中,對於記憶體的管理都非常重要,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附近。

相關文章