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

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

640.jpg

写在前面


在所有的编程语言中,我们声明一个变量时,需要系统为我们分配一块内存。当我们不再需要这个变量时,需要将内存进行回收(这个过程称之为垃圾回收)。在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不停进行滚动操作,我们会发现内存占用不断的上涨:


640.png


产生这个内存泄露的原因是:我们在Page1被unmount的时候,尽管Page1被销毁了,但是Page1的滚动回调函数通过eventListener依然可“触达”,所以不会被垃圾回收。进入Page2后,滚动事件的逻辑依然生效,内部的变量无法被GC。如果用户在Page2进行长时间滑动等操作,页面会逐渐变得卡顿。

上述的例子,在我们开发的过程中,并不少见。不仅仅是事件绑定,也有可能是定时上报逻辑等等。如何解决呢?记得在unmount的时候,进行相应的取消操作即可。


在平时的项目开发中,内存泄露还有很多其他的场景。浏览器页面还好,毕竟一直开着某个页面的用户不算太多,而且刷新就好。而Node.js发生内存泄露的后果就比较严重了,可能服务就直接崩溃了。掌握JS的变量存储方式、内存管理机制,养成良好的编码习惯,可以帮助我们减少内存泄露的发生。


JS中的弱引用


前面我们讲到了JS的垃圾回收机制,如果我们持有对一个对象的引用,那么这个对象就不会被垃圾回收。这里的引用,指的是强引用


在计算机程序设计中,还有一个弱引用的概念:一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。


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后的内存表现:


640.png


可以很直观的看到,在跳转到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的开发中。

相关文章
|
18天前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
18天前
|
JavaScript 前端开发 Java
避免 JavaScript 中的内存泄漏
【10月更文挑战第30天】避免JavaScript中的内存泄漏问题需要开发者对变量引用、事件监听器管理、DOM元素操作以及异步操作等方面有深入的理解和注意。通过遵循良好的编程实践和及时清理不再使用的资源,可以有效地减少内存泄漏的风险,提高JavaScript应用程序的性能和稳定性。
|
1月前
|
存储 JavaScript 前端开发
JS 中的内存管理
【10月更文挑战第17天】了解和掌握 JavaScript 中的内存管理是非常重要的。通过合理的内存分配、及时的垃圾回收以及避免内存泄漏等措施,可以确保代码的高效运行和稳定性。同时,不断关注内存管理的最新发展动态,以便更好地应对各种挑战。在实际开发中要时刻关注内存使用情况,以提升应用的性能和质量。
27 1
|
1天前
|
监控 JavaScript
选择适合自己的Node.js内存监控工具
选择合适的内存监控工具是优化 Node.js 应用内存使用的重要一步,它可以帮助你更好地了解内存状况,及时发现问题并采取措施,提高应用的性能和稳定性。
29 20
|
23天前
|
监控 JavaScript 前端开发
如何检测和解决 JavaScript 中内存泄漏问题
【10月更文挑战第25天】解决内存泄漏问题需要对代码有深入的理解和细致的排查。同时,不断优化和改进代码的结构和逻辑也是预防内存泄漏的重要措施。
36 6
|
23天前
|
JavaScript 前端开发 Java
JavaScript 中内存泄漏的几种常见情况
【10月更文挑战第25天】实际上还有许多其他的情况可能导致内存泄漏。为了避免内存泄漏,我们需要在开发过程中注意及时清理不再需要的资源,合理使用内存,并且定期检查内存使用情况,以确保程序的性能和稳定性
30 2
|
26天前
|
存储 JavaScript 前端开发
js 中有哪几种内存泄露的情况
JavaScript 中常见的内存泄漏情况包括:1) 全局变量未被释放;2) 意外的全局变量引用;3) 被遗忘的计时器或回调函数;4) 事件监听器未被移除;5) 子元素存在时删除父元素;6) 循环引用。
|
1月前
|
缓存 监控 JavaScript
|
1月前
|
存储 缓存 JavaScript
|
1月前
|
JavaScript 前端开发 算法
深入理解JavaScript的内存管理机制
【10月更文挑战第13天】深入理解JavaScript的内存管理机制
34 0