记忆体洩漏(memory leaks)

Node.js 记忆体洩漏(memory leaks)

导致记忆体洩漏的常见原因

不当的建立变数并使其不断膨胀

定义全域变数、在 module 或 closure 中定义变数,但不断塞入内容而没有主动清除(设成 null)。

全域阵列物件保留 Closure 没有用的变数

每次收到请求,都会使 requests 的资料量成长,而 request 又存在于 global scope,属于可从根探询到的程式码,因此它不会被清除

const http = require('http');
// 全域阵列物件
const requests = [];

http.createServer((request, response) => {
    // 将请求储存到 Global
    requests.push(request);
    response.writeHead(200, {'Content-Type': 'text/plain'});
    response.end('Hello Memory Leaks');
}).listen(3000);

setInterval 保留外部变数导致变数无法释放


const object = {
   a: new Array(1000),
   b: new Array(2000)
};
setInterval(() => {
   // setInterval
   console.log(object.a)
}, 1000);

未使用的 unused function 一直使用 originalThing,所以资料不会被回收

// 全域变数
var theThing = null;

var replaceThing = function () {
  // 区域变数取的全域变数
  var originalThing = theThing;
  var unused = function () {
    // 未使用的 function 一直使用 originalThing,所以资料不会被乐素回收
    if (originalThing) {
      console.log("hi");
    }
  };

  // 全域变数建立一个很大的阵列资料
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};

setInterval(replaceThing, 1000);

未使用的 a 一直使用外部的参数,所以传入的资料不会被回收

function parentFunction(paramA) {
  var a = paramA;
  function childFunction() {
    return a + 2;
  }
  return childFunction();
}

每次呼叫时都将 Hello 存到 potentiallyHugeArray,而这个变数是外部 scope 的变数,所以无法在呼叫完函式时清除

function outer() {
    const potentiallyHugeArray = [];
    return function inner() {
        // 每次呼叫时都将 Hello 存到 potentiallyHugeArray,而这个变数是外部 scope 的变数,所以无法在呼叫完函式时清除
        potentiallyHugeArray.push('Hello');
        console.log('Hello');
    };
};
// contains definition of the function inner
const sayHello = outer();
function repeat(fn, num) {
    for (let i = 0; i < num; i++){
        fn();
    }
}
// each sayHello call pushes another 'Hello' to the potentiallyHugeArray
repeat(sayHello, 10);
// now imagine repeat(sayHello, 100000)

忘记解除 event listener 的註册

Event Listener 中的 event handler 函式会被移除的时间包含:

  • 使用 removeEventListener
  • DOM 元素被移除后

DOM Element 保存成 JavaScript 变数,移除 DOM 但还是有 JavaScript 变数存着

将 DOM Element 保存成 JavaScript 变数后,即使使用 removeChild 移除了该 DOM Element 只要这个 JavaScript 变数还存在,就可以参照到该 DOM 元素,使得该 DOM Element 没办法被 GC。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // 直接移除元素
    document.body.removeChild(document.getElementById('button'));
    // 赞全域变数还有参照这个按钮的变数,所以这个元素还是会保留在记忆体不会被清除

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.

}

Timer 使用时没有设定 id 来做后续的 clear Timer 或是 callback

  • setTimeout
  • setInterval
function setCallback() {    
const data = {   
   counter: 0,    
   hugeString: new Array(100000).join('x')   
 };
return function cb() {
  // data 物件现在是 callback 的一部分了,不可以被随意清除
  // data object is now part of the callback's scope
  data.counter++;
  console.log(data.counter);
}
}

// 设定完后要怎麽去停止它?
setInterval(setCallback(), 1000);

上面程式码可以改写成以下,避免 Memory Leak

function setCallback() {
    // 拆解变数将 counter 独立
    let counter = 0;
    // 在 setCallback 回传 return 资料后会被移除
    const hugeString = new Array(100000).join('x');

    return function cb() {
        // 只有 counter 是 callback 的 scope 变数
        counter++;
        console.log(counter);
    }
}
// 储存 Interval Timer ID
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// 清除 Timer
clearInterval(timerId);

循环式参照

function myFunction(element) {
  this.elementReference = element;
  // 这裡会形成循环式参照
  // DOM-->JS-->DOM
  element.expandoProperty = this;
}
function Leak() {
  // 造成记忆体的洩漏
  new myFunction(document.getElementById("myDiv"));
}
function referenceCycle() {
    var OriginalObject = {};
    var OtherObject = {};
    // OriginalObject 参考 OtherObject
    OriginalObject.a = OtherObject;
    // OtherObject 参考 OriginalObject
    OtherObject.a = OriginalObject;
    return 'reference cycle';
}

referenceCycle();

hoisting 的特性产生全域变数

JavaScript 有些写法也会产生不预期的全域变数

// 假设以下 function 都是 global 的
// author 会被 hoist 成一个全域变数
function ironman() {
    author = "Kyle Mo";
}
// 这种写法在 non strict mode 下也会变成全域变数
function hello_it_home() {
    this.author = "Kyle Mo";
}
// 这种情况下就算是 strict mode author 也会变成全域变数
const hello_ironman = () => {
    this.author = "Kyle Mo";
}

Capturing objects from an iframe

忘记关闭 worker

Garbage collection 垃圾回收机制流程

