前端内存泄露浅析

简介: 手上负责的vue项目最近出现一个这样的问题,用户用着用着就出现:”喔唷,崩溃啦!“的提示。

image.png


做了以下性能优化尝试:

  • 主动销毁对象及其子对象
  • 主动取消监听listener
  • 本地搜索减少组件DOM渲染


主动销毁对象及其子对象


vue-cropper.js,组件实例不会主动销毁,需要主动调用destroy方法销毁。

createjs/easeljs,组件实例需要手动销毁canvas画布,maker.stage.canvas = null;maker.stage.removeAllChildren();


主动取消监听listener


createjs/easeljs,maker.stage._eventListeners = null;maker.stage.removeAllEventListeners();


本地搜索减少组件DOM渲染


iview的select组件,当数据量过大时,DOM渲染会占用很大的内存,非常吃性能。因此为其增加了渲染指定个数的功能,例如首次渲染只渲染20个,之后的搜索从已经加载好的数据中搜索并渲染。


有一定的收效,但是在仍然存在性能问题,切换菜单的过程中,Memory中的Javascript VM instance以100MB/次的速度增加,而且还是在没有数据的情况下。

因此,迫切的需要一次深度的性能优化,以解决当前项目遇到的问题。

解决完这个问题我将增强技能:


  • Chrome DevTools的Memory,Performance工具的应用
  • vue相关,javascript相关,DOM相关的未知内存泄漏知识点

我将记录以下深度分析内存泄露的相关内容:

  • 内存泄露分析Snapshot相关知识点
  • 内存泄露分析Snapshot的疑惑和实践
  • Chrome DevTools Elements的Event Listeners分析内存泄露


内存泄露分析Snapshot相关知识点


JS heap size


window.performance.memory对象的属性。

jsHeapSizeLimit: 2197815296
totalJSHeapSize: 12068848
usedJSHeapSize: 10730032

totalJSHeapSize和usedJSHeapSize的区别是什么?

usedJsHeapSize是内存总数:指的是JS对象占用的内存,包括V8内部对象

totalJsHeapSize是当前内存总数:指的是JS堆的占用的内存,包括任意js对象的空闲内存


通过以下代码,可以观察当前document的usedJSHeapSize占用状况,从而分析是否存在内存泄露性能问题。


setInterval(()=>{
    console.log(performance.memory);
},2000)


image.png

通过观察可以发现,js占用内存(不包括空闲内存)在一直升高,停留一段时间以后也GC不到页面初始化的的大小。


因此可以得出结论,存在内存泄露。


也可以在Chrome的任务管理器中,开启JavaScript使用的内存的监控。但是这样会开启看到所有tab甚至是插件的内存占用信息,不如code的方式直观和geek。


Heap snapshot


堆快照。其实就是当前页面的js对象及其相关的DOM节点的内存分布情况。

  • 内存未泄露堆快照
  • 内存泄露堆快照

可以在内存泄露前生成一份堆快照,再在内存泄露后生成一份堆快照。通过对比的方式,找出两份堆快照存在的内存泄露点。最好是在一次操作后分析,以便分析出问题。


Shallow Size


Shallow Size 是对象本身hold的内存。

js会为对象自身开辟一些空间用来存储数据。js中string和array会有明显的shallow size, 不过它们主要在渲染内存中存储,在js heap上仅仅暴露一个包裹对象。

渲染内存指的是监测页面的所有内存:


  • 原生内存(native memory)
  • 页面的js堆内存(js Heap memory)
  • 页面开启的所有worker的js堆内存(JS heap memory of all dedicated workers)


参考资料:即使是一个小对象,都可能间接的hold了庞大的内存。从而导致自动GC程序不能处理掉这些被间接hold的内存。


Retained Size


这是删除了对象及其依赖对象后,可以释放的内存大小,这些依赖从GC root是无法访问到的。


官方解释很拗口,简单理解其实就是对象及其依赖对象的内存大小


Comparison中的分析字段


  • # New

新创建的对象个数。

  • # Deleted

删除的对象个数。

  • # Delta

发生变化的全部对象的个数。净增对象个数。

  • Alloc.Size

已经分配的使用中的内存空间。

  • Freed Size

新对象释放出的内存空间。

  • Size Delta

发生变化的释放内存的全部空间。净增内存空间。


Heap Snapshot中的Constructor

  • (closure) 通过函数闭包对一组对象的引用计数
  • (array、string、number、regexp) 不同对象类型的列表,Array,String,Number,RexExp的属性
  • (已编译代码) 与已编译代码相关的任何内容。
  • HTMLDivElement、HTMLAnchorElement、DocumentFragment DOM对象。
  • Dep、Observer、VNode、Watcher、VueComponent 这些是vue特有的对象。


一个构造函数的属性

  • function_data 函数数据
  • name_or_scope_info 函数名称和作用域信息
  • script_or_debug_info 脚本或者debug信息


Shallow size、Retained size、Freed size、Delta size的size是以什么为单位?


所有的size都是以字节为单位的。

Note: Both the Shallow and Retained size columns represent data in bytes.


内存泄露分析Snapshot的疑惑和实践


