從JS中的內存管理提及 —— JS中的弱引用

本文首發於公衆號:符合預期的CoyPanjavascript

寫在前面

在全部的編程語言中,咱們聲明一個變量時,須要系統爲咱們分配一塊內存。當咱們再也不須要這個變量時,須要將內存進行回收(這個過程稱之爲垃圾回收)。在C語言中,有malloc和free來協助咱們進行內存管理。在JS中,開發者不須要手動進行內存管理,JS引擎會爲咱們自動作這些事情。可是,這並不意味着咱們在使用JS進行編碼時,不須要關心內存問題。java

JS中的內存分配與變量

內存聲明週期以下:react

  1. 分配你所須要的內存
  2. 使用分配到的內存(讀、寫)
  3. 不須要時將其釋放

在JS中,這三步都是對開發者無感的,不須要咱們過多的關心。面試

咱們須要注意的是,當咱們聲明一個變量、獲得一塊內存時,須要正確區分一個變量究竟是一個基本類型仍是引用類型。算法

基本類型:String,Number,Boolean,Null,Undefined,Symbol編程

引用類型:Object,Array,Function數組

對於基本類型變量來講,系統會爲其分配一塊內存,這塊內存中保存的,就是變量的內容。瀏覽器

對於引用類型變量來講,其存儲的只是一個地址而已,這個地址指向的內存塊纔是是變量的真正內容。引用變量的賦值,也只是把地址進行傳遞(複製)。舉個例子:markdown

// a 和 b 指向同一塊內存
var a = [1,2,3];
var b = a;
a.push(4);
console.log(b); // [1,2,3,4]
複製代碼

還有一點須要注意,JS中的函數傳參,實際上是按值傳遞(按引用傳遞)。舉個例子:react-router

// 函數f的入參,實際上是把 a 的值複製了一份。注意 a 是一個引用類型變量,其保存的是一個指向內存塊的一個地址。
function f(obj) {
	obj.b = 1;
}
var a = { a : 1};
f(a);
console.log(a); // { a: 1, b: 1}
複製代碼

在平時的開發中,徹底理解JS中變量的存儲方式是十分重要的。對於我本身來講,儘可能避免把引用類型變量處處傳遞,可能一不當心在某個地方修改了變量,另外一個地方邏輯沒有判斷好,很容易出Bug,特別是在項目複雜度較高,且多人開發時。這也是我比較喜歡使用純函數的緣由。

另外,根據我以前的面試經驗,有很多的小夥伴認爲下面的代碼會報錯,這也是對JS中變量存儲方式掌握不熟致使的。

// const 聲明一個不可改變的變量。 
// a 存儲的只是數組的內存地址而已,a.push 並不會改變 a 的值。
const a = [];
a.push('1'); 
console.log(a); // ['1']
複製代碼

JS中的垃圾回收

垃圾回收算法主要依賴於引用的概念。在內存管理的環境中,一個對象若是有訪問另外一個對象的權限(隱式或者顯式),叫作一個對象引用另外一個對象。例如,一個Javascript對象具備對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。

在這裏,「對象」的概念不只特指 JavaScript 對象,還包括函數做用域(或者全局詞法做用域)。當變量再也不須要時,JS引擎會把變量佔用的內存進行回收。可是怎麼界定【變量再也不須要】呢?主要有兩種方法。

引用計數算法

把「對象是否再也不須要」簡化定義爲「對象有沒有其餘對象引用到它」。若是沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。MDN上的例子:

var o = { 
  a: {
    b:2
  }
}; 
// 兩個對象被建立,一個做爲另外一個的屬性被引用,另外一個被分配給變量o
// 很顯然,沒有一個能夠被垃圾收集


var o2 = o; // o2變量是第二個對「這個對象」的引用

o = 1;      // 如今,「這個對象」只有一個o2變量的引用了,「這個對象」的原始引用o已經沒有

var oa = o2.a; // 引用「這個對象」的a屬性
               // 如今,「這個對象」有兩個引用了,一個是o2,一個是oa

o2 = "yo"; // 雖然最初的對象如今已是零引用了,能夠被垃圾回收了
           // 可是它的屬性a的對象還在被oa引用,因此還不能回收

