【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();
  • 代码自查。适合代码量较小的时候,并且可以基于以下基本原则:

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

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

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

参考

相关推荐
我要洋人死几秒前
导航栏及下拉菜单的实现
前端·css·css3
川石课堂软件测试3 分钟前
性能测试|docker容器下搭建JMeter+Grafana+Influxdb监控可视化平台
运维·javascript·深度学习·jmeter·docker·容器·grafana
科技探秘人12 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人13 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR18 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香20 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969323 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai28 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
problc33 分钟前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter
Gavin_91537 分钟前
【JavaScript】模块化开发
前端·javascript·vue.js