内存泄漏常见情况及处理方法

发布时间 2023-10-25 14:19:01作者: LeoX的爬坑笔记

内存泄漏可以被视为你家中的水泄漏;虽然一开始小滴水可能看起来不是什么大问题,但随着时间的推移,它们可能会造成重大损失。同样,在JavaScript中,当不再需要的对象没有从内存中释放时,就会发生内存泄漏。随着时间的推移,这种累积的内存使用可以减慢甚至崩溃应用程序。

定义:当不再用到的对象内存,没有及时被回收或者无法被回收就会导致内存泄漏。

垃圾回收器

在编程领域,尤其是在处理 JavaScript 等解释型语言时,内存管理至关重要。幸运的是JavaScript 内置了一个名为 "垃圾回收器"(GC:Garbage Collection)的机制来帮助实现这一目标。想象一下,一个勤劳的清洁工会定期清扫你的房子,捡起任何不用的物品并丢弃,以保持整洁。

垃圾回收器会定期检查不再需要或不再可访问的对象,并释放它们占用的内存。在理想情况下,它可以无缝运行,确保未使用的内存无需任何人工干预即可回收。然而,就像我们的清洁工有时可能会忽略隐藏角落里的闲置物品一样,垃圾回收器也可能会遗漏因引用而无意中保持存活的对象,从而导致内存泄漏。

垃圾回收机制方法

标记清除法

过程:

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  2. 然后从各个根对象开始遍历,把能够访问到的变量置1
  3. 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  4. 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

优点:

实现比较简单,只存在打标记和不打标记两种情况,这种情况使得可以采用二进制01来进行标记

缺点:

内存碎片化: 这样内存里面空闲的位置不是连续的,这样就可以使用操作系统学到的方法来分配内存了,比如先什么最先适配、最优适配、最坏适配…

引用计数法

过程:

  1. 当声明一个变量并将一个引用类型赋值给该变量时该值引用次数加1
  2. 当这个变量指向其他一个时该值的引用次数便减1
  3. 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

缺点:

  1. 循环引用会导致内存泄漏
  2. 计数器需要占很大位置。

导致应用程序内存泄漏的因素

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. 总结

①      定期审核:定期审查代码以确保遵循最佳实践。

②      测试:添加新功能后,测试潜在的内存泄漏。

③      代码卫生:保持代码整洁、模块化并且记录完善。

④      第三方库:明智地使用它们。有时它们可能是内存泄漏的原因。