从JS中的内存管理说起 —— JS中的弱引用

本文首发于公众号:符合预期的CoyPan

写在前面

在所有的编程语言中,我们声明一个变量时,需要系统为我们分配一块内存。当我们不再需要这个变量时,需要将内存进行回收(这个过程称之为垃圾回收)。在C语言中,有malloc和free来协助我们进行内存管理。在JS中,开发者不需要手动进行内存管理,JS引擎会为我们自动做这些事情。但是,这并不意味着我们在使用JS进行编码时,不需要关心内存问题。

JS中的内存分配与变量

内存声明周期如下:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放

在JS中,这三步都是对开发者无感的,不需要我们过多的关心。

我们需要注意的是,当我们声明一个变量、得到一块内存时,需要正确区分一个变量到底是一个基本类型还是引用类型。

基本类型:String,Number,Boolean,Null,Undefined,Symbol

引用类型:Object,Array,Function

对于基本类型变量来说,系统会为其分配一块内存,这块内存中保存的,就是变量的内容。

对于引用类型变量来说,其存储的只是一个地址而已,这个地址指向的内存块才是是变量的真正内容。引用变量的赋值,也只是把地址进行传递(复制)。举个例子:

// a 和 b 指向同一块内存
var a = [1,2,3];
var b = a;
a.push(4);
console.log(b); // [1,2,3,4]

还有一点需要注意,JS中的函数传参,其实是按值传递(按引用传递)。举个例子:

// 函数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 
前往Page2

page1

....
} 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 
前往Page2

page1

....
} 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引擎的内存管理

参考资料:

1、https://www.youtube.com/watch...

2、https://www.infoq.cn/article/...*bbYg

3、https://developer.mozilla.org...

你可能感兴趣的:(前端,javascript,内存管理)