【JavaScript】【内存管理】内存管理、堆栈溢出和内存泄露

前言

这篇文章主要是围绕 JavaScript 的内存管理和垃圾回收机制开展,在阅读这篇文章之前,我们可以先思考一下,下面的几个问题:

  • 什么是内存?什么是内存管理?
  • 垃圾是怎么产生的?
  • 为什么要进行垃圾回收?
  • JavaScript是如何进行垃圾回收的?
  • Chrome浏览器是如何进行垃圾回收的?

一、内存管理

JavaScript 的内存管理主要有两个方面:

  • 垃圾回收
  • 内存泄露

1.1 内存管理的简介

像C语言这样的底层语言一般都有底层的内存管理接口,比如malloc()free()。相反,JavaScript 是在创建变量(对象、字符串等)时自动进行了分配内存,并且在不使用它们时"自动"释放内存。这种方式叫做自动管理内存(Automatic Memory Management)。

释放的过程称为垃圾回收(Garbage Collection)。

"自动"释放内存的自动是混乱的根源,并让 开发者 错误的感觉他们可以不关心内存管理

1.2 内存生命周期

内存生命周期大致如下:

  1. 分配内存
    在我们创建变量或函数的时候,JavaScript引擎会为我们分配一些内存空间来存放该变量的内容
  2. 使用内存(读/写)
    读取和写入内存无非就是变量读取和写入
  3. 不再使用时释放内存
    使用完毕后,释放已分配的内存

所有语言的内存生命周期第二步是明确的。第一和第三步在底层语言中是明确的,单是在像JavaScript这些高级语言中,大部分都是隐含的

1.3 JavaScript的内存分配

JavaScript 是一种脚本语言,它的内存分配是通过 JavaScript 引擎来管理的。在JavaScript中内存分配是通过垃圾回收机制来实现的。

内存分配是指在创建变量、函数或者其它任何内容的时候,JS引擎会自动为我们分配内存,并且在不需要的时候释放内存。

JavaScript 使开发人员无需处理内存分配的工作,JavaScript自己完成这个工作,同时声明值。

另外,需要注意的是:

  • 原始值都是不可变的,所以修改的时候实际上是创建了一个新的值
  • 内存地址是尽量分配在一起的,但是不是必须、肯定在一块的
js 复制代码
// 为对象分配堆内存  
const person = {  
  name: 'John',  
  age: 24,  
};  
  
// 数组也是对象,所以分配的也是堆内存  
const hobbies = ['hiking', 'reading'];  
  
  
let name = 'John'; // 为字符串分配栈内存  
const age = 24; // 为数字分配栈内存  
  
name = 'John Doe'; // 为字符串分配新的栈内存  
const firstName = name.slice(0,4); // 为字符串分配新的栈内存

内存分配的主要方法有两种:堆内存分配和栈内存分配

1.3.1 堆内存分配(Heap Allocation)

堆内存是 JavaScript用来存储对象函数 的区域。JavaScript 引擎不会为这些对象分配一个固定大小的内存,将根据具体的需要来分配对应的内存空间。这种内存分配的方式又叫动态内存分配

堆内存分配的

  • 优点:可以 Dynamically 分配内存,不需要事先知道内存的大小。
  • 缺点:是内存的分配和释放需要额外的 overhead(额外消耗),可能会导致性能问题。

1.3.2 栈内存分配(Stack Allocation)

栈内存是 JavaScript 用来存放静态数据的一种数据结构。静态数据指的是 JavaScript 引擎在编译时期就能确定其大小的数据。在 JavaScript 中,它包括原始的值(strings, numbers, booleans, undefined, symbol, 和 null)和指向对象和函数引用。

由于引擎知道了数据的大小不会再改变了,那么在分配内存的时候,就会给它分配一个 固定大小 的空间。

在程序执行前分配内存的过程,就叫做 静态内存分配

因为引擎为这些值分配的是固定大小的内存,所以这些值的大小肯定是有个上限的,而这个上限取决于具体的浏览器。

栈内存分配的:

  • 优点:速度快,不需要额外的 Overhead(额外消耗)。
  • 缺点:需要事先知道函数的大小。