oa = null; // a屬性的那個對象如今也是零引用了
           // 它能夠被垃圾回收了
複製代碼

這種方法有一個侷限性,那就是沒法處理循環引用。在下面的例子中,兩個對象被建立,並互相引用,造成了一個循環。它們被調用以後會離開函數做用域,因此它們已經沒有用了,能夠被回收了。然而,引用計數算法考慮到它們互相都有至少一次引用,因此它們不會被回收。

// 這種狀況下,o和o2都沒法被回收。
function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}
f();
複製代碼

標記-清除算法

這個算法假定設置一個叫作根(root)的對象(在Javascript裏,根是全局對象)。垃圾回收器將按期從根開始,找全部從根開始引用的對象,而後找這些對象引用的對象……從根開始,垃圾回收器將找到全部能夠得到的對象和收集全部不能得到的對象。

關於JS中的垃圾回收算法,網上已經有不少的文章講解,這裏再也不進行贅述。

JS中的內存泄露

儘管JS爲咱們自動處理內存的分配、回收問題,可是在某些特定的場景下,JS的垃圾回收算法並不能幫咱們去除已經再也不使用的內存。這種【因爲疏忽或錯誤形成程序未能釋放已經再也不使用的內存】的現象,被稱做內存泄露。

內存佔用愈來愈高,輕則影響系統性能,重則致使進程崩潰。

可能產生內存泄露的場景有很多,包括全局變量,DOM事件,定時器等等。

下面是一段存在內存泄露的示例代碼:

class Page1 extends React.Component {

    events= []

    componentDidMount() {
        window.addEventListener('scroll', this.handleScroll.bind(this));
    }

    render() {
        return <div> <div><Link to={'/page2'}>前往Page2</Link></div> <p>page1</p> .... </div>
    }

    handleScroll(e) {
        this.events.push(e);
    }
}
複製代碼

當咱們點擊按鈕跳轉到Page2後,在page2不停進行滾動操做,咱們會發現內存佔用不斷的上漲:

產生這個內存泄露的緣由是:咱們在Page1被unmount的時候,儘管Page1被銷燬了,可是Page1的滾動回調函數經過eventListener依然可「觸達」,因此不會被垃圾回收。進入Page2後,滾動事件的邏輯依然生效,內部的變量沒法被GC。若是用戶在Page2進行長時間滑動等操做,頁面會逐漸變得卡頓。

上述的例子,在咱們開發的過程當中,並很多見。不只僅是事件綁定,也有多是定時上報邏輯等等。如何解決呢?記得在unmount的時候,進行相應的取消操做便可。

在平時的項目開發中,內存泄露還有不少其餘的場景。瀏覽器頁面還好,畢竟一直開着某個頁面的用戶不算太多,刷新就好。而Node.js發生內存泄露的後果就比較嚴重了,可能服務就直接崩潰了。掌握JS的變量存儲方式、內存管理機制,養成良好的編碼習慣,能夠幫助咱們減小內存泄露的發生。

JS中的弱引用

前面咱們講到了JS的垃圾回收機制,若是咱們持有對一個對象的引用,那麼這個對象就不會被垃圾回收。這裏的引用,指的是強引用

在計算機程序設計中,還有一個弱引用的概念: 一個對象若只被弱引用所引用,則被認爲是不可訪問(或弱可訪問)的,並所以可能在任什麼時候刻被回收。

在JS中,WeakMap 和 WeakSet 給咱們提供了弱引用的能力。

WeakMap 、WeakSet

要說WeakMap,先來講一說Map。Map 對象保存鍵值對,而且可以記住鍵的原始插入順序。任何值(對象或者原始值) 均可以做爲一個鍵或一個值。

Map對對象是強引用:

const m = new Map();
let obj = { a: 1 };
m.set(obj, 'a');
obj = null; // 將obj置爲null並不會使 { a: 1 } 被垃圾回收,由於還有map引用了 { a: 1 }
複製代碼

WeakMap是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。WeakMap是對對象的弱引用:

