本文首发于公众号:合乎预期的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,一个是oao2 = "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...