Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端内存泄露浅析 #158

Open
FrankKai opened this issue Sep 3, 2019 · 4 comments
Open

前端内存泄露浅析 #158

FrankKai opened this issue Sep 3, 2019 · 4 comments

Comments

@FrankKai
Copy link
Owner

FrankKai commented Sep 3, 2019

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

做了以下性能优化尝试:

  • 主动销毁对象及其子对象
  • 主动取消监听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分析内存泄露
@FrankKai
Copy link
Owner Author

FrankKai commented Sep 3, 2019

内存泄露分析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

通过观察可以发现,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特有的对象。

一个构造函数的属性

  • code :: (CompileLazy builtin) V8的builtins
  • context :: system/NativeContext V8的heap/factory.cc
  • feedback_cell::system V8的heap/factory.cc
  • map::system/Map V8的heap/factory.cc
  • shared [V8的heap/factory.cc]指的是SharedFunctionInfos 这是一个介于函数和已编译代码的对象,SFI没有上下文。
    • 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.

@FrankKai
Copy link
Owner Author

FrankKai commented Sep 3, 2019

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

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

image

image

素材列表->产品列表->素材列表,增加了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

第二组疑问(实践):
组件作为实例的组件,不会跟随父组件自动销毁吗?
是不是通用组件的问题?一个通用组件在多处引用,导致页面销毁后,当前实例的组件没有彻底销毁?

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

image

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

image

分析 ./src/components/uploadToOss组件

image
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

置为null后
image

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

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

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

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

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

image
image

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

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

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

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

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

关于iView内存泄露的讨论:

我的验证方式:

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

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

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

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

image

@FrankKai
Copy link
Owner Author

FrankKai commented Sep 3, 2019

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
image

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

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

@FrankKai
Copy link
Owner Author

FrankKai commented Sep 3, 2019

总结与思考

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

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

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

参考资料:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant