JavaScript内存泄漏

JavaScript内存泄漏

目录

js内存泄漏

浏览器中的内存泄漏常见的场景有

  • 全局变量

  • 定时器

  • 闭包

  • 事件处理程序

全局变量

以下代码会导致声明全局变量:

var a = 123;
if (true){
	var b = 123;
}
let c = 123;
(function fn(){
	d = 123;
})()

全局变量导致内存泄露是因为调用栈机制。调用栈会将全局作用域压入栈底,以便后续的函数执行上下文能够访问到全局作用域中的变量,因此全局作用域中的变量会存在script执行的整个周期中,除非手动删除。如果变量是在局部中,例如函数,那么当函数执行完毕后,该函数的执行上下文就会被销毁,其中的局部变量都会被回收,而全局变量显然无法像这样被回收,因此当开发者声明了大量的全局变量时,就容易导致内存泄露的发生。

为了使描述更可观,我们可以使用断点来查看:

debugger;
var a = 123;
if (true) {
    var b = 123;
};
let c = 123;
(function fn() {
    d = 123;
})()
debugger;

可以看到,在script执行完毕之前,global一直存在,全局变量都可访问。

解决全局变量导致的内存泄露的关键在于减少全局变量,尽量将变量声明在局部,因此使用let和const来替代var声明变量是一个很好的选择。

定时器

定时器也会导致内存泄露,在下面的例子中,定时器的回调引用了外层作用域的变量。

function fn() {
    let name = 1
    setInterval(function callback() {
        console.log(name += 1);
    }, 100);
}
fn();

由于定时器一直在运行,定时器函数callback就行一直存在内存中,callback函数内对name变量的引用就会一直存在,因此name变量就会一直占用内存,无法被垃圾回收机制回收。

解决的方法是及时地清理定时器。

可以做一组对照实验来验证上述结论:

// 实验组
const wm = new WeakMap();
function fn() {
    let name = {}
    wm.set(name, true)
    setInterval(function callback() {
        console.log(name);
    }, 100);
}
fn();

console.log(wm);

// 参照组
const wm = new WeakMap();
function fn() {
    let name = {}
    wm.set(name, true)
}
fn();

console.log(wm);

这里使用WeakMap来保存name变量,因为WeakMap是弱引用,不会影响垃圾回收,由于WeakMap的key只能是对象,所以我们将name变量改为了一个对象,然后分别在有定时器和没有定时器的情况下来观察name变量是否会被垃圾回收机制回收

可以看到对照组中name对象已经被垃圾回收,而实验组中name对象并未被回收,因此结论成立。

闭包

闭包是导致内存泄露的典型例子,首先我们来弄清楚闭包的定义,在《JavaScript高级程序设计第三版》中给过一个闭包的定义:有权访问另一个函数作用域中的变量的函数,如下面的例子:

function fn() {
    let a = 1;
    return function f() {
        a++;
        console.log(a);
    }
}

let getA = fn();
getA()

在闭包中,由于f函数引用(访问)了外层函数fn的a变量,并且getA变量一直保存了f函数的引用,因此变量a会一直保存在内存中,除非手动删除f函数的引用。

getA = null;
getA = 123;
// ...

按照正常的执行栈机制,fn函数在执行完后作用域就会被销毁,但是因为函数f保留了对fn函数作用域中变量的引用,为了能够让这些局部变量能够被访问到,js会将这些变量保存到堆内存中的closure对象中去。

你可以在浏览器开发者调试工具中查看closure:

function fn() {
    let a = 1;
    let b = 2;
    return function f() {
        debugger;
        a++;
        console.log(a);
    }
}

let getA = fn();
getA()

closure对象保存在堆内存中,我们可以借助内存快照来看到它们。

事件处理程序

当通过addEventListener 为节点添加事件处理程序时,该事件处理函数就已经和目标节点的事件进行绑定了,除非手动移除事件监听器或者删除该节点,否则该事件处理程序不会被垃圾回收机制回收。

另外事件处理程序也会占用一定内存,这是因为事件处理程序本身也是函数。

document.querySelector("#a").addEventListener('click', function f(){
	// ...
})

在这一过程中,声明了函数f,这通常不会引起什么问题,但是如果在某些场景下导致创建大量的事件处理程序,就可能会引起内存问题,例如长列表。

在下面这个代码示例中,会创建1000个按钮,当点击add按钮时会为每个按钮添加一个事件处理程序,当点击remove时会删除所有按钮节点,你需要在浏览器开发者工具-内存的内存快照功能中观察前后EventListener 类实例的数量和内存变化情况。

<!DOCTYPE html>
<html lang="zn">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div>
        <button id="add">add</button>
        <button id="remove">remove</button>
    </div>
</body>
<script>
    for (let i = 0; i < 1000; i++) {
        const button = document.createElement('button')
        button.innerText = "click"
        button.setAttribute('data-index', i + 1)
        button.classList.add("button")
        document.body.appendChild(button)
    }

    document.body.addEventListener('click', function (e) {
        if (e.target.tagName === 'BUTTON') {
            console.log(e.target.getAttribute('data-index'));
            console.log(e.target.dataset.index);
        }
    })

    document.querySelector("#add").addEventListener("click", function () {
        document.querySelectorAll('.button').forEach((buttonElm) => {
            buttonElm.addEventListener('click', function addEvent() {
                alert('123')
            })
        })
    })

    document.querySelector("#remove").addEventListener("click", function () {
        document.querySelectorAll('.button').forEach((buttonElm) => {
            document.body.removeChild(buttonElm)
        })
    })
