4

浅谈 JS 内存机制与内存泄漏

 1 year ago
source link: https://blog.p2hp.com/archives/9915
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

随着web的发展与普及,前端页面不仅只加载在浏览器上,也慢慢流行于各种app的webview里。尤其在如今设备性能越来越好的条件下,前端页面更是开始在app中担任重要的角色。如此一来,前端页面的停留时间变得更长,我们理应越发重视前端的内存管理,防止内存泄露,提高页面的性能。

想要打造高性能前端应用,防止崩溃,就必须得搞清楚JS的内存机制,其实就是弄清楚JS内存的分配与回收。

JS数据存储机制

图片

从图中可以看出, 在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。

代码空间:用来存放可执行代码

栈空间:一块连续的内存区域,容量较小,读取速度快,被设计成先进后出结构

堆空间:不连续的内存区域,容量较大,用于储存大数据,读取速度慢

图片

JavaScript 发展至今总共有八种数据类型,其中 Object 类型称为引用类型,其余七种称为基本类型,Object 是由其余七种基本类型组成的kv结构数据。

栈空间和堆空间

栈空间其实就是 JavaScript 中的调用栈,是用来储存执行上下文,以及存储执行上下文中的一些基本类型中的小数据,如下图所示:

图片

image.png

变量环境: 存放var声明与函数声明的变量空间,编译时就能确定,不受块级作用域影响

词法环境: 存放let与const声明的变量空间,编译时不能完全确定,受块级作用域影响

而堆空间,则是用来储存大数据如引用类型,然后把他们的引用地址保存到栈空间的变量中,所以多了这一道中转,JavaScript 对堆空间数据的读取自然会比栈空间数据的要慢,可以用下图表示两者关系:

图片

通常情况下,栈空间都不会设置太大,这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了的话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包

闭包中的数据会组成一个对象,然后保存在堆空间中,如:

图片

可以利用开发者工具查看闭包情况,其中括号中的名称就是产生闭包的函数名。一般我们会认为闭包是返回的内部函数引用的变量集合,但闭包有一个较为迷惑的情况,如下:

图片
图片
图片
图片

可以理解为,如果函数存在闭包,其所有内部函数都会拥有一个指向这个闭包的引用,即所有内部函数会共享同一个闭包,只要任意内部函数有引用外部函数中声明的变量,这个变量都会被纳入闭包内,而且最内部的函数会持有所有外部的闭包。

堆栈存放的数据类型

原始类型的数据是存放在栈中,引用类型的数据是存放在堆中?

上面这句话是用来描述栈中数据的存储情况,调用栈中的引用类型存放在堆中,相信大家都没有问题,但是原始类型真的都存放在栈中吗?

数字

V8把数字分成两种类型:smi 和 heapNumber

smi是范围为 :-2³¹ 到 2³¹-1的整数,在栈中直接存值;除了smi,其余数字类型都是heapNumber,需要另外开辟堆空间进行储存,变量保存其引用。

var times = 50000;
var smi_in_stack = 1;
var heap_number = 1.1;

// about 1.5~1.6ms, fast
console.time('smi_in_stack');
for (let i = 0; i < times; i  ) {
  smi_in_stack  ;
}
console.timeEnd('smi_in_stack');

// about 2.1~2.5ms, slow
console.time('heap_number');
for (let i = 0; i < times; i  ) {
  heap_number  ;
}
console.timeEnd('heap_number');

同时我们可以通过heap snapshots观察到heap_number的存在,所以验证了栈中的heapNumber值是存在堆中,smi值是直接存在栈中。

更基本的基本类型

V8定义了一种 oddball[1] 类型,属于 oddball 类型的有null、undefined、true和false

function BasicType() {
  this.oddBall1 = true;
  this.oddBall2 = false;
  this.oddBall3 = undefined;
  this.oddBall4 = null;
  this.oddBall5 = '';
}
const obj1 = new BasicType();
const obj2 = new BasicType();
图片

这里可以看到oddball类型以及空字符串的堆引用全部都是一个固定值,代表在V8跑起来的第一时间,不管我们有没有声明这些基本类型,他们都已经在堆中被创建完毕了。由此猜想栈中这些类型使用的也是堆中的地址。