为什么一次菜单切换会导致6MB的内存泄露?


image.png


image.png


素材列表->产品列表->素材列表,增加了6MB的内存占用。

经过对比发现,主要增大的是Object的Retained Size,从26913个(37%)增大到32933个(49%),增大了12%。


刚好VueComponent也从377个(10%)增大到600个(22%),也是从增大了12%。

所以初步断定,是由于VueComponent没有GC导致的。


第一组疑问(理论):


  • 是有对象没有被销毁吗?


  • 是对象销毁了但是由于其他对象依赖它,导致销毁失败吗?


  • 是对象销毁了但是由于其他对象依赖它的子对象,导致销毁失败吗?


以上信息是在Summary中展示的,那么如何对比两次快照呢?


Chrome DevTools提供了一个非常便利的功能,Comparison,切换到想要对比的Snapshot,即可得到2次内存占用的diff.


经过第二次和第一次的对比,我们得到这张对比分析图。


image.png


第二组疑问(实践):

组件作为实例的组件,不会跟随父组件自动销毁吗?

是不是通用组件的问题?一个通用组件在多处引用,导致页面销毁后,当前实例的组件没有彻底销毁?


#Delta值最高的(closure)是主要的原因吗?


image.png


在(closure)的末尾,我们找到很熟悉的通用组件面孔,以此为出发点去做分析。

image.png


分析 ./src/components/uploadToOss组件


image.png


shared是很可疑的,点开以后是下图的场景。


组件在这里出现,说明这个模块/组件闭包内部变量使用完后没有置为null


vue并不会监测到组件/模块不再使用,所以我们需要在vue的destroyed或者beforeDestroy生命周期中做主动销毁。



<script>
import ALIOSS from '@/components/uploadToOss';
let commonOSS = new ALIOSS();
export default {
    beforeDestroy() {
        commonOSS = null; // 这是新增的代码,销毁创建的上传OSS组件实例,释放闭包空间
    }
}
</script>


一定要注意,vue是监测不到我们不用某些模块的,只有绑定在vue实例上的实例才会与组件一起销毁,没有绑定的一定要主动销毁。


置为null前


image.png


置为null后


image.png

我们成功释放了112byte,也就是0.112Kb的内存!


  • 是有对象没有被销毁吗?是的,引入的模块没有被销毁。
  • 是对象销毁了但是由于其他对象依赖它,导致销毁失败吗?不是,我们暴露的一般是一个class,新建的实例有自己的上下文,不存在单文件组件间互相引用,因此是独立的。
  • 是对象销毁了但是由于其他对象依赖它的子对象,导致销毁失败吗?对象销毁后其子对象也会自动销毁。
  • 组件作为实例的组件,不会跟随父组件自动销毁吗?会销毁的。每次引入都是独立的。
  • 是不是通用组件的问题?一个通用组件在多处引用,导致页面销毁后,当前实例的组件没有彻底销毁?不是。但不是由于多初引入导致的,而是由于没有主动将组件创建的闭包变量置null导致的。


这次分析给了我们一个启示呢?在利用class Filter去搜索constructor,观察delta size是否为负数,freed size是否不为0,这样就可以判断出模块有没有彻底销毁。

费了半天劲,最后只优化了0.012Kb,这不和没优化没差吗?

试着从VueComponent的对比找找原因:在产品列表快照,我们发现了残留的未被销毁的素材列表的Table组件。


image.pngimage.png

所以几乎可以确定的是,切换到素材列表页面的Table组件,没有被完全销毁,在产品列表中依然可以找到它的身影。


所以,是iView的Table组件存在内存泄露?还是vue本身存在内存泄露?


再经过对比element-ui和iView,发现iView确实是存在内存泄漏的,内存占用一直降不下来,而element-ui过一会儿就会降到正常值。所以不是Vue的原因。


和老大讨论了一下,之后可能会替换成其他的UI框架。


目前的方案是监听window.performance.memory对象,一段时间内持续大于某个阀值时,会提醒用户主动刷新页面,从而释放出泄露掉的内存。


image.png


关于iView内存泄露的讨论:



我的验证方式:


  • iView官网几次切换后停留到同一个页面,element-ui官网切换 观察同一个页面的内存占用
  • 本地项目几次切换后停留到同一个页面,对比VueComponent个数,并找出其他页面的组件


image.png

就拿这个来说,我做了如下的切换foo->bar->foo->baz->foo后,获取到这个快照对比。


从图上可以看出,VueComponent新建了612个,删除了9个,净增603个,分配了17.296Kb的内存,释放了0.504Kb的内存(看到这个释放程度我真的佛了),净增16.792Kb的内存。造成了16.792Kb的内存泄露。


可能你觉得16.792Kb不算什么,因为它在我的这次分析里,内存泄露情况只排第19,排名第一第二的分别泄露了598Kb,506Kb。


image.png


Chrome DevTools Elements的Event Listeners分析内存泄露


vue中的全局事件销毁,避免listener内存泄露。


DOM0级事件销毁

