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频率低,按照GC1.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,笔者电脑测试为16MBinitial_semispace_size_即semi_sapce_size初始为1MBmax_old_generation_size_按照上面计算方法得出1400MBinitial_max_old_generation_size_即max_old_generation_size_大小,如果后续有启动参数配置则为配置大小如node --max-old-space-size=2000 xxx.js,表示将最大老生代内存设置为2000MBinitial_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有增长后续每次插入
10000DOM,都能看到明显的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比较靠谱。
