V8

相同不可变字符串的内存共享被称为String interning,
Java是支持String interning的典型例子.
在Java中, 动态创建的字符串除非手动调用intern方法, 否则不会被共享.

JavaScript的引擎通常是支持String interning的, 在不同的实现中会有差异.

子字符串可能被引擎存储为父字符串的切片(slice), 以节省内存.

这种方法的缺点是需要为不同的JavaScript运行时重新编译.

N-API引入了一种标准应用程序二进制接口(ABI)以消除C++插件需要重新编译的缺陷.
这种API是C API.
这种插件可以在编译之后对Node.js向后兼容.

Rust 插件并没有被原生支持, 但可以模仿C++插件的形式创建成动态库(dylib).
当然, 这并不是最好的方式, 已经有一个名为neon的库提供了Node.js模块的绑定,
使用它可以更轻松地为Node.js创建Rust插件.

Clinic.js是一个Node.js诊断工具包, 可以辅助发现长时间运行的Node.js程序的性能问题.

该工具通过监视CPU, 内存, 事件循环延迟, I/O句柄数量来发现可能的故障点.

doctor得出的结果可能不会令人满意, 因为会有很多unknown issues.
在这种情况下, 工具仅仅是展示性能图表, 这与采用其他手段监控运行情况的功能是一样的.

该工具用于得出火焰图(flame chart).

火焰图可以清晰地展示出函数执行时间的长短, 从而发现性能瓶颈.

该工具能够生成独特的泡泡图, 可用于发现连续的异步操作中的性能瓶颈.

大量使用V8的异步原语会导致性能问题, 原因如下:

  • Promise造成的额外封装会损失性能, 并且增加内存用量.
  • await非常慢, 与正在执行的异步任务无关, 单纯被事件循环的速度拖累.
    这意味着如果一个函数的返回值类型是 T | PromiseLike<T>,
    则在处理同步结果时, 很有可能因为await而得到本不应该存在的性能惩罚.
  • v8对await的实现方式导致了由代码习惯导致的很小的代码差异会造成相当不同的结果:
    https://stackoverflow.com/a/70979225/5462167
function syncExample() {
return '123'
}
async function asyncExample1() {
return await '123'
}
async function asyncExample2() {
return '123'
}
function asyncExample3() {
return Promise.resolve('123')
}
console.time('sync1')
for (let i = 10000; i--; ) {
syncExample()
}
console.timeEnd('sync1') // 浏览器 0.31ms, Node.js 0.3ms
console.time('sync2')
for (let i = 10000; i--; ) {
await syncExample()
}
console.timeEnd('sync2') // 浏览器 25ms, Node.js 3ms
console.time('async1')
for (let i = 10000; i--; ) {
await asyncExample1()
}
console.timeEnd('async1') // 浏览器 55ms, Node.js 4ms
console.time('async2')
for (let i = 10000; i--; ) {
await asyncExample2()
}
console.timeEnd('async2') // 浏览器 25ms, Node.js 2.4ms
console.time('async3')
for (let i = 10000; i--; ) {
await asyncExample3()
}
console.timeEnd('async3') // 浏览器 26ms, Node.js 2ms

内存未被回收的一种可能性是V8的垃圾回收器还没有执行.

使用该v8命令行参数可在global里注入一个gc函数, 调用该函数会触发垃圾回收.

使用Chrome Devtool远程调试, 保存堆快照, 进而分析堆占用.

可以使用heapdump模块在程序里自行保存堆快照.

当rss增长, 而heap相对稳定时, 说明发生了堆以外的内存泄漏.

由于缺乏调试工具, 堆以外的内存泄漏通常很难寻找故障点.

堆以外的内存泄漏还会 反映到进程的堆上 (v8自身运行也需要堆, 因此进程堆会比应用使用的堆要大),
具体表现为进程的堆比应用的堆要大很多.

这可能由于以下原因导致:

  • Buffer泄漏: Node.js的Buffer是分配在V8引擎外部的, 会显示在memoryUsage的external值里.
  • DLL泄漏: 使用的C++模块存在内存泄漏, 会显示在memoryUsage的external值里.
  • 栈泄漏: 栈泄漏的情况很少见, 这通常是因为函数一直在调用别的函数而没有返回, 导致栈一直在加深.
  • 线程泄漏: 这是栈泄漏的另一种形式.
    与栈泄漏的区别在于, 栈泄漏是栈的深度在增加, 而线程泄漏是因为栈的总数在增加.

使用指标监控工具采集Node.js进程的相关信息.
这有助于发现一些指标之间的关联, 从而辅助推测故障点.

Linux上的pmap命令可以打印一个进程的内存映射, 可以配合gdp调查内存泄漏.
执行pmap命令需要相应的权限.

pmap -x {PID}

gdb可以转储进程的内存, 同样需要相应的权限.

sudo gdb -pid {PID}
dump memory dump.bin {内存地址开始} {内存地址结尾}
# pmap打印出的第1列内容是内存地址, 第2列内容是这段内存占用的Kbytes(当pmap不带-x时, 值的末尾会有K),
# 可以以此作为dump的参数.
# 在此我们假设第1列是00007f8b00000000, 第2列是65512, gdp可以直接理解以下命令:
# dump memory dump.bin 0x00007f8b00000000 0x00007f8b00000000+65512

在转储完成后, 可以通过xxd查看十六进制的内存数据, 或通过strings将转储的内存数据转换为字符串以定位问题.

Docker容器使用了不同的命名空间, 在宿主机用gdb转储进程内存可能得到全0的数据文件
(不清楚这否是因为内存的确为全0导致的, 不像是正常结果).

在容器内部使用gdb需要给容器设置 privileged: true, 但在我的用例中同样得到全0的数据文件.

以字节为单位, 包括所有的C++和JavaScript对象在内的驻留集(resident set)的大小.
可视作进程实际占用的物理内存大小.

堆(heap)的总大小, 是驻留集的一部分.

实际使用的堆大小(used heap), 是堆的一部分.

绑定到由V8引擎管理的JavaScript对象的C++对象的内存, 是驻留集的一部分.

进程内存在RAM中保留的部分, 与交换空间(swap space)或文件系统(file system)相对.

代码.

值类型, 指针.

V8引擎会动态分配堆空间, 堆的大小会在必要时增大或缩小.
所有引用类型(对象, 字符串, 闭包等).

已使用的堆空间.

与之相对的是已分配但还未被使用的堆空间, 是被V8引擎申请但还暂未使用的内存.