function Obj() {
  this.string = 'str';
  this.num1 = 1;
  this.num2 = 1.1;
  this.bigInt = BigInt('1');
  this.symbol = Symbol('1');
}
const obj = new Obj();
debugger;
obj.string = 'other str';
obj.num1 = 2;
obj.num2 = 1;
obj.bigInt = BigInt('2');
obj.symbol = Symbol('2');
图片

debugger后内存快照

图片

其中bigInt、string、symbol的内存地址都进行了更换,由此可以猜想是因为这三种类型占用的内存大小不是一个固定值,需要根据其值进行动态分配,所以内存地址会进行更换;而heapNumber的内存地址并没有发生变化,这个更换值的操作还是在原来的内存空间中进行。因为栈是一块连续的内存空间,不希望运行中会产生内存碎片,由此可以得出bigInt、string、symbol这些内存大小不固定的类型在栈中也是保存其堆内存的引用。同时我们在栈中可以声明很大的string,如果string存放在栈中明显也不合理

故栈空间中的基本类型储存位置如下:

类型 储存位置
Number smi储存栈中,heapNumber储存堆中
String
Boolean
Null
undefined
BigInit
Symbol

上述结论主要是从heap snapshots和栈的特性中得出,毕竟最正确的答案是在源码中获得,如有不当,请指正。

JS内存回收

栈内存回收

function fn1() {
  //....
  function fn2() {
    //...
  }
  fn2();
}
fn1();
图片

调用栈中有一个记录当前执行状态的指针(称为 ESP),随着函数的执行,函数执行上下文被压入调用栈中,执行上下文中的数据会按照前面说的JS数据存储机制被分配到堆栈中,ESP会指向最后压栈的执行上下文,如左图所示的fn2函数。当fn2函数调用完毕,JS 会把ESP指针下移至fn1函数,这个指针下移的操作就是销毁fn1函数执行上下文的过程。最后fn1函数执行上下文所占用的区域会变成无效区域,下一个函数执行上下文压入调用栈的时候会直接覆盖其内存空间。简而言之,只要函数调用结束,该栈内存就会自动被回收,不需要我们操心。刚刚我们也聊到闭包,如果出现闭包的情况,闭包的数据就会组成一个对象保存在堆空间里。

堆内存回收

内存垃圾回收领域中有个重要术语:代际假说,其有以下两个特点:

  1. 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  1. 不死的对象,会活得更久。

基于代际假说,JS 把堆空间分成新生代和老生代两个区域,新生代中存放的是生存时间短的对象,通常只支持 1~8M 的容量;老生代中存放的生存时间长的对象,一些大的数据也会被直接分配到老生区中。而针对这两个区域,JS 存在两个垃圾回收器:主垃圾处理器和副垃圾处理器。这里先说说垃圾回收一般都有相同的执行流程:

  1. 标记空间中活动对象和非活动对象
  1. 回收非活动对象所占据的内存
  1. 内存整理,这步是可选的,因为有的垃圾回收器工作过程会产生内存碎片,这时就需要内存整理防止不够连续空间分配给大数据

副垃圾回收器

副垃圾回收器主要是采用 Scavenge 算法进行新生区的垃圾回收,它把新生区划分为两个区域:对象区域和空闲区域,新加入的对象都会存放到对象区域,当对象区域快被写满时,会对对象区域进行垃圾标记,把存活对象复制并有序排列至空闲区域,完成后让这两个区域角色互转,由此便能无限循环进行垃圾回收。同时存在对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器

由于老生区空间大,数据大,所以不适用 Scavenge 算法,主要是采用标记-整理算法,其工作流程是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。接着让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。垃圾回收工作是需要占用主线程的,必须暂停JS脚本执行等待垃圾回收完成后恢复,这种行为称为全停顿。 由于老生代内存大,全停顿对性能的影响非常大,所以出现了增量标记的策略进行老生区的垃圾回收。

JS内存泄漏

由于栈内存会随着函数调用结束而被释放(覆盖),所以JS中的内存泄漏一般发生在堆中。之前有同学分享过一篇关于内存泄漏的文章 ,里面讲到一些常见内存泄漏的原因和监测手段,这里我就不赘述,但是可以根据最近的IM工作讲一些实践:

