node_v8_metrics

node v8 内存一二三

基础知识

  • Node.js 进程的内存管理,都是有V8自动处理的,包括内存分配和释放。

  • V8内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,即可回收。

  • 回收是一个过程性的,从快速GC到最后的Full GC,是需要一段时间的。另外,Full GC是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏。还有一种就是引用不释放,导致无法进入GC环节,并且一直产生新的占用,这一般会发生在Javascript业务层面。

  • 定位内存泄漏,找有引用但不被使用

V8 内存构成(以下研究基于node 8.x 版本依赖的v8版本)

一个 V8 进程的内存通常由以下几个块构成:

Heap内存分配枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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频率低,按照GC 1.4GB至少要50ms以上,非增量更是1s以上。
  • 大对象区(large_object_space
    • 这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区
  • 代码区(code_space
    • 代码对象,会被分配在这里,无引用。唯一拥有执行权限的内存
  • map 区(map_space
    • 存放 CellMap,每个区域都是存放相同大小的元素,结构简单

Heap内存计算

1
2
3
4
5
6
7
8
9
10
11
12
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区+大对象区
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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系统通常为 832bits 通常为 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
2
3
4
// 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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"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,从创建到销毁过程大致过程如下。

  1. 这个对象被分配到了 new_space

  2. 随着程序的运行,new_space 塞满了,GC 开始清理 new_space 里的对象,house 因为还处于活跃状态,所以没被清理出去GC

  3. 清理了两遍 new_space,发现 house 依然还活跃着,就把 house 移动到了 old_space

  4. 随着程序的运行,old_space 也塞满了,GC 开始清理 old_space,这时候发现 house 已经没有被引用了,就把 house 给清理出去了,如果一直引用,则不会被清理

第二步里,清理 new_space 的过程叫做 Scavenge(不是胡说八道,证据在下面),即空间换时间,我们把new_space分为激活和未激活两个半(semi)区域,则过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//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

scavenge-cheney算法

  1. 当活跃区满了或主动GC,from会有两个操作,且都是在经过标记后,一个清除经过标记后的非存活对象,另一个复制经过标记后存活对象到to
  2. 交换fromto
  3. 交换中如果有存活对象经过清道夫标记后标记数>1,或当前to区域占比超过25%,则直接进入old_space

mark-sweep标记清扫

第四步里,清理 old_space 的过程叫做 Mark-sweep,也就是标记和清扫,这块占用内存很大,所以没有使用 Scavenge,这个回收过程包含了若干次(2-7次)标记过程和清理过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 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();
}

把当前 内存数据抽象为森林,如下
mark-sweep

清理后
mark-sweep
mark-sweep

  1. 标记从根可达的对象为白色
  2. 遍历白色对象的邻接对象,直到所有可到对象都标记为白色
  3. 循环标记若干次(2-7)
  4. 清理掉非白色的对象。

简单来说,Mark-sweep 就是把从根节点无法获取到的对象清理掉,与scavenge-cheney相比,scavenge-cheney只会复制存活对象,而新内存中本身就小,且存活对象不多,所以高效。mark-sweep则只会清除没被标记的对象,而老生代死对象少,这也就是mark-sweep针对老生代区域高效GC的原因。

mark-compact 标记整理和压缩

由于经过mark-sweep算法GC后,会出现不连续的空间,导致空间碎片,当下次需要移动大对象或对象晋升,但没有足够的空间使用,将会再次导致GC,但往往这个时候GC没必要的,因为很可能是刚刚GC过,所以怎么合理利用空间碎片就成了性能关键。于是mark-compact算法出现了。

  1. 将存活对象移动到old_space的一端
  2. 将另一端直接清空

整理之前(黑色为死对象)
整理之前

整理之后
整理之后
整理之后

我们对比下三种GC算法

GC算法对比

incremental-marking

由于GC期间,需要执行stop-the-world来保证应用程序逻辑和GC看到的一致性,所以v8中引入了incremental-marking增量标记策略,清理一会儿,执行一会儿应用程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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,然后执行下面的代码
1
2
3
4
5
6
7
8
9
10
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为对象内存总大小且包含内部引用对象大小

上面经过过滤后会看到申明的两个类 BeikeClassBeikeFangClass,可以看到 BeikeFangClass(6)下一层级有 BeikeClass(7),而且 2400000+3200000=5600000 也符合上面对 Shallow SizeRetained Size的解释(瞎解释?,官网走你

注:Retained Size 是性能调优阶段重要指标(主动GC

怎么样才能看到内存在涨呢?看个动态的Performance之前叫Timeline

`Performance`

看,这里的波动图就能看到内存在涨。怎么操作?执行下面的代码

1
2
3
4
5
6
7
8
9
10
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
    DOM GC
    Minor GC

  • 最后一次主动GC后内存也明显下降了

  • 我们还可以点击下方的 Call tree 来查看整个过程的调用树,我们能看到除Major GCMinor GCDOM GC之外的其他相关系统调用栈及具体信息
    Call Tree

  • 通过Event Log查看根据时间系统调用栈
    `Event Log`

怎么分析内存爆了?谁爆了?

那我们模拟一个泄漏的例子,模拟内存增长:

  • 打开 memory,然后执行下面的代码,每隔一段时间录制一段 HEAP SNAPSHOTS,然后做两两对比

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class BeikeClass {
    constructor(){
    this.fang = new ArrayBuffer(1000000)
    }
    }

    const _heap = {}

    setInterval(() => {
    _heap[Date.now()] = new BeikeClass()
    }, 100)

    `多次HEAP SNAPSHOTS`

  • 对于上面的图来说,我们的首先选中其中一个SNAPSHOTS比如SNAPSHOTS 7,然后修改Summary->Comparison,右侧选中SNAPSHOTS 7作对比,我们看到右侧红色框中new中比较多,也是我们需要关注的。

  • 重复上面的过程,选择对比SNAPSHOTS 8SNAPSHOTS 7,同样能得到一个对比
    二次对比

  • 对比两张图,可以看到两次对比中BeikeClassArrayBufferstring等几项都明显增长,我们点击三角展开就能定位变量最终引用比如这里的ArrayBuffer,点开后fang in BeikeiClass-1560244861634-_heap,及最终引用链(官网叫支配项)

  • 查看对象引用关系

    对象引用

最终我们也就能定位到是BeikeClassArrayBufferstring几个可能是‘凶手’,从而破案。

node环境怎么操作?

node环境下结合node-heapdump和自己监听内存使用情况或使用node-memwatch

  • node-memwatch监听当前程序(V8实例)的GC事件,然后会触发leakstats事件,leak是在内存泄漏时候会触发,stats是在GC后触发,上报数据

如果遇到node-memwatch编译报错

1
2
no matching constructor for initialization of 'String::Utf8Value'
candidate constructor not viable: requires 1 argument, but 2 were provided

修复方法两个:node升级到9以上或者修改源码,上面笔者说本文基于8.x,所以这里只能改源码了

1
String::Utf8Value utfString(isolate, str->ToString());

删除utfString第一个参数即可(别问为什么,问就是看源码)

笔者这里有一段代码示例,(我们在启动的时候可以加上 --trace_gc 参数来观察运行过程中的详细GC信息)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

1
2
3
4
5
let filename = './' + Date.now() + '.heapsnapshot'
heapdump.writeSnapshot(filename, function(a, b){
console.log('succ filename', b)
filename = b
})

下面生成了6个快照

6个heapsnapshot

导入Chrome->memory分析

6个heapsnapshot diff
上图中能明显看到内存是增长趋势,按照之前描述的方法进行分析对比就能知道是那块变量出现问题

我们可以使用memwatch提供的diff方法进行对应两个时间点的snapshotdiff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
{
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的结果看,明显看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
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数据

node-trace_gc

  • 我们重点关注这里的红框部分,左边的是通过memwatch.stats函数监听得到的数据(后续会讲),右边的是通过启动参数得到的GC算法。能看到前面说的Mark-sweepscavenge两个算法,针对老生代和新生代。

内存使用

  • 作用域

    1
    2
    3
    const foo = function(){
    let inner = {}
    }

    上面的代码foo函数没执行一次,都会生成一个foo的函数作用域,同时foo的局部变量也在函数作用域中,执行结束函数作用域也随之销毁,局部变量亦然。局部变量存活周期很短,会首先分配到新生代的From区域,函数执行结束后,也就被GC掉了。

  • 作用域链

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const foo1 = function(){
    const foo2 = function(){
    let inner_var = 1
    return (function(){
    return inner_var
    }())
    }
    foo2()
    }
    foo1()

    上面的代码foo1在执行的时候,首先生成foo1Function 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
2
3
4
5
6
7
8
9
// 显示内存
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('---------------------------------------------------------------------------');
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不停分配内存但不释放
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();

程序down

第一个红框内:我们看到上面的分配内存只执行了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
2
3
4
5
6
7
8
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;
};

分配后的增量GC
分配中常驻内存

这里我们执行了20次,执行后有多次的full_gc,还有多次的inc_gc,分配很频繁,前面也说到了,v8的GC原则中,在很频繁触发GC的时候会采用inc_gc也就是增量的,这样保证程序能及时响应我们请求。而且我们看到总堆内存和使用堆内存变化并不大,最大的就是常驻内存变化一直增加,这也就说明Buffer在node环境中不是通过v8的分规则进行分配的,我们在适当的时候也可以用这种方法突破v8的限制,当然我们也可以使用前面说的通过--max-old-space-size参数启动的时候指定。

我们可以看下对应的leak事件中的数据,其中包含事件开始时间和结束时间(NODE_UNIXTIME_V8类型),在五次GC过程中内存增长了(字节),这里只有内存(疑似)泄漏,没有详细的原因说明

1
2
3
4
5
6
{
start: 2019-06-28T06:06:53.000Z,
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比较靠谱。