本文作者: 黄伟
node v8 内存一二三
基础知识
- Node.js 进程的内存管理,都是有V8自动处理的,包括内存分配和释放。
- 在V8内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,即可回收。
- 回收是一个过程性的,从快速GC到最后的Full GC,是需要一段时间的。另外,Full GC是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏。还有一种就是引用不释放,导致无法进入GC环节,并且一直产生新的占用,这一般会发生在Javascript业务层面。
- 定位内存泄漏,找有引用但不被使用
V8 内存构成(以下研究基于node 8.x 版本依赖的v8版本)
一个 V8 进程的内存通常由以下几个块构成:
Heap内存分配枚举
// NOTE: SpaceIterator depends on AllocationSpace enumeration values being
// consecutive.
// Keep this enum in sync with the ObjectSpace enum in v8.h
enum AllocationSpace {
NEW_SPACE, // Semispaces collected with copying collector.
OLD_SPACE, // May contain pointers to new space.
CODE_SPACE, // No pointers to new space, marked executable.
MAP_SPACE, // Only and all map objects.
LO_SPACE, // Promoted large objects.
FIRST_SPACE = NEW_SPACE,
LAST_SPACE = LO_SPACE,
FIRST_PAGED_SPACE = OLD_SPACE,
LAST_PAGED_SPACE = MAP_SPACE
};
复制代码
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内存计算
size_t Heap::Capacity() {
if (!HasBeenSetUp()) return 0;
return new_space_->Capacity() + OldGenerationCapacity();
}
size_t Heap::OldGenerationCapacity() {
if (!HasBeenSetUp()) return 0;
return old_space_->Capacity() + code_space_->Capacity() +
map_space_->Capacity() + lo_space_->SizeOfObjects();
}
复制代码
从上面代码可以看出v8整个heap内存容器大小即新生代+老生代+代码区+map区+大对象区
Heap::heap()
:...
// semispace_size_ should be a power of 2 and old_generation_size_ should
// be a multiple of Page::kPageSize.
max_semi_space_size_(8 * (kPointerSize / 4) * MB),
initial_semispace_size_(MB),
max_old_generation_size_(700ul * (kPointerSize / 4) * MB),
initial_max_old_generation_size_(max_old_generation_size_),
initial_old_generation_size_(max_old_generation_size_ /
kInitalOldGenerationLimitFactor),
old_generation_size_configured_(false), //是否配置老生代内存上限
...
// heap.h 头文件申明
static const int kInitalOldGenerationLimitFactor = 2;
复制代码
上面代码中,看到几个关键信息:
- 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_ 同理可得到为最大老生代内存的一半
// Returns the maximum amount of memory reserved for the heap.
size_t MaxReserved() {
return 2 * max_semi_space_size_ + max_old_generation_size_;
}
复制代码
- 该函数获得当前系统当前编译器中最大的heap内存,通过计算为1432MB,笔记通过API实时调用能看到也是1432MB,可通过v8提供的getHeapStatistics()函数拿到heap_size_limit就是我们heap内存最大上限。
内存分布
内存模块中通常分为已申请区、使用区、可使用区等。new_space则分为激活区、未激活区。
以下通过三个API获取的当前系统及程序内存环境数据,笔者已换算成MB
- process.memoryUsage()
- v8.getHeapStatistics()
- v8.getHeapSpaceStatistics()
{
"code": 100000,
"data": {
"memoryUsage": {
"rss": 217,
"heapTotal": 181,
"heapUsed": 152,
"external": 11
},
"HeapStatistics": {
"total_heap_size": 181,
"total_heap_size_executable": 10,
"total_physical_size": 179,
"total_available_size": 1260,
"used_heap_size": 152,
"heap_size_limit": 1432,
"malloced_memory": 1,
"peak_malloced_memory": 11,
"does_zap_garbage": 0
},
"HeapSpaceStatistics": [
{
"space_name": "new_space",
"space_size": 32,
"space_used_size": 8,
"space_available_size": 9,
"physical_space_size": 32
},
{
"space_name": "old_space",
"space_size": 88,
"space_used_size": 86,
"space_available_size": 2,
"physical_space_size": 88
},
{
"space_name": "code_space",
"space_size": 9,
"space_used_size": 8,
"space_available_size": 1,
"physical_space_size": 9
},
{
"space_name": "map_space",
"space_size": 3,
"space_used_size": 3,
"space_available_size": 1,
"physical_space_size": 3
},
{
"space_name": "large_object_space",
"space_size": 50,
"space_used_size": 49,
"space_available_size": 1250,
"physical_space_size": 50
}
]
},
"msg": "metrics"
}
复制代码
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)区域,则过程如下:
//heap.h
// Returns the timer used for a given GC type.
// - GCScavenger: young generation GC
// - GCCompactor: full GC
// - GCFinalzeMC: finalization of incremental full GC
// - GCFinalizeMCReduceMemory: finalization of incremental full GC with
// memory reduction
HistogramTimer* Heap::GCTypeTimer(GarbageCollector collector) {
if (IsYoungGenerationCollector(collector)) {
return isolate_->counters()->gc_scavenger();
} else {
if (!incremental_marking()->IsStopped()) {
if (ShouldReduceMemory()) {
return isolate_->counters()->gc_finalize_reduce_memory();
} else {
return isolate_->counters()->gc_finalize();
}
} else {
return isolate_->counters()->gc_compactor();
}
}
}
// 是否是新生代收集器
static inline bool IsYoungGenerationCollector(GarbageCollector collector) {
return collector == SCAVENGER || collector == MINOR_MARK_COMPACTOR;
}
static inline GarbageCollector YoungGenerationCollector() {
return (FLAG_minor_mc) ? MINOR_MARK_COMPACTOR : SCAVENGER;
}
//gc-tracer.cc
const char* GCTracer::Event::TypeName(bool short_name) const {
switch (type) {
case SCAVENGER:
return (short_name) ? "s" : "Scavenge";
case MARK_COMPACTOR:
case INCREMENTAL_MARK_COMPACTOR:
return (short_name) ? "ms" : "Mark-sweep";
case MINOR_MARK_COMPACTOR:
return (short_name) ? "mmc" : "Minor Mark-Compact";
case START:
return (short_name) ? "st" : "Start";
}
return "Unknown Event Type";
}
复制代码
scavenge-cheney算法
- 当活跃区满了或主动GC,from会有两个操作,且都是在经过标记后,一个清除经过标记后的非存活对象,另一个复制经过标记后存活对象到to
- 交换from和to
- 交换中如果有存活对象经过清道夫标记后标记数>1,或当前to区域占比超过25%,则直接进入old_space
mark-sweep标记清扫
第四步里,清理 old_space 的过程叫做 Mark-sweep,也就是标记和清扫,这块占用内存很大,所以没有使用 Scavenge,这个回收过程包含了若干次(2-7次)标记过程和清理过程:
// full GC
void Heap::CollectAllAvailableGarbage(GarbageCollectionReason gc_reason) {
// Since we are ignoring the return value, the exact choice of space does
// not matter, so long as we do not specify NEW_SPACE, which would not
// cause a full GC.
// Major GC would invoke weak handle callbacks on weakly reachable
// handles, but won't collect weakly reachable objects until next
// major GC. Therefore if we collect aggressively and weak handle callback
// has been invoked, we rerun major GC to release objects which become
// garbage.
// Note: as weak callbacks can execute arbitrary code, we cannot
// hope that eventually there will be no weak callbacks invocations.
// Therefore stop recollecting after several attempts.
if (gc_reason == GarbageCollectionReason::kLastResort) {
InvokeOutOfMemoryCallback();
}
RuntimeCallTimerScope runtime_timer(
isolate(), &RuntimeCallStats::GC_Custom_AllAvailableGarbage);
if (isolate()->concurrent_recompilation_enabled()) {
// The optimizing compiler may be unnecessarily holding on to memory.
DisallowHeapAllocation no_recursive_gc;
isolate()->optimizing_compile_dispatcher()->Flush(
OptimizingCompileDispatcher::BlockingBehavior::kDontBlock);
}
isolate()->ClearSerializerData();
set_current_gc_flags(kMakeHeapIterableMask | kReduceMemoryFootprintMask);
isolate_->compilation_cache()->Clear();
const int kMaxNumberOfAttempts = 7;
const int kMinNumberOfAttempts = 2;
for (int attempt = 0; attempt < kMaxNumberOfAttempts; attempt++) {
if (!CollectGarbage(OLD_SPACE, gc_reason,
v8::kGCCallbackFlagCollectAllAvailableGarbage) &&
attempt + 1 >= kMinNumberOfAttempts) {
break;
}
}
set_current_gc_flags(kNoGCFlags);
new_space_->Shrink();
UncommitFromSpace();
}
复制代码
把当前 内存数据抽象为森林,如下
清理后
- 标记从根可达的对象为白色
- 遍历白色对象的邻接对象,直到所有可到对象都标记为白色
- 循环标记若干次(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增量标记策略,清理一会儿,执行一会儿应用程序。
void Heap::ReportExternalMemoryPressure() {
const GCCallbackFlags kGCCallbackFlagsForExternalMemory =
static_cast<GCCallbackFlags>(
kGCCallbackFlagSynchronousPhantomCallbackProcessing |
kGCCallbackFlagCollectAllExternalMemory);
if (external_memory_ >
(external_memory_at_last_mark_compact_ + external_memory_hard_limit())) {
CollectAllGarbage(
kReduceMemoryFootprintMask | kFinalizeIncrementalMarkingMask,
GarbageCollectionReason::kExternalMemoryPressure,
static_cast<GCCallbackFlags>(kGCCallbackFlagCollectAllAvailableGarbage |
kGCCallbackFlagsForExternalMemory));
return;
}
if (incremental_marking()->IsStopped()) {
if (incremental_marking()->CanBeActivated()) {
StartIncrementalMarking(i::Heap::kNoGCFlags,
GarbageCollectionReason::kExternalMemoryPressure,
kGCCallbackFlagsForExternalMemory);
} else {
CollectAllGarbage(i::Heap::kNoGCFlags,
GarbageCollectionReason::kExternalMemoryPressure,
kGCCallbackFlagsForExternalMemory);
}
} else {
// Incremental marking is turned on an has already been started.
const double kMinStepSize = 5;
const double kMaxStepSize = 10;
const double ms_step =
Min(kMaxStepSize,
Max(kMinStepSize, static_cast<double>(external_memory_) /
external_memory_limit_ * kMinStepSize));
const double deadline = MonotonicallyIncreasingTimeInMs() + ms_step;
// Extend the gc callback flags with external memory flags.
current_gc_callback_flags_ = static_cast<GCCallbackFlags>(
current_gc_callback_flags_ | kGCCallbackFlagsForExternalMemory);
incremental_marking()->AdvanceIncrementalMarking(
deadline, IncrementalMarking::GC_VIA_STACK_GUARD,
IncrementalMarking::FORCE_COMPLETION, StepOrigin::kV8);
}
}
复制代码
理论于实践的意义
注:本文Chrome调试部分基于版本(Version 74.0.3729.169 (Official Build) (64-bit))
首先认识下Chrome DevTools内存模块(当前静态内存分布,时刻)
- 准备好 Chrome,然后执行下面的代码
class BeikeClass {
constructor(){} //没有构造函数效果一样
}
class BeikeFangClass {
constructor(){
this.fang = new BeikeClass()
}
}
let array = new Array(100000).fill('').map(item => new BeikeFangClass())
复制代码
- 打开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
看,这里的波动图就能看到内存在涨。怎么操作?执行下面的代码
var x = [];
function grow() {
for (var i = 0; i < 10000; i++) {
document.body.appendChild(document.createElement('div'));
}
x.push(new Array(1000000).join('x'));
}
document.getElementById('grow').addEventListener('click', grow);
复制代码
然后点击开始记录当前时刻开始一段时间内的内存使用情况,下面仔细看下这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,然后做两两对比
class 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编译报错
no matching constructor for initialization of 'String::Utf8Value'
candidate constructor not viable: requires 1 argument, but 2 were provided
复制代码
修复方法两个:node升级到9以上或者修改源码,上面笔者说本文基于8.x,所以这里只能改源码了
String::Utf8Value utfString(isolate, str->ToString());
复制代码
删除utfString第一个参数即可(别问为什么,问就是看源码)
笔者这里有一段代码示例,(我们在启动的时候可以加上 –trace_gc 参数来观察运行过程中的详细GC信息)
class BeikeFang {
constructor(){
this.stamp = Date.now()
}
}
class BeikeINF {
constructor(){
this.list = new BeikeFang()
}
}
// 每个10ms向GC_VARS数组push一个{class: new BeikeINF()}
global.GC_VARS = []
const timer = setInterval(() => {
var GC_VAR = {class: new BeikeINF()}
global.GC_VARS.push(GC_VAR)
}, 10);
setTimeout(() => {
clearInterval(timer)
console.log('clear')
}, 100000);
复制代码
在clear之前然后每隔一段时间生成heapsnapshot
let filename = './' + Date.now() + '.heapsnapshot'
heapdump.writeSnapshot(filename, function(a, b){
console.log('succ filename', b)
filename = b
})
复制代码
下面生成了6个快照
导入Chrome->memory分析
上图中能明显看到内存是增长趋势,按照之前描述的方法进行分析对比就能知道是那块变量出现问题
我们可以使用memwatch提供的diff方法进行对应两个时间点的snapshot的diff
const hd = new memwatch.HeapDiff();
let diff = null
function sleep() {
return new Promise((resolve, reject) => {
setTimeout(() => {
diff = hd.end()
resolve(diff)
}, 5000);
})
}
diff = await sleep()
ctx.ajax(diff, {
error: false,
message: 'heap diff'
})
复制代码
下面是利用HeapDiff类生成的diff结果
{
before: {
nodes: 447998,
size_bytes: 52876240,
size: "50.43 mb"
},
after: {
nodes: 447670,
size_bytes: 52568136,
size: "50.13 mb"
},
change: {
size_bytes: -308104,
size: "-300.88 kb",
freed_nodes: 1975,
allocated_nodes: 1543,
details: [{
what: "Arguments",
size_bytes: -64,
size: "-64 bytes",
+: 0,
-: 2
},
{
what: "Array",
size_bytes: -41432,
size: "-40.46 kb",
+: 102,
-: 631
},
{
what: "BeikeFang",
size_bytes: 14112,
size: "13.78 kb",
+: 441,
-: 0
},
{
what: "BeikeINF",
size_bytes: 14112,
size: "13.78 kb",
+: 441,
-: 0
},
{
what: "Buffer",
size_bytes: -80,
size: "-80 bytes",
+: 0,
-: 1
},
{
what: "BufferList",
size_bytes: 48,
size: "48 bytes",
+: 1,
-: 0
},
{
what: "Closure",
size_bytes: 720,
size: "720 bytes",
+: 16,
-: 3
},
{
what: "Code",
size_bytes: -282656,
size: "-276.03 kb",
+: 15,
-: 392
},
{
what: "FSReqWrap",
size_bytes: -32,
size: "-32 bytes",
+: 0,
-: 1
},
{
what: "Native",
size_bytes: 344,
size: "344 bytes",
+: 6,
-: 1
},
{
what: "Number",
size_bytes: -16,
size: "-16 bytes",
+: 0,
-: 1
},
{
what: "Object",
size_bytes: 14288,
size: "13.95 kb",
+: 445,
-: 2
},
{
what: "Promise",
size_bytes: 384,
size: "384 bytes",
+: 4,
-: 0
},
{
what: "PromiseWrap",
size_bytes: 192,
size: "192 bytes",
+: 4,
-: 0
},
{
what: "ReadableState",
size_bytes: 192,
size: "192 bytes",
+: 1,
-: 0
},
{
what: "Socket",
size_bytes: 248,
size: "248 bytes",
+: 1,
-: 0
},
{
what: "String",
size_bytes: -8856,
size: "-8.65 kb",
+: 1,
-: 212
},
{
what: "TCP",
size_bytes: 32,
size: "32 bytes",
+: 1,
-: 0
},
{
what: "TickObject",
size_bytes: -128,
size: "-128 bytes",
+: 0,
-: 2
},
{
what: "Timeout",
size_bytes: 176,
size: "176 bytes",
+: 1,
-: 0
},
{
what: "Timer",
size_bytes: 32,
size: "32 bytes",
+: 1,
-: 0
},
{
what: "TimersList",
size_bytes: 72,
size: "72 bytes",
+: 1,
-: 0
},
{
what: "WritableState",
size_bytes: 224,
size: "224 bytes",
+: 1,
-: 0
},
{
what: "system / Context",
size_bytes: 56,
size: "56 bytes",
+: 3,
-: 2
}]
}
}
复制代码
从diff的结果看,明显看到
{
what: "BeikeFang",
size_bytes: 14112,
size: "13.78 kb",
+: 441,
-: 0
},
{
what: "BeikeINF",
size_bytes: 14112,
size: "13.78 kb",
+: 441,
-: 0
},
复制代码
那我们还可以进行进一步的diff,继续采用这种方式进行选择diff,最终确认内存爆掉的凶手
在node中查看GC数据
上面一节中说到了通过添加–trace_gc来查看运行过程中详细的GC数据
- 我们重点关注这里的红框部分,左边的是通过memwatch.stats函数监听得到的数据(后续会讲),右边的是通过启动参数得到的GC算法。能看到前面说的Mark-sweep、scavenge两个算法,针对老生代和新生代。
内存使用
- 作用域
const foo = function(){ let inner = {} }
复制代码
上面的代码foo函数没执行一次,都会生成一个foo的函数作用域,同时foo的局部变量也在函数作用域中,执行结束函数作用域也随之销毁,局部变量亦然。局部变量存活周期很短,会首先分配到新生代的From区域,函数执行结束后,也就被GC掉了。 – 作用域链
const 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或重新赋值。
查看内存数据
// 显示内存
const showMem = function() {
const mem = process.memoryUsage();
const format = function(bytes) {
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
};
console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss));
console.log('---------------------------------------------------------------------------');
};
// 不停分配内存但不释放
const useMem = function() {
const size = 20 * 1024 * 1024;
const arr = new Array(size);
for (let i = 0; i < size; i++) {
arr[i] = 0;
}
return arr;
};
const total = [];
for (let j = 0; j < 15; j++) {
showMem();
total.push(useMem());
}
showMem();
复制代码
第一个红框内:我们看到上面的分配内存只执行了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再看一遍
const useMem = function () {
const size = 200 * 1024 * 1024;
const arr = Buffer.alloc(size);
for (let i = 0; i < size; i++) {
arr[i] = 0;
}
return arr;
};
复制代码
这里我们执行了20次,执行后有多次的full_gc,还有多次的inc_gc,分配很频繁,前面也说到了,v8的GC原则中,在很频繁触发GC的时候会采用inc_gc也就是增量的,这样保证程序能及时响应我们请求。而且我们看到总堆内存和使用堆内存变化并不大,最大的就是常驻内存变化一直增加,这也就说明Buffer在node环境中不是通过v8的分规则进行分配的,我们在适当的时候也可以用这种方法突破v8的限制,当然我们也可以使用前面说的通过–max-old-space-size参数启动的时候指定。
我们可以看下对应的leak事件中的数据,其中包含事件开始时间和结束时间(NODE_UNIXTIME_V8类型),在五次GC过程中内存增长了(字节),这里只有内存(疑似)泄漏,没有详细的原因说明
{
start: 2019-06-28T06:06:53.000Z, // 这里目前最新node-memwatch版本把时间注掉了,可以自己打开,然后发一个自己源下的包
end: 2019-06-28T06:07:05.000Z,
growth: 584568,
reason: 'heap growth over 5 consecutive GCs (12s) - 167.25 mb/hr'
}
复制代码
我们可以按照上面讲的Comparison方式来对比我们泄漏前和泄漏后的堆内存变化,看那些增长明显
不过需要注意:我们在leak事件里不能主动结束HeapDiff()的end(),在leak会提前结束,所以我们还是在leak里手动生成heapsnapshot比较靠谱。