浏览器侦测到某个物件不在被使用时,会执行垃圾回收(garbage collection)的机制,以此释放记忆体空间。

  • 定期从根物件 (root,在浏览器中是 window,node 则是 global) 开始往下探询每一个子节点
  • 并清除没有被探询到、或是没有被探询物件参考的物件,也就是所谓「无法到达的物件 (unreachable objects)」

Memory Leak

如果 root -> F 的参考消失,导致 F 变成「无法到达的物件」,那麽 F 与其子节点们就会被自动回收。

Stack & Heap

  • 比较简单类型的 Primitive Type 会被放在 stack 裡
  • 比较複杂类型的 Reference Type 则会把资料存在 heap 中,再把资料在 heap 的记忆体位址记录到 stack 裡

Memory Leak

可以看到 Object 类型的数据实际上是存在 Heap 裡,Stack 中存的只是物件在 Heap 中的记忆体位置而已

变数 four = three 这段 code 实际上是把 Three 指向的物件在 Heap 中的记忆体位置 指派给 Four 变数,所以它们实际上指向的是同一个物件

Stack & Heap Garbage collection 回收机制

如果是物件的话,Stack 中存的是 Heap 空间的 address

所以就算 Stack 被回收,存在 Heap 空间的数据依然存在,这时就需要靠 GC 来判断 Heap 空间中哪些资料是用不到且需要被回收的

正确避免 Memory Leak

移除元素前先移除 Event Listener

避免元素还有其他变数在使用,所以移除前,相关的绑定使用的变数要先移除

  • 移除 Event Listener
  • 移除元素
var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// 移除 Event Listener
element.removeEventListener('click', onClick);
// 移除元素
element.parentNode.removeChild(element);

解决 Memory Leaks 问题

使用将变数设为 null

手动告诉机器这个物件没有使用了

var myVar = "Hello";
// Hello
console.log(myVar);
myVar = null;
// null
console.log(myVar);

利用开发者工具中的快照

简单用法就是使用一阵子之后重新抓一次快照,观察记忆体有没有上升太多

事件加入 console.log 观察

事件中的 listener 可以放个 console.log('避免重複监听') 来暴力观察

使用 Chrome 的 DevTool 监控

Chrome 提供的 DevTool 除了能监测运行在浏览器中的程式外,也能监测运行在本地端的 Node.js 程式,能使用 Devtool 监测 Heap 的使用状况。

1. 在要执行的程式加入 --inspect 标记启动监控

node --inspect test.js
Debugger listening on ws://127.0.0.1:9229/6b6d9bbb-1db2-4f46-a399-b9e8f4a4bfa3

2. 开启 Chrome Inspect 监控

打开 Chrome 网址输入 chrome://inspect,在画面下点选 inspect 即可开启监控

Memory Leak

在开启的视窗就可以看到记忆体使用状况

Memory Leak

3. 记录当前 Memory 使用状况

切换到 Memory 页籤,在下方看到目前运行中的 VM instance,按下 Take snapshot,就会帮我们分析此刻的 heap 使用状况,并将结果储存在左侧边栏。

Memory Leak

4. 查询 Memory 使用状况

点选左侧栏 Snapshot 就可以看到执行的 node 程式记忆体使用状况,在上方页籤可以看到有这几个项目

类型 说明
Constructor 此物件的建构子 (DevTool 帮我们进行的分类)
Distance 从根 (root) 开始探访的深度。
Shallow Size 物件本身佔用的记忆体量(bytes),通常 shallow size 很大的都是 String 或是装着 Primitive Type Data 的阵列,如果是物件裡存着 reference 则不会被算到 shallow size 裡面。
Retained Size 物件本身佔用的记忆体空间,加上依赖此物件的所有资料所佔用的记忆体量(bytes)。可以表示,你删除这个物件后,他总共会释放的记忆体量,因此在查找 memory leak 问题时,我们会以这个栏位为判断点。

Memory Leak

先将物件以 Retained Size 排序,没意外的话佔用最多的应该是 (compiled code),因为我们引用了一些套件,这些物件也会被存在 Heap 中。

Memory Leak

使用 K6 进行压力测试

压力测试程式

// request.js
import http from "k6/http";
import { sleep } from "k6";

export default function() {
  http.get("http://localhost:3000");
  sleep(1);
}

1. 执行压力测试

从终端机启动压力测试,使用下面的参数

参数 说明
duration 执行测试的时间
vus 同时测试的虚拟使用者数量 (virtual users)
k6 run --duration 2m --vus 100 request.js

Memory Leak

等它跑完

Memory Leak

2. 观察 Chrome DevTool 变化

重新 Take shapshot,如果你发现 heap 不断增长且没有回到正常数字的话,那八成是抓到凶手了!

Memory Leak

观察两个 snapshot 之间的变化,若看不太出来的话,可能单次 leak 的记忆体量很少,可以试着发送更多请求数看看,看能不看看出个什麽明显的变化

3. 找 Memory Leaks 凶手

压测前

Memory Leak

压测后

找到发现 (closure) / () / context / requests 使用了太多的记忆体,可以看到是刚刚程式中 requests 这个变数,那就可试着从这裡把问题修正了

Memory Leak

Memory Leak

其他可能状况

引用的套件有 Memory Leaks 问题

也有可能找了老半天,发现是使用的套件有 Memory Leak 问题

参考资料