确认是否有内存泄漏的情况

  1. 本地打包一个去掉压缩、拥有sourcemap及没有任何console的生产版本(console会保留对象引用,阻碍销毁;去掉压缩和保留sourcemap有利于定位源码)
  1. 启动本地服务器,使cef访问本地项目
  1. 不断操作和记录heap snapshots,观察snapshots和timeline情况
  1. 最终内存从22.5m上升至34.6m,conversation实例从443上升至1117,message实例从443上升至1287,而该用户实际只有221个会话
  1. 不断在会话间切换,通过timeline看到有内存没被释放,而且生成detached dom
图片

通过上述观测,可以判断为有内存泄漏情况。

确定内存泄漏排查方式

IM页分为:会话列表,会话顶栏,消息列表,输入框四部分。使用逐一排查法缩小排查范围,排查各个部分内存情况。如:先保留会话列表,注释其余三个部分,操作会话列表并使用timeline和heap snapshots进行内存排查。按照这一方法逐步排查四个部分组件,并针对各个组件进行优化。可以简单归纳成一个通用步骤:

  1. 使用timeline进行录制,观察是否像上面那样有不被释放的内存区域
  1. 选择不被释放的区域进行查看,先找自己项目中的锚点物:像我们IM数据都是用conversation和messsage对象进行储存,所以可以先进行这两个对象的搜索查看
  1. 如果没有好的锚点物也没关系,接着查看detached dom(毕竟很多事件绑定在dom中,事件中引用着数据,造成无法被释放)和 string
图片

有些detached dom可能是react虚拟dom的数据,但像上面的Detached HTMLAudioElement会随着操作一直增加,所以这个是不正常的。

图片

像这里string的重复,经排查是有相同conversation和message对象引起

图片

堆快照里包含太多运行时、上下文等信息,实在太难从中找到有用的信息,所以会把目标放在锚点物、detached dom和string上

  1. 利用heap snapshot 的comparison模式过滤出操作阶段内存变更情况,更有利于查找影响位置

图片

上面是个人进行内存泄露排查整理的方法,如果你有更好的方法,欢迎交流∠(°ゝ°)

React中一个需要注意的内存泄漏问题

图片

现象: 当组件被销毁后,仍有一些异步事件调用组件中setState方法

原理: 组件销毁后,再调用setstate方法会保留相关引用,造成内存泄漏

// 测试代码
const [test, setTest] = useState(null);
useEffect(() => {
  (async () => {
    // 这里表达一个异步操作如:xhr、fetch、promise等等
    await sleep(3000);
    const obj = new TestObj();
    setTest(obj);
  })();
}, []);
图片

如果把代码改成这样,就不会造成内存泄漏:

const [test, setTest] = useState(null);
useEffect(() => {
  let unMounted = false;
  (async () => {
    await sleep(3000);
    if (unMounted) return;
    const obj = new TestObj();
    setTest(obj);
  })();
  return () => {
    unMounted = true;
  };
}, []);

这是在开发环境测试的,翻看源码发现react只会在开发模式保留这些引用,然后抛出warning来提醒开发者这里可能有内存泄漏的问题(如这些setState是注册在全局事件里或者setInterval里的调用),生产环境是不会对其进行引用,所以不需要额外进行处理也不会造成内存泄漏

图片
图片
图片

react18更是直接把这个报错给干掉,以免误导开发者使用刚刚说的类似手段来进行避免报错,这里有做解释:https://github.com/facebook/react/pull/22114

图片

本文先是讲述js类型在内存空间的储存位置,接着探讨堆栈中的内存是如何进行回收,最后描述内存泄漏确定和排查的方法,也补充一个react中有关setState造成“内存泄漏”的例子。内存泄漏在复杂应用中是难以避免的,个人排查也只能是解决一些比较明显的内存泄漏现象。所以为了更好地解决这个应用内内存泄漏问题,必须做好线上监控,利用广大用户操作数据,发现内存泄漏问题,进而不断改善应用的性能。

  1. https://developer.chrome.com/docs/devtools/memory-problems/memory-101/
  1. https://www.cnblogs.com/goloving/p/15352261.html
  1. https://hashnode.com/post/does-javascript-use-stack-or-heap-for-memory-allocation-or-both-cj5jl90xl01nh1twuv8ug0bjk
  1. https://www.ditdot.hr/en/causes-of-memory-leaks-in-javascript-and-how-to-avoid-them

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK