关于前端:从JS中的内存管理说起-JS中的弱引用

120次阅读

共计 5377 个字符,预计需要花费 14 分钟才能阅读完成。

本文首发于公众号:合乎预期的 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 <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 引擎的内存治理

参考资料:

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

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

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

正文完
 0