const wm = new WeakMap();
let obj = { b: 2 };
wm.set(obj, '2');
obj = null; // 將obj置爲 null 後,儘管 wm 依然引用了{ b: 2 },可是因爲是弱引用,{ b: 2 } 會在某一時刻被GC。
複製代碼

正因爲這樣的弱引用,WeakMap 的 key 是不可枚舉的 (沒有方法能給出全部的 key)。若是key 是可枚舉的話,其列表將會受垃圾回收機制的影響,從而獲得不肯定的結果。

WeakSet能夠視爲 WeakMap 中全部值都是布爾值的一個特例,這裏就再也不贅述了。

JavaScript 的 WeakMap 並不是真正意義上的弱引用:實際上,只要鍵仍然存活,它就強引用其內容。WeakMap 僅在鍵被垃圾回收以後,才弱引用它的內容。這種關係更準確地稱爲 ephemeron

WeakRef

WeakRef是一個更高級的API,它提供了真正的弱引用。咱們直接藉助上文的內存泄露的例子來看一看WeakRef的效果:

import React from 'react';
import { Link } from 'react-router-dom';

// 使用WeakRef將回調函數「包裹」起來,造成對回調函數的弱引用。
function addWeakListener(listener) {
    const weakRef = new WeakRef(listener);
    const wrapper = e => {
        if (weakRef.deref()) {
            return weakRef.deref()(e);
        }
    }
    window.addEventListener('scroll', wrapper);
}

class Page1 extends React.Component {

    events= []

    componentDidMount() {
        addWeakListener(this.handleScroll.bind(this));
    }

    componentWillUnmount() {
        console.log(this.events);
    }

    render() {
        return <div> <div><Link to={'/page2'}>前往Page2</Link></div> <p>page1</p> .... </div>
    }

    handleScroll(e) {
        this.events.push(e);
    }
}


export default Page1;
複製代碼

咱們再來看看點擊按鈕跳轉到page2後的內存表現:

能夠很直觀的看到,在跳轉到page2後,持續滾動一段時間後,內存平穩。這是由於隨着page1被unmount,真正的滾動回調函數( Page1的 handleScroll 函數)被GC掉了。其內部的變量也最終被GC。

但其實,這裏還有一個問題,雖然咱們經過weakRef.deref() 拿不到 handleScroll 滾動回調函數了(已被GC),可是咱們的包裹函數 wrapper 依然會執行。由於咱們沒有執行removeEventListener。理想狀況是:咱們但願滾動監聽函數也被取消掉。

能夠藉助FinalizationRegistry來實現這個功能。看下面的示例代碼:

// FinalizationRegistry構造函數接受一個回調函數做爲參數,返回一個示例。咱們把實例註冊到某個對象上,當該對象被GC時,回調函數會觸發。
const gListenersRegistry = new FinalizationRegistry(({ window, wrapper }) => {
    console.log('GC happen!!');
    window.removeEventListener('scroll', wrapper);
});

function addWeakListener(listener) {
    const weakRef = new WeakRef(listener);
    const wrapper = e => {
        console.log('scroll');
        if (weakRef.deref()) {
            return weakRef.deref()(e);
        }
    }
    // 新增這行代碼,當listener被GC時,會觸發回調函數。回調函數傳參由咱們本身控制。
    gListenersRegistry.register(listener, { window, wrapper });
    window.addEventListener('scroll', wrapper);
}
複製代碼

WeakRef 和 FinalizationRegistry 屬於高級Api,在Chrome v84 和 Node.js 13.0.0 後開始支持。通常狀況下不建議使用。由於容易用錯,致使更多的問題。

寫在後面

本文從JS中的內存管理講起,說到了JS中的弱引用。雖然JS引擎幫咱們處理了內存管理問題,可是咱們在業務開發中並不能徹底忽視內存問題,特別是在Node.js的開發中。

關於V8的內存策略的更多細節,能夠移步我以前翻譯的一篇文章:

V8引擎的內存管理

參考資料:

一、www.youtube.com/watch?v=TPm…

二、www.infoq.cn/article/lKs…

三、developer.mozilla.org/zh-CN/docs/…

相關文章
相關標籤/搜索