与C/C++不同,JavaScript中并没有严格意义上区分栈内存与堆内存。因此我们可以简单粗暴的理解为JavaScript的所有数据都保存在堆内存中。但是在某些场景,我们仍然需要基于堆栈数据结构的思维来实现一些功能,比如JavaScript的执行上下文。执行上下文的执行顺序借用了栈数据结构的存取方式。因此理解栈数据结构的原理与特点十分重要。

1.4 JavaScript的使用内存

在JavaScript中使用分配的内存基本上意味着在其中读取和写入。

这可以通过读取或写入变量或对象属性的值,甚至将参数传递给函数来完成。

1.5 JavaScript的释放内存

JavaScript 语言是高级语言,已经在解释器中嵌入了"垃圾回收器",它的主要工作是跟踪内存的分配和使用,,以便当分配的内存不再使用时,自动释放它。

不幸的是,这个过程只是进行粗略估计,因为知道是否需要一些一块内存的一般问题是不可判定 (不能通过算法来解决)。

大多数垃圾收集器通过收集不再被访问的内存来工作,例如,指向它的所有变量都超出了作用域。但是,这是可以收集的内存空间集合的一个不足估计值,因为在内存位置的任何一点上,仍然可能有一个变量在作用域中指向它,但是它将永远不会被再次访问。

二、垃圾回收机制

如上文所述自动寻找是否一些内存"不再需要"的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。

另外,我们还需要知道:JavaScript的V8引擎限制了内存的使用,因此不同操作系统的内存大小会不一样。

还需要注意的是,垃圾回收,不是立即被回收,而是等着被回收

V8引擎最初设计是作为浏览器的引擎,并未考虑占据过多的空间,随着web2技术工程化的发展,占据了越来越多的内存空间。又由于被V8的回收机制所限制,这样就引起可JS执行的线程被挂起,会影响当前执行的页面应用性能

2.1 什么是垃圾回收

什么是垃圾回收呢?顾名思义,主要就是两点:垃圾和回收

然后基于这两点有个 what/how/when,基本就把事儿讲明白了。可以问问自己:

  • 垃圾
    • 什么是垃圾?垃圾其实都是指已经没用的内存区域
    • 如何找打垃圾 ?流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法引用计数法
    • 何时回收垃圾?垃圾收集器会定期(周期性)找出那些不再继续使用的变量
  • 回收
    • 什么是回收? 回收就是指让这些区域可以被新的有用数据覆盖
    • 怎么回收? 基本就是清扫(Sweep) 和整理(Compact) 这两种策略
    • 何时回收? 找完了就清理,惰性清理(增量标记完成后)

2.2 内存引用

垃圾收集算法主要依赖的是引用。

在内存管理上下文中,如果对象具有对另一个对象的访问权(隐式/显示的),则称对象引用另一个对象。

在这种情况下,"对象"的概念不仅特种 JavaScript 对象,还包括函数作用域(或者全局词法作用域)

作用域和执行上下文可以看看我下面两篇文章:

【JavaScript】【作用域】词法作用域和动态作用域 - 掘金 (juejin.cn)

【JavaScript】【作用域】执行上下文和执行栈 - 掘金 (juejin.cn)

2.3 垃圾回收算法

主流的两类垃圾回收算法:

  • 追踪式垃圾回收算法
  • 引用计数法(Reference counting)

2.3.1 引用计数(Reference counting)垃圾收集算法

这是最初级的垃圾收集算法。此算法把"对象是否不再需要"简化定义为"对象有没有其他对象引用它"。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收

MDN的栗子:

js 复制代码
var o = {
  a: {
    b: 2,
  },
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量 o
// 很显然,没有一个可以被垃圾收集

var o2 = o; // o2 变量是第二个对"这个对象"的引用

o = 1; // 现在,"这个对象"只有一个 o2 变量的引用了,"这个对象"的原始引用 o 已经没有

var oa = o2.a; // 引用"这个对象"的 a 属性
// 现在,"这个对象"有两个引用了,一个是 o2,一个是 oa

o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
// 但是它的属性 a 的对象还在被 oa 引用,所以还不能回收

oa = null; // a 属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
  • 限制(缺点): 无法处理循环引用的事例

举个栗子:

js 复制代码
function f() {
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

o 和 o2 这两个对象在被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用技术算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

我们可以打印 o 和 o2的结果看看:可以发现 o 和 o2 两者之间就是无限的循环引用

2.3.2 标记-清除(Mark-and-sweep)算法

这个算法把"对象是否不再需要"简化定义为"对象是否可以再访问"。

该算法大致过程如下:

  1. 垃圾收集器设定了一个叫做根(root)的对象。在JavaScript中,根就是全局对象window,在NodeJs中根就是global对象
  2. 垃圾收集器定期从根开始,算法检查所有根及其所有子节点,并将它们标记为活动的(表示它们还是在引用)。任何根不能到达的地方都将被标记为垃圾
  3. 最后,垃圾收集器释放所有未标记为活动的内存块,并将该内存返回给操作系统

运行中标记和清除算法的可视化动图

该算法是对先前算法的一种改进,因为"有零引用的对象"总是不可访问的,但是相反却不一定,正如我们在循环中看到的那样。

缺点:

  • 收集过程中必须暂停整个系统。不允许更改工作集 => 可能导致程序定期(通常是不可预测的)"冻结",从而使某些实时和时间紧迫的应用程序变得不可能
  • 必须检查整个工作内存,其中大部分都要检查两次 => 可能导致分页内存出现问题

截止2012年,所有现代浏览器都有标记-清除垃圾收集器。并且过去几年在JavaScript垃圾收集(分代/增量/并发/并行垃圾收集)领域所做的所有改进都是对标记-清除算法的改进,而不是对垃圾收集算法本身的改进,也没有简化"一个对象不再需要"

三、堆栈溢出和内存泄漏

3.1 堆栈溢出(Stack Overflow)

3.1.1 什么是堆栈溢出

堆栈溢出是一种内存错误 ,指的是程序试图往已经满的堆栈中添加数据,导致数据覆盖了其他内存区域或者程序崩溃的情况。

3.1.2 堆栈溢出的原因

主要原因如下:

  • 程序中递归深度过渡

    举个例子:

    js 复制代码
    function isEven (num) {
        if (num === 0) {
            return true;
        }
        if (num === 1) {
            return false;
        }
        return isEven(Math.abs(num) - 2);
    }
    console.log(isEven(10)); // true
    console.log(isEven(1000000)); // Outputs: Uncaught RangeError: Maximum call stack size exceeded 

    溢出原因:每次执行代码时,都会分配一定尺寸的站空间(windows系统中为1M)。每次方法调用时都会在栈里储存一定信息(如参数、局部变量、返回值等等)。这些信息再少也会占用一定空间,成千上万个此类空间累计起来,自然就超过线程的栈空间

  • 使用了过多的本地变量

3.1.3 堆栈溢出的解决方案

可以采取的解决办法如下:

  • 优化算法和数据结构:通过减少递归深度、缩小计算规模等方式来降低函数调用时的堆栈空间消耗(使用闭包等方案)
  • 增加堆栈大小:在编译器或者操作系统级别增加堆栈空间大小
  • 使用动态内存分配代替本地变量:将本地变量改为指向动态分配的内存块的指针,从而减少对堆栈空间的需求
  • 审查代码,减少不必要的函数调用等方式来避免
  • ....

3.2 内存泄漏

3.2.1 什么是内存泄漏

内存泄漏是指申请的内存,在执行完后没有及时的清理或者销毁,占用空闲内存。内存泄漏过多的话,就会导致后面的程序申请不到内存,因此内存泄漏会导致内部内存溢出

3.2.1 出现内存泄漏的场景

  • 全局变量过多

    在JavaScript中,全局变量会一直存于内存中,直到页面关闭。如果代码中存在大量未使用的全局变量,就会导致内存泄漏。因此,应该尽量避免声明不必要的全局变量

  • 闭包引用

    闭包是指函数能访问其词法作用域外部的变量。如果在闭包引用了外部的对象而这个闭包又没有被正确释放,就会导致内存泄漏。

    因为闭包会持有对外部变量的引用,导致这些变量无法被垃圾回收器回收

  • 没有被清除的计时器

    因为定时器有对回调函数的引用,如果定时器不被清除,回调函数中引用的对象也不会被释放

  • 事件监听未移除

    当在DOM元素上注册了事件监听(如click事件),但在元素被移除之前没有显式地移除事件监听器,就会导致内存泄漏。

    因为事件监听器会持有对回调函数的引用,如果监听器不被移除,回调函数中引用的对象也不会被释放

  • 循环引用

    当两个或多个对象相互引用,并且它们之间没有正确的接触引用时,就会导致循环引用。

    因为在这种情况下,垃圾回收器无法判断哪些对象可以释放,从而导致内存泄漏

  • 为正确释放资源

    JavaScript中还存在其他类型的资源泄漏,如未关闭的网络连接、未释放的DOM元素等。这些资源的泄漏同样会导致内存泄漏

3.2.3 内存泄漏会引发的问题

  • 轻则影响应用性能,表现为迟缓卡顿
  • 重则导致页面崩溃,表现为页面无法正常使用

3.3 如何定位内存泄漏

  • 借用辅助工具。DevTools

    内存泄漏定位和分析一般需要辅助工具,比如 Chrome DevTools。开发者可以通过 DevTools 记录页面活动概况,生成可视化分析结果,从时间轴中直观了解内存泄漏情况;利用 DevTools 获取若干次内存快照,检查内存堆栈变化;以及使用 Chrome 任务管理器,实时监控内存的使用情况。

    DevTools会将这段时间内的页面行为活动进行记录和分析

    • 使用 Chrome DevTools 定位内存泄漏

      • 使用Performance面板,大致流程如下:

        1. 打开准备分析的页面和DevTools的Performance面板
        2. 勾选 memory 并开始录制
        3. 在模拟用户操作一段时间后结束录制
        4. 通过生成的结果可以直观查看到内存时间线,了解内存随时间的占用变化,如果内存占用曲线呈阶梯状一直上升,则可能存在内存泄漏。
        5. 按需选取时间线中的区域片段,检查对应时间段内的活动类型和时间占用,作为排查和定位内存泄漏的辅助办法。
      • 使用Memory大致流程如下:

        打开准备分析的页面和DevTools的Memory面板,按需生成快照。每个快照的内容是快照时刻,进行一次垃圾回收后,应用中所有可达的对象

        当开发者明确知道与内存泄漏关联的用户交互步骤时,可以生成多次内存快照进行对比,排查出泄漏的对象:在做用户交互操作之前,进行一次正常内存堆栈信息的快照;在做用户交互操作中或操作结束时,进行内存快照。使用 Comparison 视图或使用 filter 按需查看快照之间的差异。

    • 使用NodeJS中的内存泄漏定位

      如果需要定位 Node.js 中的内存泄漏,启动 Node.js 时带上 --inspect 参数,以便利用 Chrome DevTools 工具生成 Memory 快照数据。如图所示,启动 Node.js 服务后,打开 Chrome DevTools,会有 Node 标识,点击可以打开 Node 专用 DevTools。

      除此之外,也可以借助第三方包 heapdump 生成快照文件,导入至 Chrome DevTools 中的 Memory 进行快照对比。

      启动 Node.js 时带上 --expose-gc 参数以便调用 global.gc() 方法触发垃圾回收。借助 process.memoryUsage().heapUsed 检查内存大小,作为内存泄漏的辅助判断。

      js 复制代码
      const heapdump = require("heapdump");
      
      const capture = function () {
        global.gc();
        heapdump.writeSnapshot("./HZFE_HEAPSNAPSHOT/" + Date.now() + ".heapsnapshot");
        console.log("heapUsed:", process.memoryUsage().heapUsed);
      };
      
      capture();
      
      /* 可能有内存泄漏的代码片段 start */
      // code
      /* 可能有内存泄漏的代码片段 end */
      
      capture();
  • 代码自查。适合代码量较小的时候,并且可以基于以下基本原则:

    • 是否滥用全局变量,没有手动回收

    • 是否没有正确销毁定时器、闭包

    • 是否没有正确监听事件和销毁事件

参考

相关推荐
PAK向日葵4 分钟前
【算法导论】PDD 0817笔试题题解
算法·面试
加班是不可能的,除非双倍日工资2 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi3 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip3 小时前
vite和webpack打包结构控制
前端·javascript
excel3 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国4 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼4 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy4 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT4 小时前
promise & async await总结
前端
Jerry说前后端4 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化