</script>

</html>

https://codesandbox.io/s/musing-heyrovsky-lvfng8?file=/index.html

你可以直接进入此页面操作:

Document

结果如下:

上面的内存快照分别进行以下操作:

  1. 未处理,默认情况

  2. 点击两次add按钮,即为每个元素节点添加了两次事件处理程序。

  3. 点击remove按钮,即删除所有元素节点

可以看到,当事件处理程序个数增加时,堆内存也随之升高,当删除元素时,对应的事件处理程序也随之被释放。

由此可以知道,在某些会声明大量事件处理程序的场景下(例如长列表),应当及时地通过删除元素节点或者移除事件处理程序来避免内存问题。

除此之外还有一种方法可以解决这种内存问题,就是使用同一个函数,即:

function callback(){
	// ...
}
element.addEventListener('click', callback)

在这种情况下,由于只声明了一个函数,因此不会造成性能问题。另一种方式是使用事件代理,原理也和这种方式类似。

小结:

当通过addEventListener 为节点添加事件处理程序时,该事件处理函数就已经和目标节点的事件进行绑定了,除非手动移除事件监听器或者删除该节点,否则该事件处理程序不会被垃圾回收机制回收。由于事件处理程序本身是函数,因此会占用一定的内存,在需要声明大量事件处理程序的场景下(例如长列表)就会导致内存问题。

解决的方法有:

  • 及时地删除绑定的元素节点或者移除事件处理程序

  • 相同逻辑的情况下只使用一个函数,避免声明多个函数

  • 使用事件代理

服务端的内存泄漏(Nodejs)

Nodejs本身就是JavaScript运行时,因此前面所说的闭包、全局变量、定时器等也会导致Nodejs内存泄漏,但是与浏览器端不同的是,浏览器中js作用对象是单个用户,而服务端的js作用对象是多个用户,因此Nodejs中需要额外注意以下几种形式的内存泄漏:

  • 将内存作为缓存

  • 模块作用域未释放

  • 消费不及时(消费速度小于生产速度)

将内存作为缓存

nodejs可以将数据进行存储,以便后续使用,而缓存的方式主要有:

  • 存储到数据库

  • 存储到文件

  • 存储到内存

相较于缓存到数据库和缓存到文件这两种方式而言,缓存到内存这种方式的访问速度要比前两种快得多,但是这种方式也并非十全十美,缓存的数据越多,占用内存就越多,如果缓存的对象一直不释放,那么就会造成性能问题。

以下面的代码为例,该例子中接收http请求name参数,并将值缓存到store数组中。

const http = require("http");
const url = require("url");

const store = [];
http.createServer((req, res) => {
	// 过滤掉浏览器自动发送的网站logo的请求
	if (req.url === "/favicon.ico") {
		res.end("");
		return;
	}
	console.log(store);
	// 第二个参数默认是false,设置为ture后,将字符串格式转换为对象格式。
	const query = url.parse(req.url, true).query;
	store.push(query.name);
	res.end("hello nodejs");
}).listen(3000, () => {
	console.log("server start");
});

这种行为非常危险,在浏览器端由于是作用于单个用户,因此可能只会存储非常少量的数据到store,但是服务端是作用于多个用户,并且是长时间运行,这就很可能因为缓存对象的不断累积而导致内存问题。

解决的方法也很简单,限制缓存大小和制定对应的过期策略

一个限制缓存大小的简单方法是一旦数组长度超过规定的大小,就使用先进先出规则移出数组元素。

function handleStoreLength(store, max){
	while(store.length > max){
		store.pop();
	}
}

更复杂的场景应当使用Redis等存储系统。

模块作用域未释放

nodejs的模块化本质上是通过对象实现的,导出变量实际上是将变量挂载在module.exports对象上,导入的时候也是导入的这个对象,而导出的函数可以访问到该模块作用域中的私有变量,因此模块的作用域会被缓存在内存中。

// a.js
let a = [];
function addA() {
	a.push(a.length);
}

exports.a = a;
exports.addA = addA;

// b.js
const obj = require("./a.js");

console.log(obj.a);
obj.addA();
console.log(obj.a);
// []
// [0]

由于模块的缓存机制,模块是常驻老生代内存的,因此在模块设计时要格外注意上面这种代码,因为每调用一次addA函数都会增加内存。

消费不及时

在生产消费模式中,可能会将数据暂时存放到内存中,但是如果此时消费速度小于生产速度时,就很容易由于消费不及时导致内存不断累积。

一种实际的场景是将日志写入到数据库中,日志的产生速度非常快,数据量也非常大,数据库的写入效率小于日志生产效率,这就会形成数据库写入操作的堆积,内存占用不会回落,从而出现内存泄漏。

解决的方式是监控队列的长度,一旦堆积,应当通过监控系统产生报警。

最后更新于