本文首发于公众号:合乎预期的 CoyPan
写在后面
在所有的编程语言中,咱们申明一个变量时,须要零碎为咱们调配一块内存。当咱们不再须要这个变量时,须要将内存进行回收(这个过程称之为垃圾回收)。在 C 语言中,有 malloc 和 free 来帮助咱们进行内存治理。在 JS 中,开发者不须要手动进行内存治理,JS 引擎会为咱们主动做这些事件。然而,这并不意味着咱们在应用 JS 进行编码时,不须要关怀内存问题。
JS 中的内存调配与变量
内存申明周期如下:
- 调配你所须要的内存
- 应用调配到的内存(读、写)
- 不须要时将其开释
在 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…