内存泄漏常见情况及处理方法
内存泄漏可以被视为你家中的水泄漏;虽然一开始小滴水可能看起来不是什么大问题,但随着时间的推移,它们可能会造成重大损失。同样,在JavaScript中,当不再需要的对象没有从内存中释放时,就会发生内存泄漏。随着时间的推移,这种累积的内存使用可以减慢甚至崩溃应用程序。
定义:当不再用到的对象内存,没有及时被回收或者无法被回收就会导致内存泄漏。
垃圾回收器
在编程领域,尤其是在处理 JavaScript 等解释型语言时,内存管理至关重要。幸运的是JavaScript 内置了一个名为 "垃圾回收器"(GC:Garbage Collection)的机制来帮助实现这一目标。想象一下,一个勤劳的清洁工会定期清扫你的房子,捡起任何不用的物品并丢弃,以保持整洁。
垃圾回收器会定期检查不再需要或不再可访问的对象,并释放它们占用的内存。在理想情况下,它可以无缝运行,确保未使用的内存无需任何人工干预即可回收。然而,就像我们的清洁工有时可能会忽略隐藏角落里的闲置物品一样,垃圾回收器也可能会遗漏因引用而无意中保持存活的对象,从而导致内存泄漏。
垃圾回收机制方法
标记清除法
过程:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把能够访问到的变量置1
- 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收
优点:
实现比较简单,只存在打标记和不打标记两种情况,这种情况使得可以采用二进制01来进行标记
缺点:
内存碎片化: 这样内存里面空闲的位置不是连续的,这样就可以使用操作系统学到的方法来分配内存了,比如先什么最先适配、最优适配、最坏适配…
引用计数法
过程:
- 当声明一个变量并将一个引用类型赋值给该变量时该值引用次数加1
- 当这个变量指向其他一个时该值的引用次数便减1
- 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存
缺点:
- 循环引用会导致内存泄漏
- 计数器需要占很大位置。
导致应用程序内存泄漏的因素
1.全局变量
在 JavaScript 中,最高级别的作用域是全局作用域。在此作用域中声明的变量可从代码中的任何地方访问,这可能很方便,但也有风险。对这些变量的不当管理可能会导致意外的内存保留。
原因是什么?当一个变量在未使用 let 、const 或 var 声明的情况下被错误赋值时,它就会成为一个全局变量。此类变量驻留在全局作用域中,除非显式删除,否则会在应用程序的整个生命周期中持续存在。这个是由于历史遗留原因。可使用use strict开启JS严格模式来避免。
例如:假设你正在创建一个计算矩形面积的函数:
function calculateArea(width, height) {
area = width * height; //无意中创建全局变量“area”
return area;
}
calculateArea(10, 5);
这里 area 变量无意中被全局化,因为它没有与 let 、const 或 var 一起声明。这意味着函数执行后,area 仍然可以访问并占用内存:
console.log(area); // Outputs: 50
避免:最佳做法是始终使用 let 、const 或 var 声明变量,以确保它们具有正确的作用域,不会无意中成为全局变量。此外,如果有意使用全局变量,请确保它们对于全局访问是必不可少的,并有意识地管理它们的生命周期。
修改:正确对 area 变量进行作用域设置:
function calculateArea(width, height) {
let area = width * height;
return area;
}
calculateArea(10, 5);
项目中的bug实例:showBimface的filter
2.定时器和回调函数
在JavaScript中提供了内置函数,允许在特定的时间段后异步执行代码(使用 setTimeout)或以规律的间隔执行(使用 setInterval)。尽管它们非常强大,但如果没有正确管理,它们可能无意中导致内存泄漏。
原因:如果一个间隔或超时引用了一个对象,只要定时器还在运行,它就可以保持该对象在内存中,即使应用程序的其他部分不再需要该对象。
避免:关键是在不需要定时器时始终停止它们。如果完成了一个间隔或超时,使用clearInterval()或clearTimeout()分别清除它们。这会停止间隔并允许其回调中引用的任何对象有资格进行垃圾回收,前提是没有其他挥之不去的引用。
3.闭包
在JavaScript中,函数具有“记忆”它们创建时的环境的特殊能力。这种能力使内部函数可以访问外部(封闭)函数的变量,即使外部函数已经完成其执行。这种现象被称为“闭包”。
原因:闭包的能力伴随着责任。闭包保持对其外部环境变量的引用,这意味着如果闭包仍然活着(例如作为回调或在事件监听器中),它引用的变量将不会被垃圾回收,即使外部函数早已完成其执行。
例如:有一个创建倒计时的函数:
function createCountdown(start) {
let count = start;
return function() {
return count--;
};
}
let countdownFrom10 = createCountdown(10);
这里,countdownFrom10 是一个闭包。每次调用它时,它会将 count 变量减少一个。由于内部函数保持对 count 的引用,count 变量不会被垃圾回收,即使在程序的其他地方没有对createCountdown函数的其他引用。
现在想象一下,如果count是一个更大、更消耗内存的对象,闭包无意中将其保留在内存中。
避免:虽然闭包是一个强大的特性并且经常是必要的,但重要的是要注意它们引用的内容。确保:
① 只捕获需要的内容:除非必要,不要在闭包中捕获大对象或数据结构。
② 完成后断开引用:如果一个闭包被用作事件监听器或回调后不再需要它,就删除监听器或使回调为null,以断开闭包的引用。
修改:有意断开引用:
function createCountdown(start) {
let count = start;
return function() {
return count--;
};
}
let countdownFrom10 = createCountdown(10);
countdownFrom10 = null;
4. 事件监听器
在JavaScript中的事件监听器通过允许 “监听”特定的事件(如点击或按键)并在这些事件发生时采取行动,实现交互性。但与其他JavaScript功能一样,如果不仔细管理,它们可能会成为内存泄漏的来源。
原因:当你将事件监听器附加到DOM元素时,它在该函数(通常是闭包)和该元素之间创建了一个绑定。如果删除了元素或不再需要该事件监听器,但没有明确删除监听器,关联的函数仍留在内存中,可能保留其引用的其他变量和元素。
例如:假设将一个点击监听器附加到一个按钮:
const button = document.getElementById('myButton');
button.addEventListener('click', function() {
console.log('Button was clicked!');
});
现在在应用程序中从DOM中删除按钮:
button.remove();
即使按钮从DOM中删除,事件监听器的函数仍然保留对按钮的引用。这意味着按钮不会被垃圾回收,导致内存泄漏。
避免:关键是积极管理你的事件监听器:
① 明确删除:在删除元素或不再需要它们时,使用removeEventListener()始终删除事件监听器。
② 使用一次:如果你知道一个事件只需要一次,你可以在添加监听器时使用{ once: true }选项。 修改上面的示例以进行正确管理:
const button = document.getElementById('myButton');
function handleClick() {
console.log('Button was clicked!');
}
button.addEventListener('click', handleClick);
// 稍后在代码中,当我们完成按钮时:
button.removeEventListener('click', handleClick);
button.remove();
通过在删除按钮之前明确地删除事件监听器,我们确保监听器的函数和按钮本身都可以被垃圾回收。
5. 分离的DOM元素
文档对象模型(DOM)是网页上所有元素的分层表示。当你修改DOM,例如通过删除元素,但仍然在JavaScript中持有对该元素的引用,你就已经创建了所谓的** “分离的DOM元素” **。这些元素不再可见,但由于它们仍然被代码引用,所以它们不能被垃圾回收。
原因:当从DOM中删除元素但仍有指向它们的JavaScript引用时,会创建分离的DOM元素。这些引用阻止垃圾回收器回收这些元素占用的内存。
例如:假设有一个物品列表,并且决定删除一个:
let listItem = document.getElementById('itemToRemove');
listItem.remove();
现在,即使您已经从DOM中删除了 listItem,你仍然在 listItem 变量中对其有引用。这意味着实际的元素仍然在内存中,从DOM中分离但占用空间。
避免:为了防止分离的DOM元素引起的内存泄漏,使引用为 null,即删除DOM元素后,使对其的任何引用为 null:
listItem.remove();
listItem = null;
限制元素引用:只在绝对需要时存储对DOM元素的引用。
修改示例以防止内存泄漏:
let listItem = document.getElementById('itemToRemove');
listItem.remove();
listItem = null; // 断开对分离的DOM元素的引用
通过在从DOM中删除 listItem 后使 listItem 引用为null,确保垃圾回收器可以回收已删除元素占用的内存。
6. Websockets和外部连接
Websockets 提供了一个全双工通信通道,通过单个、长时间的连接。这使它非常适合实时应用,如聊天应用、在线游戏和实时体育更新。然而,由于 Websockets 的性质是保持开放的,如果不正确处理,它们可能成为内存泄漏的潜在来源。
原因:当 Websockets和其他持久的外部连接管理不当时,它们即使不再需要也可以持有对象或回调的引用。这可以阻止这些引用的对象被垃圾回收,导致内存泄漏。
例如:假设一个应用程序,该应用程序打开一个 websocket 连接以接收实时更新:
let socket = new WebSocket('ws://example.com/updates');
socket.onmessage = function(event) {
console.log(`Received update: ${event.data}`);
};
现在,如果在某个时候,页面导航离开了应用的这一部分或关闭了使用此连接的特定UI组件,但忘记关闭 websocket,它仍然保持打开状态。与其事件监听器关联的任何对象或闭包都不能被垃圾回收。
避免:积极管理websocket连接至关重要。
① 明确关闭:当不再需要时,始终使用 close() 方法关闭 websocket 连接:
socket.close();
② 引用为 null:关闭 websocket 连接后,使任何关联的引用为 null 以帮助垃圾回收器:
socket.onmessage = null;
socket = null;
③ 错误处理:实施错误处理以检测连接何时丢失或意外终止,然后清理任何相关的资源。
继续上面的示例,正确的管理看起来是这样的:
let socket = new WebSocket('ws://example.com/updates');
socket.onmessage = function(event) {
console.log(`Received update: ${event.data}`);
};
// 稍后在代码中,当连接不再需要时:
socket.close();
socket.onmessage = null;
socket = null;
7. 常用工具
预防内存泄漏的最佳方法是尽早检测它们。浏览器开发者工具,尤其是Chrome DevTools中的 “Memory”标签尤其有用,允许监视内存使用情况,拍摄快照并随着时间的推移跟踪更改。
8. 总结
① 定期审核:定期审查代码以确保遵循最佳实践。
② 测试:添加新功能后,测试潜在的内存泄漏。
③ 代码卫生:保持代码整洁、模块化并且记录完善。
④ 第三方库:明智地使用它们。有时它们可能是内存泄漏的原因。