如何定位Node.js的内存泄漏

JavaScript07

如何定位Node.js的内存泄漏,第1张

一、定位node.js内存漏洞的工具:

工欲善其事必先利其器,在排查时,我们还是需要一些工具来帮忙的。

devTool

这个是今年初出的 Node.js 调试工具,基于 Electron 将 Node.js 和 Chromium 的功能融合在了一起。操作起来比 node-inspector 方便,开放的 Timeline 功能还是比较实用的,虽然不是实时显示。

仅需要 devtool xxx.js,还可以通过 .devtoolrc 来进行参数定制,具体见 GitHub

heapdump + chrome devTool

这个是比较传统的定位内存泄漏的组合。heapdump 可以直接在代码中调用生成内存快照,然后将快照文件导入到 chrome devTool 进行分析,之后操作其实和前者就差不多了。不过,这个方案和前者有一点区别就是,前者实际还是在浏览器环境中,所以生成的内存快照会有一些 DOM 对象的存在,会有一定的干扰。而这个方案,是直接调用底层 V8 的方法,生成的快照只有 Node.js 环境中的对象。

memwatch

这个可以在代码里直接使用,实时检测内存动态,当发生内存泄漏的时候,会触发 ‘leak’ 事件,会传递当前的堆状态,配合 heapdump 有奇效。

二、定位问题:

用 devTool 的可以忽略下面的过程:

打开 Chrome Devtools ,进入到 Profiles 选项卡,点 Load 按钮,加载之前生成的快照。

对于内存快照,有四个视图,Summary,Comparison,Containment,Statistics,这里面常用的是前三个。

在 Summary 视图中,我们可以看到当前快照的全部信息,以及多个快照之间的信息。在列表里显示的都是对象的构造函数名字,可以先忽略被括号包裹的对象,优先观察其他的对象,最后再来看他们。后面的 shallow size 表示的是对象自身的大小,retained size 表示的是对象和它依赖对象的大小,一般是 GC 不可达的。

在 Comparison 视图中,我们可以进行多个快照之间的对比,这个用处比较大,如果我们将前两次快照进行对比,可能比较快速的定位出问题的对象。注意观察 New、Deleted、Delta,如果是内存泄漏的对象,可能是一直在 New,而没有 Deleted。

在 Containment 视图中,我们可以查看整个 GC 路径,当然一般不会用到。因为展开在 Summary 和 Comparison 列举的每一项,都可以看到从 GC roots 到这个对象的路径。通过这些路径,你可以看到这个对象的句柄被什么持有,从而定位问题产生的原因。值的注意的是,其中背景色黄色的,表示这个对象在 Javascript 中还存在引用,所以可能没有被清除。如果是红色的,表示的是这个对象在 Javascript 中不存在引用,但是依然存活在内存中,一般常见于 DOM 对象,它们存放的位置和 Javascript 中对象还是有不同的,在 Node.js 中很少遇见。

首先,检查了代码,发现所有的代码都是用new来分配内存,用delete来释放内存。那么,不能够用一个全程替换,来替换掉所有的new和delete操作符,因为代码的规模太大了,那样做除了浪费时间没有别的任何好处。好在源代码是用C++来写成的,所以,这意味着没有必要替换掉所有的new和delete,而只用重载这两个操作符。对了,值用重载这两个操作符,就能在分配和释放内存之前做点什么。这是一个绝对的好消息。也知道该如何去做。因为,MFC也是这么做的。需要做的是:跟踪所有的内存分配和交互引用以及内存释放。源代码使用Visual C++写成,当然这种解决方法也可以很轻松的使用在别的C++代码里面。要做的第一件事情是重载new和delete操作符,将会在所有的代码中被使用到。在stdafx.h中,加入:

#ifdef _DEBUG

inline void * __cdecl operator new(unsigned int size,

const char *file, int line)

{

}

inline void __cdecl operator delete(void *p)

{

}

#endif

这样,就重载了new和delete操作符。用$ifdef和#endif来包住这两个重载操作符,这样,这两个操作符就不会在发布版本中出现。看一看这段代码,会发现,new操作符有三个参数,它们是,分配的内存大小,出现的文件名,和行号。这对于寻找内存泄漏是必需的和重要的。否则,就会需要很多时间去寻找出现的确切地方。加入了这段代码,调用new()的代码仍然是指向只接受一个参数的new操作符,而不是这个接受三个参数的操作符。另外,也不想记录所有的new操作符的语句去包含__FILE__和__LINE__参数。需要做的是自动的让所有的接受一个参数的new操作符调用接受三个参数的new操作符。这一点可以用一点点小的技巧去做,例如下面的这一段宏定义,

