node v8 内存一二三
基础知识
Node.js 进程的内存管理,都是有
V8
自动处理的,包括内存分配和释放。在
V8
内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,即可回收。回收是一个过程性的,从快速
GC
到最后的Full GC
,是需要一段时间的。另外,Full GC
是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏。还有一种就是引用不释放,导致无法进入GC
环节,并且一直产生新的占用,这一般会发生在Javascript
业务层面。定位内存泄漏,找有引用但不被使用
V8
内存构成(以下研究基于node 8.x
版本依赖的v8版本)
一个 V8
进程的内存通常由以下几个块构成:
Heap内存分配枚举
1 | // NOTE: SpaceIterator depends on AllocationSpace enumeration values being |
node-v8
暴露模块
- 新生代内存区(
new_space
)- 大多数的对象都会被分配在这里,区域很小但是垃圾回收比较频繁,由两个半区域(
semispace
)构成
- 大多数的对象都会被分配在这里,区域很小但是垃圾回收比较频繁,由两个半区域(
- 老生代内存区(
old_space
)- 属于老生代,存放从新生代晋升而来的对象,可能包含对
new space
的引用,GC
频率低,按照GC
1.4GB
至少要50ms
以上,非增量更是1s
以上。
- 属于老生代,存放从新生代晋升而来的对象,可能包含对
- 大对象区(
large_object_space
)- 这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区
- 代码区(
code_space
)- 代码对象,会被分配在这里,无引用。唯一拥有执行权限的内存
- map 区(
map_space
)- 存放
Cell
和Map
,每个区域都是存放相同大小的元素,结构简单
- 存放
Heap内存计算
1 | size_t Heap::Capacity() { |
- 从上面代码可以看出
v8
整个heap
内存容器大小即新生代
+老生代
+代码区
+map区
+大对象区
1 | Heap::heap() |
- 上面代码中,看到几个关键信息:
max_semi_space_size_
,kPointerSize
是指的当前系统的当前编译器的sizeof(void*)
大小,即指针大小,64bits
系统通常为8
,32bits
通常为4
,笔者电脑测试为16MB
initial_semispace_size_
即semi_sapce_size
初始为1MB
max_old_generation_size_
按照上面计算方法得出1400MB
initial_max_old_generation_size_
即max_old_generation_size_
大小,如果后续有启动参数配置则为配置大小如node --max-old-space-size=2000 xxx.js
,表示将最大老生代内存设置为2000MB
initial_old_generation_size_
同理可得到为最大老生代内存的一半
1 | // Returns the maximum amount of memory reserved for the heap. |
- 该函数获得当前系统当前编译器中最大的
heap
内存,通过计算为1432MB
,笔记通过API实时调用能看到也是1432MB
,可通过v8
提供的getHeapStatistics()
函数拿到heap_size_limit
就是我们heap
内存最大上限。
内存分布
内存模块中通常分为已申请区、使用区、可使用区等。new_space
则分为激活区、未激活区。
以下通过三个API获取的当前系统及程序内存环境数据,笔者已换算成MB
process.memoryUsage()
v8.getHeapStatistics()
v8.getHeapSpaceStatistics()
1 | { |
v8内存生命周期
假设当前有一个变量house
,从创建到销毁过程大致过程如下。
这个对象被分配到了
new_space
随着程序的运行,
new_space
塞满了,GC
开始清理new_space
里的死
对象,house
因为还处于活跃状态,所以没被清理出去GC
清理了两遍
new_space
,发现house
依然还活跃着,就把house
移动到了old_space
随着程序的运行,
old_space
也塞满了,GC
开始清理old_space
,这时候发现house
已经没有被引用了,就把house
给清理出去了,如果一直引用,则不会被清理
第二步里,清理 new_space
的过程叫做 Scavenge
(不是胡说八道,证据在下面),即空间换时间,我们把new_space
分为激活和未激活两个半(semi
)区域,则过程如下:
1 | //heap.h |
scavenge-cheney算法
- 当活跃区满了或主动GC,
from
会有两个操作,且都是在经过标记后,一个清除经过标记后的非存活对象,另一个复制经过标记后存活对象到to
- 交换
from
和to
- 交换中如果有存活对象经过清道夫标记后标记数
>1
,或当前to
区域占比超过25%
,则直接进入old_space
mark-sweep标记清扫
第四步里,清理 old_space
的过程叫做 Mark-sweep
,也就是标记和清扫,这块占用内存很大,所以没有使用 Scavenge
,这个回收过程包含了若干次(2-7次)标记过程和清理过程:
1 | // full GC |
把当前 内存数据抽象为森林
,如下
清理后
- 标记从根可达的对象为白色
- 遍历白色对象的邻接对象,直到所有可到对象都标记为白色
- 循环标记若干次(2-7)
- 清理掉非白色的对象。
简单来说,Mark-sweep
就是把从根节点无法获取到的对象清理掉,与scavenge-cheney
相比,scavenge-cheney
只会复制存活对象,而新内存中本身就小,且存活对象不多,所以高效。mark-sweep
则只会清除没被标记的对象,而老生代死对象少,这也就是mark-sweep
针对老生代区域高效GC的原因。
mark-compact
标记整理和压缩
由于经过mark-sweep
算法GC后,会出现不连续的空间,导致空间碎片,当下次需要移动大对象或对象晋升,但没有足够的空间使用,将会再次导致GC,但往往这个时候GC没必要的,因为很可能是刚刚GC过,所以怎么合理利用空间碎片就成了性能关键。于是mark-compact
算法出现了。
- 将存活对象移动到
old_space
的一端 - 将另一端直接清空
整理之前(黑色为死对象)
整理之后
我们对比下三种GC算法
incremental-marking
由于GC期间,需要执行stop-the-world
来保证应用程序逻辑和GC看到的一致性,所以v8中引入了incremental-marking
增量标记策略,清理一会儿,执行一会儿应用程序。
1 | void Heap::ReportExternalMemoryPressure() { |
理论于实践的意义
注:本文Chrome调试部分基于版本(Version 74.0.3729.169
(Official Build) (64-bit))
首先认识下Chrome DevTools
内存模块(当前静态内存分布,时刻)
- 准备好
Chrome
,然后执行下面的代码
1 | class BeikeClass { |
- 打开
Chrome devtools
,进入memory
介绍下几个tab:
Constructor
为构造函数Distance
为对象到根层级Shallow Size
为对象自己内存大小(不包含内部引用)Retained Size
为对象内存总大小且包含内部引用对象大小
上面经过过滤后会看到申明的两个类 BeikeClass
、BeikeFangClass
,可以看到 BeikeFangClass
(6)下一层级有 BeikeClass
(7),而且 2400000+3200000=5600000
也符合上面对 Shallow Size
和 Retained Size
的解释(瞎解释?,官网走你)
注:Retained Size
是性能调优阶段重要指标(主动GC
)
怎么样才能看到内存在涨呢?看个动态的Performance
之前叫Timeline
看,这里的波动图
就能看到内存在涨。怎么操作?执行下面的代码
1 | var x = []; |
然后点击开始记录当前时刻开始一段时间内的内存使用情况,下面仔细看下这20s左右内存使用情况:
首先主动
GC
一次,能看到内存有所下降,也就是图中标注的第一次(Major GC
,其中可能包含一到多次Minor GC
),Major GC
通常是针对老生代、Minor GC
通常针对新生代,那也就意味着Major
通常比Minor
慢,因为老生代内存比新生代内存大很多,算法也不相同。中间红色框标记的为向文档中插入
10000
个DOM,可以看到js heap
有增长后续每次插入
10000
DOM,都能看到明显的js heap
增长,同时还有nodes
增长在随着DOM原来越多,系统会自动触发
DOM GC
,尝试回收无用DOM,以及Minor GC
最后一次主动GC后内存也明显下降了
我们还可以点击下方的
Call tree
来查看整个过程的调用树,我们能看到除Major GC
、Minor GC
、DOM GC
之外的其他相关系统调用栈及具体信息通过
Event Log
查看根据时间系统调用栈
怎么分析内存爆了?谁爆了?
那我们模拟一个泄漏的例子,模拟内存增长:
打开
memory
,然后执行下面的代码,每隔一段时间录制一段HEAP SNAPSHOTS
,然后做两两对比1
2
3
4
5
6
7
8
9
10
11class BeikeClass {
constructor(){
this.fang = new ArrayBuffer(1000000)
}
}
const _heap = {}
setInterval(() => {
_heap[Date.now()] = new BeikeClass()
}, 100)对于上面的图来说,我们的首先选中其中一个
SNAPSHOTS
比如SNAPSHOTS 7
,然后修改Summary->Comparison
,右侧选中SNAPSHOTS 7
作对比,我们看到右侧红色框中new
中比较多,也是我们需要关注的。重复上面的过程,选择对比
SNAPSHOTS 8
与SNAPSHOTS 7
,同样能得到一个对比对比两张图,可以看到两次对比中
BeikeClass
、ArrayBuffer
、string
等几项都明显增长,我们点击三角展开就能定位变量最终引用比如这里的ArrayBuffer
,点开后fang in BeikeiClass
-1560244861634
-_heap
,及最终引用链(官网叫支配项)查看对象引用关系
最终我们也就能定位到是BeikeClass
、ArrayBuffer
、string
几个可能是‘凶手
’,从而破案。
node环境怎么操作?
node
环境下结合node-heapdump
和自己监听内存使用情况或使用node-memwatch
node-memwatch
监听当前程序(V8实例)的GC
事件,然后会触发leak
和stats
事件,leak
是在内存泄漏时候会触发,stats
是在GC
后触发,上报数据
如果遇到node-memwatch
编译报错
1 | no matching constructor for initialization of 'String::Utf8Value' |
修复方法两个:node
升级到9
以上或者修改源码,上面笔者说本文基于8.x
,所以这里只能改源码了
1 | String::Utf8Value utfString(isolate, str->ToString()); |
删除utfString
第一个参数即可(别问为什么,问就是看源码)
笔者这里有一段代码示例,(我们在启动的时候可以加上 --trace_gc
参数来观察运行过程中的详细GC
信息)
1 | class BeikeFang { |
在clear
之前然后每隔一段时间生成heapsnapshot
1 | let filename = './' + Date.now() + '.heapsnapshot' |
下面生成了6个快照
导入Chrome->memory分析
上图中能明显看到内存是增长趋势,按照之前描述的方法进行分析对比就能知道是那块变量出现问题
我们可以使用memwatch
提供的diff
方法进行对应两个时间点的snapshot
的diff
1 | const hd = new memwatch.HeapDiff(); |
下面是利用HeapDiff
类生成的diff
结果
1 | { |
从diff的结果看,明显看到
1 | { |
那我们还可以进行进一步的diff
,继续采用这种方式进行选择diff
,最终确认内存爆掉的凶手
在node
中查看GC
数据
上面一节中说到了通过添加--trace_gc
来查看运行过程中详细的GC
数据
- 我们重点关注这里的红框部分,左边的是通过
memwatch.stats
函数监听得到的数据(后续会讲),右边的是通过启动参数得到的GC
算法。能看到前面说的Mark-sweep
、scavenge
两个算法,针对老生代和新生代。
内存使用
作用域
1
2
3const foo = function(){
let inner = {}
}上面的代码foo函数没执行一次,都会生成一个foo的函数作用域,同时foo的局部变量也在函数作用域中,执行结束函数作用域也随之销毁,局部变量亦然。局部变量存活周期很短,会首先分配到新生代的From区域,函数执行结束后,也就被GC掉了。
作用域链
1
2
3
4
5
6
7
8
9
10const foo1 = function(){
const foo2 = function(){
let inner_var = 1
return (function(){
return inner_var
}())
}
foo2()
}
foo1()上面的代码
foo1
在执行的时候,首先生成foo1
的Function Scope
,然后进入foo2的Function Scope
,到里面的闭包中,return inner_var
时,闭包的Function Scope
里没有inner_var
,然后找foo2的Function Scope
,找到了其中的局部变量,然后返回。这里的如果foo2里作用域没有inner_var
则再向上找,直到global Scope
。主动释放变量
根据前面讲到的
GC
原则,我们在编码的时候要注意主动释放不用的内存变量。全局上的变量是整个APP生命周期可访问,所以这部分的变量会很快放到老生代,所以如果有未使用的或用过后不再使用的变量,及时释放。对于局部变量而言,v8本身的GC就够用了,除非手抖搞成了全局的。释放变量可通过delete
或重新赋值。
查看内存数据
1 | // 显示内存 |
1 | // 不停分配内存但不释放 |
第一个红框内:我们看到上面的分配内存只执行了9次,v8内存就爆了,heapTotal 1437.03 MB heapUsed 1352.93 MB rss 1377.70 MB
第九次中,申请的heap内存总共1437.03MB
,已使用1352.93MB
,常驻内存1377.70MB
,在第十次分配中就爆了。
第二个红框内:我们看到相关的GC
数据,在第九次后尝试GC
老生代内存失败。
第三个红框内:v8内存爆掉,进程down,给出了相关的js stack trace
,我们可以明确看到useMem
就是凶手。
当然这个时候再访问系统也就GG了。
堆外存
在上面我们用Array
发现其分配内存是在v8的heap
中,Buffer
则不会通过v8来分配,是Node
自己处理分配的,我们把useMem
换成Buffer
再看一遍
1 | const useMem = function () { |
这里我们执行了20次,执行后有多次的full_gc
,还有多次的inc_gc
,分配很频繁,前面也说到了,v8的GC原则中,在很频繁触发GC的时候会采用inc_gc
也就是增量的,这样保证程序能及时响应我们请求。而且我们看到总堆内存和使用堆内存变化并不大,最大的就是常驻内存变化一直增加,这也就说明Buffer
在node环境中不是通过v8的分规则进行分配的,我们在适当的时候也可以用这种方法突破v8的限制,当然我们也可以使用前面说的通过--max-old-space-size
参数启动的时候指定。
我们可以看下对应的leak
事件中的数据,其中包含事件开始时间和结束时间(NODE_UNIXTIME_V8
类型),在五次GC
过程中内存增长了(字节),这里只有内存(疑似)泄漏,没有详细的原因说明
1 | { |
我们可以按照上面讲的Comparison
方式来对比我们泄漏前和泄漏后的堆内存变化,看那些增长明显
不过需要注意:我们在leak
事件里不能主动结束HeapDiff()
的end()
,在leak
会提前结束,所以我们还是在leak
里手动生成heapsnapshot
比较靠谱。