window.onbeforeunload = () => {};
window.onbeforeunload = null; // 销毁,可以在vue的destroyed生命周期(最好在这个,因为无需在beforeDestroy引用vue实例)或beforeDestroy。


DOM2级事件销毁

this.foo= (e) => {}
window.addEventListener('resize', this.foo);
window.removeEventListener('resize', this.foo);// 销毁,可以在vue的beforeDestroy生命周期(引用vue实例最好在这个周期销毁)或destroyed。


全局事件销毁前(内存释放前):


image.png

image.png


全局事件销毁后(内存释放后):


image.png


image.png


通过观察可以发现,一次菜单切换,减少了一个冗余的全局事件监听器,性能有些许提升。


总结与思考


经过一系列分析我们发现,可以通过以下几种方式分析内存泄露的问题并修复。

  • 监听在window的事件没有解绑
  • 绑在EventBus的事件没有解绑
  • 第三方库创建的实例没有调用销毁函数
  • 自定义组件/模块闭包内部变量未被销毁


前端同学在选型前端UI框架时,不妨先测试测试是否存在内存泄露。


斯世浊清,全赖吾辈激扬!


参考资料:




相关文章
|
1天前
|
Web App开发 缓存 前端开发
【Flutter前端技术开发专栏】Flutter中的性能优化与内存管理
【4月更文挑战第30天】本文探讨了Flutter应用的性能优化和内存管理。关键点包括:减少布局重绘(使用`const`构造函数和最小化依赖),选择合适的动画实现,懒加载和按需加载以提升性能。同时,强调了避免内存泄漏和优化内存使用,利用Flutter提供的性能分析工具。实践案例展示了如何优化ListView,包括使用`ListView.builder`和缓存策略。通过这些方法,开发者可以提升应用的响应性、流畅性和稳定性。
【Flutter前端技术开发专栏】Flutter中的性能优化与内存管理
|
1天前
|
Dart 前端开发 Java
【Flutter前端技术开发专栏】Flutter中的内存泄漏检测与解决
【4月更文挑战第30天】本文探讨了Flutter应用中的内存泄漏检测与解决方法。内存泄漏影响性能和用户体验,常见原因包括全局变量、不恰当的闭包使用等。开发者可借助`observatory`工具或`dart_inspector`插件监测内存使用。解决内存泄漏的策略包括避免长期持有的全局变量、正确管理闭包、及时清理资源、妥善处理Stream和RxDart订阅、正确 disposal 动画和控制器,以及管理原生插件资源。通过这些方法,开发者能有效防止内存泄漏,优化应用性能。
【Flutter前端技术开发专栏】Flutter中的内存泄漏检测与解决
|
1天前
|
存储 缓存 JavaScript
【Web 前端】JS哪些操作会造成内存泄露?
【4月更文挑战第22天】【Web 前端】JS哪些操作会造成内存泄露?
|
7月前
|
存储 JavaScript 前端开发
手撕前端面试题【javascript~ 列表动态渲染、无重复数组、数组排序、新数组、创建数组、深浅拷贝、内存泄露等】
html页面的骨架,相当于人的骨头,只有骨头是不是看着有点瘆人,只有HTML也是如此。 css,相当于把骨架修饰起来,相当于人的皮肉。
40 0
|
7月前
|
JavaScript 前端开发 程序员
|
10月前
|
存储 JavaScript 前端开发
前端 js 栈内存和堆内存 基本数据类型和复杂数据类型的区别?
前端 js 栈内存和堆内存 基本数据类型和复杂数据类型的区别?
73 0
|
JavaScript 前端开发 算法
前端内存泄漏详解(一)
前端内存泄漏详解(一)
375 0
|
存储 JavaScript 前端开发
手撕前端面试题【javascript~文件扩展名、分隔符、单向绑定、判断版本、深浅拷贝、内存泄露等】
手撕前端面试题【javascript~文件扩展名、分隔符、单向绑定、判断版本、深浅拷贝、内存泄露等】
204 0
手撕前端面试题【javascript~文件扩展名、分隔符、单向绑定、判断版本、深浅拷贝、内存泄露等】
|
JavaScript 前端开发 Java
前端面试题【js动态创建节点、怎么阻止冒泡事件、怎么阻止默认事件、什么是深拷贝,什么是浅拷贝、js造成内存泄漏的操作有哪些等】
前端面试题【js动态创建节点、怎么阻止冒泡事件、怎么阻止默认事件、什么是深拷贝,什么是浅拷贝、js造成内存泄漏的操作有哪些等】
182 0
前端面试题【js动态创建节点、怎么阻止冒泡事件、怎么阻止默认事件、什么是深拷贝,什么是浅拷贝、js造成内存泄漏的操作有哪些等】
|
JavaScript 前端开发 索引
手撕前端面试题(Javascript~事件委托、数组去重、合法的URL、快速排序、js中哪些操作会造成内存泄漏......
手撕前端面试题(Javascript~事件委托、数组去重、合法的URL、快速排序、js中哪些操作会造成内存泄漏......
127 0
手撕前端面试题(Javascript~事件委托、数组去重、合法的URL、快速排序、js中哪些操作会造成内存泄漏......
http://www.vxiaotou.com