记忆体洩漏(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)」
如果 root -> F
的参考消失,导致 F 变成「无法到达的物件」
,那麽 F 与其子节点们就会被自动回收。
Stack & Heap
- 比较简单类型的 Primitive Type 会被放在 stack 裡
- 比较複杂类型的 Reference Type 则会把
资料存在 heap 中
,再把资料在heap 的记忆体位址
记录到 stack 裡
可以看到 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
即可开启监控
在开启的视窗就可以看到记忆体使用状况
3. 记录当前 Memory 使用状况
切换到 Memory 页籤
,在下方看到目前运行中的 VM instance,按下 Take snapshot
,就会帮我们分析此刻的 heap
使用状况,并将结果储存在左侧边栏。
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 问题时,我们会以这个栏位为判断点。 |
先将物件以 Retained Size
排序,没意外的话佔用最多的应该是 (compiled code)
,因为我们引用了一些套件,这些物件也会被存在 Heap 中。
使用 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
等它跑完
2. 观察 Chrome DevTool 变化
重新 Take shapshot
,如果你发现 heap 不断增长且没有回到正常数字的话,那八成是抓到凶手了!
观察两个 snapshot 之间的变化,若看不太出来的话,可能单次 leak 的记忆体量很少,可以试着发送更多请求数看看,看能不看看出个什麽明显的变化
3. 找 Memory Leaks 凶手
压测前
压测后
找到发现 (closure) / () / context / requests
使用了太多的记忆体,可以看到是刚刚程式中 requests
这个变数,那就可试着从这裡把问题修正了
其他可能状况
引用的套件有 Memory Leaks 问题
也有可能找了老半天,发现是使用的套件有 Memory Leak 问题
参考资料
- [web] 记忆体问题 memory leak | PJCHENder 未整理笔记
- 从你的 Node.js 专案裡找出 Memory leak,及早发现、及早治疗! | 方格子
- Load testing for engineering teams | Grafana k6
- Trash talk: the Orinoco garbage collector · V8
- 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them
- 网站常见 Memory Leak: 循环参照、事件循环监听、存取全域变数 | 前端三分钟 | 一起用三分钟分享技术与知识
- JavaScript 记忆体洩漏(Memory Leak)问题 - G. T. Wang
- 身为 JS 开发者,你应该要知道的记忆体管理机制. 如果你是写 C/C++… | by 莫力全 Kyle Mo | Starbugs Weekly 星巴哥技术专栏 | Medium
- Memory Leaks in JavaScript and how to avoid them. | by Eduard Hayrapetyan | Preezma Software Development Company | Medium