纵横研究院NodeJS技术专题社区

【原创】NodeJs内存泄漏示例学习

2019-03-24  本文已影响107人  拂云枝

内存泄漏就是应该被回收的内存,因为被标记为可达到对象而没有被正常回收。可达到的对象可以简单理解为被引用,但在实际情况中可能因为闭包等隐式引用而不易被发觉,下面几种常见的场景列举了一些内存泄漏的示例,供学习分析。

相关文章

目录

全局变量

全局变量不会被GC,因此使用全局变量(尤其是全局变量为对象且会增长的时候)要在不需要的时候将变量删除,或将变量设置为nullundefined

// 定义全局变量
var1 = 'var1' // 不推荐写法,且在严格模式下会报错
global.var2 = 'var2'
global.var3 = []

// 释放全局变量
global.var1 = null
global.var2 = undefined
delete global.var3

闭包

闭包:实现外部作用域访问内部作用域中变量的方法。这得益于高阶函数的特性:函数可以作为参数或返回值。

闭包是一个造成内存泄漏比较常见的场景。一个简单的闭包形式如下:

const func = function () {
  const data = 'inner variable'
  return () => {
    return data
  }
}

// 实现了外部访问 func 内部作用域的变量data
const getData = func()
console.log(getData())

下面介绍一个在闭包中产生内存泄漏的经典示例(引用于:https://github.com/ElemeFE/node-interview/issues/7

function format (bytes) {
  return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}

let theThing = null
let replaceThing = function () {
  let leak = theThing
  let unused = function () {
    if (leak) { console.log('hi') }
  }
  // 不断修改引用
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('a')
    }
  }

  global.gc() // 手动触发GC,保证能回收的内存都回收了
  console.log(`heapUsed: ${format(process.memoryUsage().heapUsed)}`)
}
setInterval(replaceThing, 100)

执行node -expose-gc test7.js,输出:

heapUsed: 4.31 MB
heapUsed: 5.27 MB
heapUsed: 6.23 MB
heapUsed: 7.18 MB
heapUsed: 8.13 MB

从运行结果中看到内存不断上升。

该代码是想通过replaceThing方法每次重新生成一个新对象,但之前生成的对象没有释放导致内存泄漏。从代码上分析,leak变量虽然引用了theThing,且在unused方法中引用了,但unused方法未被引用,因此不因发生内存泄漏。这里涉及到了另一个知识,如下:

闭包对象是当前作用域中的所有内部函数作用域共享的,并且这个当前作用域的闭包对象中除了包含一条指向上一层作用域闭包对象的引用外,其余的存储的变量引用一定是当前作用域中的所有内部函数作用域中使用到的变量。

结合以上示例分析,即在replaceThing的作用域中存储的变量是它内部函数unusedsomeMethod所有使用到的变量,所以leak变量虽然未被someMethod方法引用,但someMethod引用的闭包的变量包含leak。所以整体的引用关系可以梳理如下:

  1. theThing引用了longStr(无法释放)、someMethod
  2. someMethod引用了leak
  3. leak引用了上一次执行的theThinglet leak = theThing
    ......1、2、3循环引用

所有创建的theThing对象都无法释放,导致内存泄漏。要解除这里的循环引用,需要只要打破这种循环引用的关系,以下介绍两种方式,应根据实际代码逻辑选择。

// 解决方法一:删除leak变量,直接引用最外层的theThing
let theThing = null
let replaceThing = function () {
  let unused = function () { // eslint-disable-line
    if (theThing) { console.log('hi') }
  }
  // 不断修改引用
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('a')
    }
  }

  global.gc() // 手动触发GC
  console.log(`heapUsed: ${format(process.memoryUsage().heapUsed)}`)
}
setInterval(replaceThing, 100)
// 解决方法二:将unused方法引用的变量改为参数传递
let theThing = null
let replaceThing = function () {
  let leak = theThing
  let unused = function (thing) { // eslint-disable-line
    if (thing) { console.log('hi') }
  }
  // 不断修改引用
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('a')
    }
  }

  global.gc() // 手动触发GC
  console.log(`heapUsed: ${format(process.memoryUsage().heapUsed)}`)
}
setInterval(replaceThing, 100)

缓存

javascript中,使用对象来缓存一些计算结果是很容易的,可以避免重复计算一些相同结果。lodash库中memoize方法实现对函数的计算结果缓存,默认以第一个参数作为缓存的key。源码如下:

function memoize(func, resolver) {
  if (typeof func != 'function' || (resolver != null && typeof resolver != 'function')) {
    throw new TypeError('Expected a function')
  }
  const memoized = function(...args) {
    // 以第一个参数作为key,可传递resolver方法自定义key
    const key = resolver ? resolver.apply(this, args) : args[0]
    const cache = memoized.cache

    // 缓存存在,直接返回
    if (cache.has(key)) {
      return cache.get(key)
    }
    // 调用执行方法,并缓存到cache中
    const result = func.apply(this, args)
    memoized.cache = cache.set(key, result) || cache
    return result
  }
  memoized.cache = new (memoize.Cache || MapCache)
  return memoized
}

在浏览器端,由于方法调用次数有限,缓存的key不多,且浏览器页面刷新频率较高,缓存占用的内存不会很高。然后在node服务器中,参数种类多、服务器运行时间长会导致缓存的key较多,且占用的内存一直不能得到释放,因此需要谨慎地使用此类缓存。可以控制key可缓存的最大数,也可以在合适的时机清理掉一些过时的缓存。

当然,考虑分布式服务部署、多进程和缓存的高效管理,缓存最好借用外部工具来保证服务不保存任何状态,如redis

监听器

先看下面连接socket的示例

const net = require('net')
let client = new net.Socket()

function format (bytes) {
  return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}

function callbackListener () {
  console.log('connected!')
};

function connect () {
  client.connect(8000, '127.0.0.1', callbackListener)
}

connect()

client.on('error', (error) => { // eslint-disable-line
  console.log(`heapUsed: ${format(process.memoryUsage().heapUsed)}`)
})

client.on('close', function () {
  client.destroy()
  setTimeout(connect, 1)
})

输出:

heapUsed: 3.83 MB
heapUsed: 3.89 MB
heapUsed: 3.91 MB
heapUsed: 3.91 MB
heapUsed: 3.92 MB
heapUsed: 3.92 MB
heapUsed: 3.93 MB
heapUsed: 3.94 MB
heapUsed: 3.94 MB
heapUsed: 3.95 MB
(node:12708) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 connect listeners added. Use emitter.setMaxListeners() to increase limit

堆内存的使用量一直在上升,在第11次的时候提示监听器的数量操作了最大值10。

在NodeJs中,事件监听使用EventEmitter类,EventEmitter类包含一个listeners数组,存放所有的监听回调的方法,listeners组件默认最大值为10。上面的示例里,每次调用connect方法都会添加一个监听器,因此监听器一直增长,导致内存泄漏。

解决方式如下:

client.on('close', function () {
  // 移除之前的监听器
  client.removeListener('connect', callbackListener);
  client.destroy()
  setTimeout(connect, 1)
})

通常EventEmitter的实例对象不会释放,所以重复添加的监听器也不能得到释放。以这个示例为例,其他的监听器同样需要考虑是否有重复添加监听器的情况。

本文参考资源如下

上一篇 下一篇

猜你喜欢

热点阅读