因为内存这章很多都是理论上的知识,由于现代高级语言都是会自动回收内存的,不像以前写C最后还要手动置空回收变量了,所以很多理论可能只能意会,那些我将不会总结出来,因为很多理论我个人也只是了解了一点,无法很清晰的写出原因来帮大家分析;
看完这章,应该要能了解以下知识
- V8的内存分代与交替存活(新老生代的交替回收)
- 内存的自动回收与闭包间的关系(老生常谈,以前说过,就不说了)
- 什么是堆外内存?
- 造成内存泄漏的几种原因及缓存存活限制
- 内存泄漏的排查方式
接下来,我们根据列的这几点来看整章的知识点
V8中的内存分代
- 新生代:存活时间较短的对象,会被GC自动回收的对象及作用域,比如不被引用的对象及调用完毕的函数等。
- 老生代:存活时间较长或常驻内存的对象,比如闭包因为外部仍在引用内部作用域的变量而不会被自动回收,故会被放在常驻内存中,这种就属于在新生代中持续存活,所以被移到了老生代中,还有一些核心模块也会被存在老生代中,例如文件系统(fs)、加密模块(crypto)等
- 如何调整内存分配大小: 启动node进程时添加参数即可
node --max-old-space-size=1700 <project-name>.js
调整老生代内存限制,单位为MB(貌似最高也只能1.8G的样子)(老生代默认限制为 64/32 位 => 1400/700 MB)node --max-new-space-size=1024 <project-name>.js
调整新生代内存限制,单位为KB(老生代默认限制为 64/32 位 => 32/16 MB)
内存回收时使用的算法
- Scavenge 算法(用于新生代,具体实现中采用 Cheney 算法)
- 算法的结果一般只有两种,空间换时间或时间换空间,Cheney属于前者
- 它将现有的空间分半,一个作为 To 空间,一个作为 From 空间,当开始垃圾回收时会检查 from 空间中存活的对象并赋复制入 To 空间中,而非存活就会被直接释放,完成复制后,两者职责互换,下一轮回收时重复操作,也就是说我们本质上只使用了一半的空间,明显放在老生代这么大的内存浪费一半就很不合适,而且老生代一般生命周期较长,需要复制的对象过多,正因此所以它就被用于新生代中,新生代的生命周期短,一般不会有这么大的空间需要留存,相对来说这是效率最高的选择,刚和适合这个算法
- 前面我们提到过,如果对象存活时间较长或较大就会从新生代移到老生代中,那么何种条件下会过渡呢,满足以下2个条件中的一个就会被过渡
- 在一次 from => to 的过程中已经经历过一次 Scavenge 回收,即经过一次新生代回收后,再下次回收时仍然存在,此时这个对象将会从本次的 from 中直接复制到老生代中,否则则正常复制到 To
- from => to 时,占用 to 的空间达到 25% 时,将会由于空间使用过大自动晋升到老生代中
- Mark-Sweep & Mark-Compact(用于老生代的回收算法)
- 新生代的最后我们提到过,Cheney 会浪费一半的空间,这个缺点在老生代是不可原谅的,毕竟老生代有 1.4G 不是,浪费一半就是 700M 啊,而且每次都去复制这么多常驻对象,简直浪费,所以我们是不可能继续采纳 Scavenge 的;
- mark-sweep 顾名思义,标记清除,上一条我们提到过,我们要杜绝大量复制的情况,因为大部分都是常驻对象,所以 mark-sweep 只会标记死去的老对象,并将其清除,不会去做复制的行为,因为死对象在老生代中占比是很低的,但此时我们很明显看到它的缺点就是清除死去的部分后,可能会造成内存的不连续而在下次分配大对象前立刻先触发回收,但是其实需要回收的那些在上轮已经被清除了,只是没有将活着的对象连续起来
缺点举例:这就像 buffer 一样,在一段 buffer 中,我们清除了其中断断续续的部分,这些部分就为空了,但是剩下的部分会变得不连续,下次我们分配大对象进来时,大对象是一个整体,我们不可能将其打散分别插入原本断断续续的空间中,否则将变的不连续,下次我们去调用这个大对象时也将变得不连续,这就没有意义了,这就像你将一个人要塞进一个已经装满了家具的房间里一样,各个家具间可能会存在空隙,但是你一个整体的人怎么可能打散分散到这些空间?并在下次调用时在拼到一起呢(什么纳米单位的别来杠,你可以自己想其他例子) - 在这个缺点的基础上,我们使用了 mark-compact 来解决,它会在 mark-sweep 标记死亡对象后,将活着的对象全部向一侧移动,移动完成后,一侧全为生,一侧全为死,此时我们便可以直接将死的一侧直接清理,下次分配大对象时,直接从那侧拼接上即可,仿佛就像把家具变成工整了,将一些没用的小家具整理到一侧,将有用的其他家具全部工整摆放,在下次有新家具时,将一侧的小家具全部丢掉,在将新的放到有用的旁边紧密结合。
需要注意的是:在三种回收算法任一进行时都会中断 js 的当前逻辑,新生代的比较小,我们就不说他了,但老生代一次清理就是很大一部分。
此时我自己也有这个疑问:这停顿的时间岂不是过于长了?可能我们有一段逻辑是要展示给用户的需要立即得到结果,而剩下的一段是一些后台进行的数据存储等逻辑,如果此时能够先将结果返回,然后再进行后续跟用户无关的操作,这不是更好吗?否则会因为整体阻塞性的操作而影响用户体验。
V8的解决方案:V8 其实在 mark 的标记阶段已经开始入手解决这个问题了,V8将其标记过程由 一口气标记 => 增量标记,这样做的好处就是,标记死亡对象的过程是渐进式的,而逻辑就不会停止那么久,由于只会在回收时停止,所以渐进式的清理也会让我们的逻辑渐进式的执行,这种标记方式我们称为 增量标记(incremental marking),改进后垃圾回收的最大停顿时间减少到了原本的 1/6 左右,虽然总时间没有减少,但是用户体验是直线上升的,真是史诗级的改进,佩服佩服!
查看垃圾回收日志的方式是在启动时添加参数 --trace_gc
即可,执行结束后,会在 gc.log
文件中得到所有垃圾回收信息
内存回收与闭包的关系
前面我们已经提到过由新生代晋升入老生代的两个条件,而闭包是有可能触发这个操作的,由于闭包留存作用域的特性,我们很容易就可以达到占用新生代中 from => to 占用 25% 的情况,32/2 * 0.25 = 4 ,也就说只要一个对象达到 4M 时即可晋升入老生代,而在 utf8 的编码格式下,一个中文占用 3 个字节,4M === 4 * 1024 * 1024 === 4194304,即 4194304 字节就可实现晋升,这在一个中大型服务中,还是比较容易达到要求的,而另一种晋升方式,只要已经实现过一次 Scavenge 即可晋升,闭包实在是太容易满足了~
什么是堆外内存,和堆内内存有什么关系?
书中在这节,举了2个例子来说明这个点,第一个例子是不停的给数组分配空间,然后使用 Process.memoryUsage()
去查看 rss(系统常驻内存)、heapTotal(堆中总共申请的内存量)、heapUsed(目前堆中使用的内存量)这三项指标,以下使用书中的代码来证明 buffer 并非通过 V8 分配
var showMem = function () {
var mem = process.memoryUsage()
var 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)}`)
}
var useMem = function () {
// 使用非 buffer 的情况
var size = 20 * 1024 * 1024
var arr = new Array(size)
for (var i = 0; i < size; i++) {
arr[i] = 0
}
return arr
// 使用 buffer 的情况
var size = 200 * 1024 * 1024
var buffer = new Buffer(size)
for (var i = 0; i < size; i++) {
buffer[i] = 0
}
return buffer
}
var total = []
for (var j = 0; j < 15; j++) {
showMem()
total.push(useMem())
}
showMem()
未使用 buffer 的结果
Process: heapTotal 6.42MB, heapUsed 4.18MB, rss 21.06MB
Process: heapTotal 166.93MB, heapUsed 164.40MB, rss 181.48MB
Process: heapTotal 326.95MB, heapUsed 324.41MB, rss 341.52MB
Process: heapTotal 486.96MB, heapUsed 484.41MB, rss 501.62MB
Process: heapTotal 646.97MB, heapUsed 644.41MB, rss 661.64MB
Process: heapTotal 806.98MB, heapUsed 804.41MB, rss 714.21MB
Process: heapTotal 966.99MB, heapUsed 964.41MB, rss 860.75MB
Process: heapTotal 1127.00MB, heapUsed 1124.41MB, rss 1022.77MB
Process: heapTotal 1287.02MB, heapUsed 1284.42MB, rss 1183.73MB
<--- Last few GCs --->
[17556:0000018ED123A9B0] 1823 ms: Scavenge 1284.3 (1292.1) -> 1284.3 (1292.1
) MB, 0.1 / 0.0 ms allocation failure incremental marking delaying mark-sweep
[17556:0000018ED123A9B0] 2332 ms: Mark-sweep 1284.3 (1292.1) -> 1283.9 (1289
.1) MB, 509.2 / 0.0 ms (+ 11.9 ms in 5 steps since start of marking, biggest st
ep 3.9 ms, walltime since start of marking 1973 ms) last resort
[17556:0000018ED123A9B0] 2703 ms: Mark-sweep 1283.9 (1289.1) -> 1283.9 (1289
.1) MB, 370.4 / 0.0 ms last resort
<--- JS stacktrace --->
使用 buffer 的结果
Process: heapTotal 6.42MB, heapUsed 4.18MB, rss 21.11MB
Process: heapTotal 6.92MB, heapUsed 4.40MB, rss 221.57MB
Process: heapTotal 6.92MB, heapUsed 4.41MB, rss 421.60MB
Process: heapTotal 8.92MB, heapUsed 3.95MB, rss 622.03MB
Process: heapTotal 8.92MB, heapUsed 3.95MB, rss 822.04MB
Process: heapTotal 8.92MB, heapUsed 3.95MB, rss 1022.04MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1222.72MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1245.13MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1375.48MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1575.99MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1481.48MB
Process: heapTotal 9.42MB, heapUsed 3.94MB, rss 1485.76MB
Process: heapTotal 6.42MB, heapUsed 3.50MB, rss 1546.98MB
Process: heapTotal 6.42MB, heapUsed 3.50MB, rss 1619.86MB
Process: heapTotal 6.42MB, heapUsed 3.50MB, rss 1623.78MB
Process: heapTotal 6.42MB, heapUsed 3.48MB, rss 1630.46MB
由上述结果,我们通过 heapTotal heapUsed 与 rss 是否同步增加就可以看出,当使用buffer时,增加的只有常驻内存 rss,而通过 V8 分配的堆内存却基本没有变化,这就证明了 buffer 并不是通过 V8 来分配处理的,那么显而易见,用 buffer 存储的内存块就是 堆外内存,所以它并不受 V8 的内存分配机制限制,因此没有报内存溢出的错误。
结论:buffer 声明的都为堆外内存,它们是由系统限定而非 V8 限定,直接由 C++ 进行垃圾回收处理,而不是 V8,在进行网络流与文件 I/O 的处理时,buffer 明显满足它们的业务需求,而直接处理字符串的方式,显然在处理大文件时有心无力。所以由 V8 处理的都为堆内内存。
造成内存泄漏的几种可能及限制缓存生命周期的重要性
内存泄漏的情况在网络服务与文件I/O中可能出现的多点,毕竟网络服务中我们大量使用了缓存~下面为几种可能
- 将内存当做缓存来使用,就像闭包用于用作存储一样,当需要存储的数据日积月累越来越多时,如果不将其适当清除,最终一定会超过系统限制而造成内存泄漏,严格意义上来说,我们是绝对不建议将内存当做缓存使用的,而且缓存应当是共用的存在,而在单一进程中将内存当做缓存,因为要进行大量的进程间通信将会导致缓存的公共使用及其不方便,所以我们才都会使用 redis 或 memcached 来作为通用缓存库,而这两者对缓存生命周期的处理比你自己写好多啦~
在前面的章节我们提到过,在处理高并发情景时,我们一定会对最高并发量做一个限制,就像 V8 默认是 5 个并发一样,剩下未进行的任务将会作为队列存储,在前者完成后,从队列中取出继续进行,正常情况下,我们的写入处理速度是一定会跟得上这个累计内存的速度的,但是我们仍然会有生产大于写入的可能,而导致队列过长最终大于限制内存而造成内存泄漏
解决方案:此时我们则应该限制队列的长度,当超过一定长度时,立刻进行报警通知运维人员,或当响应时间过长时直接返回给用户稍后再试而避免做写入处理等。。。
内存泄漏的排查
书中主要介绍了几种工具的使用,大家自己看吧,这个总结来没有什么意义~何况这2个还是比较老的工具了
heapdump
memwatch