1. V8内存管理和相关问题
Node.js基于V8引擎,其内存管理就是V8的内存管理。
V8内置了自动垃圾回收(GC)。
V8由Google开发,使用C++编写,最早在Chrome中使用。相对于其他JavaScript引擎将代码装换成字节码或解释执行,V8将代码变异成原生机器码,并且使用了如内联缓存等方法来提高性能。JavaScript程序在V8引擎下运行速度媲美二进制程序。
autoauto- 1. V8内存管理和相关问题 (原)auto - 1.1. V8内存设计auto - 1.1.1. 内存分区auto - 1.1.2. 内存生命周期auto - 1.2. V8垃圾回收auto - 1.2.1. 标记清除法auto - 1.2.2. 垃圾回收算法auto - 1.3. Node.js如何检视内存和GCauto - 1.3.1. 测试auto - 1.3.1.1. external内存和GC测试auto - 1.3.1.2. heap内存和GC测试auto - 1.3.2. 更多auto - 1.3.2.1. 总结auto - 1.4. 常见的内存泄漏案例auto - 1.4.1. 全局变量auto - 1.4.2. 闭包auto - 1.4.3. 消费者速度小于生产者auto - 1.5. 如何发现和定位内存问题auto - 1.5.1. memwatch-nextauto - 1.5.2. heapdumpauto - 1.5.3. 使用PM2做 Memory Threshold Auto Reload 处理autoauto
1.1. V8内存设计
1.1.1. 内存分区
V8中,内存分为几个部分:
新生代区 new space
大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁。老生代区 old space
属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针。大对象区 large object space
这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区。代码区 code space
代码对象,会被分配在这里。唯一拥有执行权限的内存。map区 map space
存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。
1.1.2. 内存生命周期
一个对象A创建后,被分配到新生代区。
新生代区满后,V8进行Scavenge操作,清除需要回收的。如果对象A还有效,则保留。
如果对象A再次被清理(或者满足其他条件),则晋升到老生代区。
老生代区满后,V8进行Mark Sweep操作,将这时需要回收的对象A清除。
1.2. V8垃圾回收
1.2.1. 标记清除法
当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。
与之对应的还有引用计数法,但会因循环引用导致内存泄漏,所以很少见到。
1.2.2. 垃圾回收算法
由于新生代和老生代存放了不同性质的内存对象,其清除方式也不同。
简单来说,新生代使用Scavenge算法。分成From和To两个区,将需要回收的对象留在From,其他移到To,然后交换From和To。垃圾回收将To空间内存全部释放。
老生代使用Mark Sweep算法,直接标记需要被回收的对象,在垃圾回收时释放相应地址空间。
此外,还有Mark Compact算法,将存活和需要回收的对象放在地址区域的两边,以避免回收后内存不连续的问题。
1.3. Node.js如何检视内存和GC
Node.js提供了一些API来帮助开发者检视程序的内存使用状况和GC情况。
process.memoryUsage()
会返回一个内存使用信息对象,单位为字节Byte。类似:
Object {rss: 25358336, heapTotal: 8232960, heapUsed: 5488248, external: 8608}
- rss 驻留集大小, 即程序分配的物理内存大小,包括堆、栈、代码段
- heapTotal V8堆总大小
- heapTotal V8堆使用量大小
- external V8绑定到Javascript的C++对象的内存大小
对象,字符串,闭包等存于堆内存。 变量存于栈内存。 实际的JavaScript源代码存于代码段内存。
1.3.1. 测试
下面的测试,执行时都给node添加启动参数--trace-gc
和--expose-gc
。
前者可以打印出GC操作log,后者允许在代码中控制GC。
1.3.1.1. external内存和GC测试
尝试用fs.readFileSync('/path/')
读取一个100M左右的文件。发现rss和external增加了100M左右。
heapUsed则只增加了一点。看来直接读取文件返回的是一个C++对象的引用。
即使没有保存fs.readFileSync()
返回的对象,rss和external还是增大了。且即使等待,这部分内存也不会被回收。
在代码中调用global.gc()
主动进行GC回收。
回收后,增加的100M左右rss被释放。
1.3.1.2. heap内存和GC测试
如果使用fs.readFileSync('/path/', 'utf-8')
,返回的将是一个字符串对象。会占用heap内存。
反复执行10次并保留每次的引用,发现程序错误:
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
这体现了V8的堆内存大小是有限制的。这个限制可以修改。
老生代用node --max-old-space-size=xxxx
(单位MB)修改。
新生代用node --max-new-space-size=xxxx
(单位MB)修改。
1.3.2. 更多
-
node --v8-options
print v8 command line options -
node --v8-pool-size=num
set v8's thread pool size -
node --prof-process
process v8 profiler output generated using --prof -
node --track-heap-objects
track heap object allocations for heap snapshots -
os.totalmem()
系统总内存 -
os.freemem()
系统空闲内存
1.3.2.1. 总结
通过测试,可以发现GC的一些表面规则:
- 部分函数会创建C++对象并返回其引用,而不是JS对象。因此占用external而非heap。
- global.x 不会被回收。const x,如果后面没有使用x,则会很快被回收。
1.4. 常见的内存泄漏案例
1.4.1. 全局变量
全局变量global.xxx不会被GC回收。
未声明变量会隐式产生全局变量:
function foo() {
// 即 global.a = 1;
a = 1;
}
使用tslint等工具规范代码可以避免此种问题。
1.4.2. 闭包
闭包就是能够读取其他函数内部变量的函数。
闭包作用域会保留其中涉及的引用,会导致对象无法被回收。
要注意的一个知识点是:每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。
看一个经典问题(曾经是web框架meteor的著名bug):
let theThing = null;
const replaceThing = function () {
const originalThing = theThing;
function unused() {
if (originalThing) {}
}
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: () => {}
};
};
setInterval(() => {
replaceThing();
console.log(process.memoryUsage().heapTotal)
}, 1000);
运行这段代码,会发现rss、heapTotal、heapUsed不断增长。
这是因为在replaceThing()的词法作用域中,声明了originalThing,而闭包函数unused()使用了originalThing;theThing.someMethod()虽然是空函数,但由于上面提及的知识点,其闭包作用域也包含了originalThing,而theThing定义在文件作用域,无法回收。
这些就导致了每次重声明的originalThing都无法回收,就会有大量longStr积累在堆中。
如果需要解决这个问题,可以在replaceThing()的最后加originalThing = null;
。
这个问题出现的关键在于,变量间产生了循环使用,且一个在闭包作用域中,导致其每次定义后,都无法释放。
也可以改成下面这样,效果一样:
let theThing = null;
const replaceThing = function () {
const originalThing = {
theThing,
longStr: new Array(1000000).join('*'),
};
function unused() {
if (originalThing) {}
}
theThing = ()=> {}
};
setInterval(() => {
replaceThing();
console.log(process.memoryUsage().heapTotal)
}, 1000);
1.4.3. 消费者速度小于生产者
常见于使用消息队列或大量IO操作时。由于作为生产者时,消费者一方不能及时处理任务,导致任务数据在生产者内存缓存中大量积存,最终导致内存溢出。
1.5. 如何发现和定位内存问题
1.5.1. memwatch-next
memwatch-next是一个能发现内存泄漏问题,并给出简单问题分析的工具。
使用如下:
// 使用方式1:监听内存泄漏
// 5个连续GC周期下,
memwatch.on('leak', function (info) {
console.warn("MEMLEAK", info);
});
// 使用方式2:生成一段时间的内存和对象变化报告
const hd = new memwatch.HeapDiff();
setTimeout(() => {
const diff = hd.end();
console.log(JSON.stringify(diff, null, " "));
}, 1000 * 10);
在上面那个闭包引起内存泄漏的代码中使用,可以发现部分报告输出如下:
{
"change": {
"details": [
{
"what": "Closure",
"size_bytes": 6624,
"size": "6.47 kb",
"+": 96,
"-": 4
},
{
"what": "String",
"size_bytes": 93003520,
"size": "88.7 mb",
"+": 205,
"-": 22
}
]
}
}
由此,可以推测是大量String对象造成内存占用,可能和闭包有关。
1.5.2. heapdump
heapdump是一个用于导出V8 Heap Snapshot的工具。导出数据可以导入到Chrome浏览器查看。
和memwatch结合使用:
memwatch.on('leak', function (info) {
console.warn("MEMLEAK", info);
heapdump.writeSnapshot('' + Date.now() + '.heapsnapshot');
});
等到leak事件触发后,便会导出一个.heapsnapshot文件。从 [Chrome开发者工具]-[memory]-[Profiles]-[Heap snapshot] 中,Load这个文件。
然后就可以看到报告内容。
可以按Shallow Size排序,查看是何种对象占用了大量内存。(如果内存泄漏时缓慢增长的,则可以等待足够长时间后再导出报告)
对上面的闭包例子做报告,可以发现占用最多的是string,有多个大体积的“***...*”字符串。
1.5.3. 使用PM2做 Memory Threshold Auto Reload 处理
有时内存泄漏的问题隐藏地很深,短时间内难以定位和解决。这时要优先保证服务正常运行不收内存
问题的影响,就可以利用pm2管理工具的内存限制重启特性。
具体方式是在配置文件中增加max_memory_restart
属性:
apps: [{
max_memory_restart: '300M'
}]