#ifdef _DEBUG

#define DEBUG_NEW new(__FILE__, __LINE__)

#else

#define DEBUG_NEW new

#endif

#define new DEBUG_NEW

现在所有的接受一个参数的new操作符都成为了接受三个参数的new操作符号,__FILE__和__LINE__被预编译器自动的插入到其中了。然后,就是作实际的跟踪了。需要加入一些例程到重载的函数中去,让能够完成分配内存和释放内存的工作。这样来做, #ifdef _DEBUG

inline void * __cdecl operator new(unsigned int size,

const char *file, int line)

{

void *ptr = (void *)malloc(size)

AddTrack((DWORD)ptr, size, file, line)

return(ptr)

}

inline void __cdecl operator delete(void *p)

{

RemoveTrack((DWORD)p)

free(p)

}

#endif

另外,还需要用相同的方法来重载new[]和delete[]操作符。这里就省略掉它们了。

最后,需要提供一套函数AddTrack()和RemoveTrack()。用STL来维护存储内存分配记录的连接表。

这两个函数如下:

typedef struct {

DWORD address

DWORD size

char file[64]

DWORD line

} ALLOC_INFO

typedef list<ALLOC_INFO*>AllocList

AllocList *allocList

void AddTrack(DWORD addr, DWORD asize, const char *fname, DWORD lnum)

{

ALLOC_INFO *info

if(!allocList) {

allocList = new(AllocList)

}

info = new(ALLOC_INFO)

info->address = addr

strncpy(info->file, fname, 63)

info->line = lnum

info->size = asize

allocList->insert(allocList->begin(), info)

}

void RemoveTrack(DWORD addr)

{

AllocList::iterator i

if(!allocList)

return

for(i = allocList->begin()i != allocList->end()i++)

{

if((*i)->address == addr)

{

allocList->remove((*i))

break

}

}

}

现在,在程序退出之前,allocList存储了没有被释放的内存分配。为了看到是什么和在哪里被分配的,需要打印出allocList中的数据。使用了Visual C++中的Output窗口来做这件事情。

void DumpUnfreed()

{

AllocList::iterator i

DWORD totalSize = 0

char buf[1024]

if(!allocList)

return

for(i = allocList->begin()i != allocList->end()i++) {

sprintf(buf, "%-50s: LINE %d, ADDRESS %d %d unfreed ",

(*i)->file, (*i)->line, (*i)->address, (*i)->size)

OutputDebugString(buf)

totalSize += (*i)->size

}

sprintf(buf, "----------------------------------------------------------- ")

OutputDebugString(buf)

sprintf(buf, "Total Unfreed: %d bytes ", totalSize)

OutputDebugString(buf)

}

现在就有了一个可以重用的代码,用来监测跟踪所有的内存泄漏了。这段代码可以用来加入到所有的项目中去。虽然它不会让你的程序看起来更好,但是起码它能够帮助你检查错误,让程序更加的稳定。

内存生命周期:

程序的运行需要 内存 ,只要程序提出要求,操作系统或者运行是就必须供给内存。

对于持续运行的服务进程,必须及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

内存泄露案例: 全局变量、未销毁的定时器和回调函数( setInterval )、闭包(外部函数的变量被引用,得不到释放)、DOM 引用(移除了元素,但是仍然有对 元素的引用)

用于标识无用变量的方式有两种:标记清除法和引用计数法。

当变量进入环境时,这个变量标记为“进入环境”;而当变量离开环境时,则将其标记为“离开环境”。

可使用一个“进入环境”的变量列表及一个“离开环境”的变量列表来跟踪变量的变化,也能够翻转某个特殊的位来记录一个变量什么时候进入环境及离开环境。

当声明了一个变量并将一个引用类型值赋给该变量时,则该值的引用次数就是1;若是同一个值又被赋给另外一个变量,则该值的引用次数加1;若是包含对该值引用的变量又取得了另一个值,则该值的引用次数减1。当该值的引用次数变为0时,则能够回收其占用的内存空间。 当垃圾回收器下一次运行时,就会释放那些引用次数为0的值所占用的内存。

怎样可以观察到内存泄漏呢?

经验法则 :如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。这就要求实时查看内存占用。

前面说过,及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。

在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。 这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。