浅谈JavaScript内存管理

内存管理概述

首先我们需要明确一点,无论是什么语言,所谓的内存管理,其实指的是堆内存的管理 (也就是堆内存的申请和释放);栈内存一般都是由操作系统进行管理 ,只有堆内存的管理才可能需要我们关注和参与。而且栈内存的管理也比较简单:将要执行前变量入栈,执行结束后变量出栈

内存管理的方式根据语言设计的目标和哲学可以分成两种:手动管理和自动管理。

  • 手动管理内存:在 C(malloc、free )、C++(new、delete )、Rust 这些语言中,需要我们手动申请和释放堆内存
  • 自动管理内存:而 JAVA、JavaScript、Python、Go 等,他们实现了垃圾回收机制,通过垃圾回收器帮我们自动释放那些不再需要的内存。也就是说,在这些语言中,我们只需要关注内存的申请 ,并不需要关注内存的释放

但是一般这些实现垃圾回收机制的语言会进一步封装,屏蔽数据结构的具体实现(开发者不用关心什么数据类型低层到底是存在栈中还是存在堆中),让开发者专注于数据结构上层(业务层)设计与使用,比如 JavaScript。

v8 内存架构

内存架构图

新生代(Young generation)

分成两块相同大小的内存空间:From-Space 和 To-Space。由 Minor GC,采用 Scavenge 算法进行管理。

老生代(Old generation)

分成两块:引用区(old pointer space)和数据区(old data space)。由 Major GC,采用 Mark-Sweep-Compact(标记-清除-整理) 算法进行管理。

  • old pointer space:包含具有指向其他对象的指针的幸存对象。
  • old data space:包含仅包含数据的对象(没有指向其他对象的指针)。字符串、装箱数字和未装箱双精度数组在"新空间"中存活了两个次要 GC 周期后被移至此处(这也是普通数据类型的闭包存放的地方)。

优化 main thread 阻塞思路:Fragment Mark,Lazy Sweep、Lazy Compact

大对象空间(Large object space)

这是大于其他空间大小限制的对象所在的地方,存放一些特别大的对象,垃圾收集器永远不会移动大对象。

一般来说,如果一个对象大小超过了 128KB,那么它就可能被直接存入 Large object space。

代码空间(Code space)

这是即时(JIT)编译器存储编译后的代码块的地方。这是唯一具有可执行内存的空间(尽管 Codes 可能会在"大对象空间"中分配,并且这些空间也是可执行的)

元胞空间(Cell space)、属性元胞空间(Property cell space)和映射空间(Map space)

这些空间分别包含 Cells、PropertyCells 和 Maps。每个空间都包含大小相同的对象,并且对它们指向的对象类型有一些限制,这简化了收集。

堆栈内存如何配合使用

  • 栈(stack)内存(static memory allocation):基本类型,对象的引用,闭包
  • 堆(heap)内存(dynamic memory allocation):对象类型

栈内存保存基本数据类型对象数据类型的引用,堆内存保存对象数据类型。最开始 js 脚本在执行的时候,将全局环境压入执行栈。全局基本类型存入栈内存,对象类型存入堆内存,并在栈中保存对象的引用。当执行到函数调用时,将函数压入执行栈,变量声明采用同样的方式存放到对应的堆和栈中。当函数调用结束时,函数退栈,相应的函数中声明的在栈中的变量也退出栈内存。但是这里存在一个问题,如果函数调用结束产生了闭包,那么我们需要将闭包移动到堆内存中。

每一个线程有自己独立的 stack、heap 内存空间

GC(垃圾回收器)

我们知道垃圾回收要解决的核心其实就是:怎么分辨哪些内存需要回收?

总的来说,有两种算法(思路)来解决这个分辨是否是垃圾的问题:引用计数标记清除

这是两种不同的思路:

  • 引用计数:它的关注点是变量本身 ,通过观察其他变量对自身引用(++)与解引用(--)统计来判断该变量是否可以被释放。
  • 标记清除:它的关注点可触达性(reachable )。假如我们认为 a 应该是可用的(不能释放 a 的内存),那么 a 引用到的变量就必须可用,如果要想 a 引用的变量可用,那么就必须保证 a 引用变量的引用变量可用,以此类推。也就是类似于多米诺骨牌 一样,要想 a 变量可用,那么就找出 a 相关的变量(横向遍历、深度递归),并且保证他们也必须可用。而我们一般认为执行栈栈底的变量是需要保证可用的,比如全局变量

引用计数(已废弃)

思想

引用计数的基本思路很简单:就是统计变量使用者的数量,持有就加一,释放就减一,如果统计过程中发现变量的使用者数量是 0,那么可以说明这个变量没有人用了,此时就可以进行内存释放。

特性

了解了这个算法的思路,可以发现这种算法具有这些特点:

  1. 由于标记清除的运行时机是使用者持有或者释放,那么这种算法可以第一时间感知和释放不需要的内存(及时性)。
  2. 由于我们不知道一个变量的被引用的上限是多少,所以对于计数变量的内存分配是不好把握的,有一种思路就是先分配一个适中的大小,当超过的时候再动态扩大,类似于 js 动态数组的存储处理思路(计数变量的内存大小问题)。
  3. 引用计数还是一种局部的思路,那么这种思路对于循环引用的情况就会存在问题。比如 a 引用 b,b 又引用 a,从局部的角度看 a 和 b 是不应该释放,但是如果从全局的角度来看,如果全局不再需要使用 a、b 那么他们应该是需要被释放的(循环依赖是引用计数算法的困局,这就要求开发者不再使用那块内存的时候,手动解开循环引用)。

标记-清除-(整理)

思想

引用计数的思路是从使用者的角度出发,观察变量的使用者来确定变量是否可以释放;而标记清除则是一组已知对象指针(称为根集)出发(这包括执行堆栈全局对象 ),递归遍历变量引用(使用)链路,通过判断变量是否在这条链路上(可达性 reachable)来判断变量是否可以释放。

标记完成之后,需要使用的内存不再使用的内存通过标记就可以将它们区分出来,此时就可以进行内存了。被释放的内存地址通过一个叫做空闲表的数据结构记录起来,这样到后面需要申请新的内存时,就到空闲表里面去找一块出来就行。

整理这一块的工作是可选的 ,它指的是将稀疏的空闲内存碎片整理成一整块空闲内存,这样方便下次内存分配。当然,整理也是需要考虑一些权衡,因为整理需要进行内存复制,复制的成本也是相对比较高的,所以一般来说:我们只整理一些高度碎片化的内存。

特性

根据标记清除算法的实现思路,我们可以发现它具有这些特点:

  1. 标记清除过程和主线程其他任务的执行是冲突的,不能并行。但是遍历过程又是比较耗时的,因此可能会导致卡顿(阻塞问题)。
  2. 由于这种算法内存释放和内存回收是相互独立的,那么内存回收的运行时机和回收频率的把握将是一个问题(运行时机、运行频率问题)。

针对这两个问题,我们可以采取一些优化策略来解决。

v8 GC

v8 由两个垃圾回收器(GC),分别为 Major GC 和 Minor GC。Major GC 负责从整个堆(heap)中收集垃圾,而 Minor GC 负责收集新生代(young generation)中的垃圾

世代算法:该算法基于 世代假设,也就是假设大多数的变量都是临时变量,如果一个变量在一次检测中存活下来了,那么它在下一次检测时还存活的概率会比较大。那么如果通过多次检测(一般是两次)都存活,那么可以将其放置到另一种类的代中去(老生代)。

接下来我们主要讲一讲新生代(young generation)和老生代(old generation)中的垃圾回收算法。

新生代(Scavenge)

新生代将内存划分成相同大小的两个部分:From-Space(使用区)和 To-Space(休闲区)。新声明的变量会直接写入到 From-Space,同时记录变量的写入次数为 1(初次写入)。当 From-Space 内存满了的时候开始运行 GC,将存活的变量写入到 To-Space,同时变量的写入次数加一,完成之后 To-Space 与 From-Space 对调。等到下一次 From-Space 被写满,运行 GC 时,检查存活变量的写入次数,如果写入次数是 2,就直接存入老生代,而那些写入次数为 1 的才写入 To-Space。

老生代(Mark-Sweep-Compact)

老生代采用 Mark-Sweep-Compact 算法,该算法分成标记、清除、整理三个部分。

标记(增量)

关于标记,如果想要做到增量标记,可以采用三色标记法写屏障配合实现。

三色标记法指的是:黑白灰三色:

  • 黑色:自身及其成员变量借被标记。
  • 白色:未被标记的对象。
  • 灰色:自身被标记,但是成员变量没有被标记。

具体算法:

最开始都是白色,接着从根节点开始标记成灰色,相关节点也标记成灰色。当相关节点遍历标记完成时,该节点就可以标记成黑色了。如果系统中没有灰色节点时,此时标记工作就完成了,可以进行下一步清除工作。

对于这种方式,中断修复就很简单了。对于每一次进来,只需要找出所有的灰色节点,从灰色节点开始继续处理就可以了。

写屏障 指的是:一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作强三色不变性

清除(lazy)

将可以释放的内存统一记录到一张空闲表中去。

整理(lazy)

将高度碎片化的内存整理成一块连续的内存。

常见内存泄漏场景

"The main cause for leaks in garbage collected languages are unwanted references."

总的来说:内存泄漏的主要原因是没有及时释放不需要变量的引用

场景一:意外泄漏到全局变量

js 复制代码
function foo() {
  // var arr = new Array(1e7).fill(123456789)
  arr = new Array(1e7).fill(123456789)
  this.arr = new Array(1e7).fill(123456789)
}
foo()

由于全局变量是常驻内存,也就是等到程序运行结束才会释放,这就导致 window.arr 或者 global.a 也要等到程序运行结束再释放。

解决方式是采用:'use strict'

场景二:忘记释放监视

在 js 中,注册事件处理函数是一种很常见的编程模式。当事件发生时,事件调用我们事先写好的事件处理函数。但是我们的事件一直处于监视中,所以事件处理函数的引用也会被监视者一直持有。那么此时事件处理函数所引用的变量时不能被回收的。

如果当事件不再需要监视时,忘记释放所持有的事件,那么就会导致内存泄漏。比如:

  • 没有关闭 setInterval
js 复制代码
const arr = new Array(1e7).fill(123456789)
setInterval(() => {
  console.log(arr)
}, 1000)
  • 没取消的事件监听
js 复制代码
var element = document.getElementById('button')
const arr = new Array(1e7).fill(123456789)
function onClick(event) {
  console.log(arr)
}
element.addEventListener('click', onClick)

解决方式其实就是移除监听。

场景三:忘记释放 DOM 引用

简单来说就是使用变量去保存 dom,当 dom 被移除时,记得释放那个存储 dom 的变量。

js 复制代码
let dom = document.getElementById('button')
document.body.removeChild(dom)

// dom = null

解决方式:dom = null

场景四:错误的使用闭包

像这种情况下,Chrome 是很容易区分的,每次函数调用结束就可以确定 data 闭包可以被释放了。

js 复制代码
const fn = () => {
  let data = new Array(1e7).fill(123456789)
  const getData = () => data
}
setInterval(() => {
  fn()
  // console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)

但是加上全局变量一起混用,在以前 Chrome 就搞不清了,会存在内存泄漏(不过这个问题现在已经修复了)。

js 复制代码
let bigData = null
const fn = () => {
  let data = bigData
  const getData = () => data
  bigData = new Array(1e7).fill(123456789)
}
setInterval(() => {
  fn()
  // console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)

那么这种情况内存肯定是一直在增加的,算不算泄漏,主要看需求。

js 复制代码
const fn = () => {
  let data = new Array(1e7).fill(123456789)
  const getData = () => data
  return getData
}

const list = []
setInterval(() => {
  const getData = fn()
  list.push(getData)
  // console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 1000)

由闭包问题导致的内存泄漏主要的解决方案就是:及时释放闭包引用(将引用到闭包的函数置为 null)。

总结

其实内存泄漏的场景还有很多,这里就不一一列举了。总的来说,内存泄漏产生的主要原因是变量的引用没有及时释放,这个问题在嵌套不深的情况下很容易发现,但是在嵌套比较深的情况下,就比较难发现了。总的来说,我们平时开发者应该要有这样的意识:及时释放不需要的变量引用,即使 v8 的 GC 会帮我们处理好绝大多数的情况。

如何排除内存泄漏

内存泄漏情况

本质上有两种类型的泄漏:导致内存使用周期性增加 的泄漏和发生一次但不再导致内存进一步增加的泄漏。

发生一次泄漏这种情况一般来说不算严重,除非泄漏的变量比较多、内存比较大,不过这种也很好排查。

接下来我们看一看周期性泄漏,这是一个相对比较严重的问题。

排除内存泄漏

排查内存泄漏的方法有很多,我们这里使用 Chrome 开发者工具的 Performance 和 Memory 面板来查看内存使用情况。

测试代码

html 复制代码
<html lang="en">
  <body>
    <button onclick="normal()">普通操作</button>
    <button onclick="start()">开始内存泄漏</button>
    <button onclick="stop()">停止内存泄漏</button>
    <script>
      var x = []
      let timer = null
      const normal = () => {
        x = new Array(1 ** Math.floor(Math.random() * 8)).fill(123456789)
        // 其他操作
        // 。。。
        setTimeout(normal, 1000)
      }
      const start = () => {
        x.push(new Array(1e5).fill(123456789))
        timer = setTimeout(start, 1000)
      }
      const stop = () => {
        clearInterval(timer)
      }
    </script>
  </body>
</html>

正常情况

我们先看看正常情况下的内存使用情况,打开控制面板,选择 Performance 选项卡,点击面板左上角小圆圈按钮开始记录,然后点击 普通操作 按钮,等待一段时间后,点击停止按钮停止记录。中途我们需要大概十秒钟左右点击一下清除按钮,进行一下垃圾回收。

我们知道,每一次低谷代表运行了 GC,释放了临时变量。可以看到每一次的低谷基本上差不多,没有增长的趋势,所以这是正常的情况。

内存泄漏情况

接下来我们再来看看内存泄漏的情况,同样打开控制面板,选择 Performance 选项卡,点击面板左上角小圆圈按钮开始记录,然后点击 开始内存泄漏 按钮,等待一段时间后,点击停止按钮停止记录。中途我们需要大概十秒钟左右点击一下清除按钮,进行一下垃圾回收。

可以看到我们的低谷具有慢慢增高的趋势,说明存在内存泄漏。

找到内存泄漏

找到内存泄漏的方法有很多,我们这里简单看两种:对比快照和分析 timeline

对比快照的思路是:先在初始状态拍下一张快照,然后再在内存泄漏的情况下拍下一张快照,通过对比这两张快照来发现可疑的变量。

分析 timeline 的思路是:先在初始状态下录制一段 timeline,选择蓝色尖峰,这些代表内存分配,我们将选择设置为尽可能接近大峰值之一,通过观察它的内存分配情况发现可疑的变量。

WeakMap 测试

js 复制代码
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms * 1000))

const consoleMemory = async () => {
  global.gc()
  await sleep(1) // 确保GC异步运行完
  const memoryUsage = process.memoryUsage().heapUsed / 1024
  console.log(`${memoryUsage} KB`)
}

const work = async () => {
  await consoleMemory() // 2445.90625 KB

  let wm = new WeakMap()
  let a = {}
  await consoleMemory() // 2694.0546875 KB

  wm.set(a, new Array(1e7).fill(123456789))
  await consoleMemory() // 80816.4140625 KB

  a = null
  await consoleMemory() // 2690.71875 KB
}

work()
// node --expose-gc gc.js

可以发现在 WeakMap 中,当我们 key 的引用被释放时,value 所引用的内存会自动释放。大家也可以自己测试一下 WeakSet。

需要注意的是:默认情况下,Node.js 是禁用 global.gc()方法的,需要在启动 Node.js 时使用--expose-gc 选项来启用该方法,也就是 node --expose-gc xxx.js

写在最后

以上是我个人的思考和整理,欢迎各位大佬交流和指正。

参考

Trash talk: the Orinoco garbage collector

Memory Management in V8, garbage collection and improvements

Demystifying memory management in modern programming languages

4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them

Chrome DevTools

JavaScript 内存泄漏教程

「硬核JS」你真的了解垃圾回收机制吗

相关推荐
uhakadotcom6 分钟前
视频直播与视频点播:基础知识与应用场景
后端·面试·架构
范文杰13 分钟前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪21 分钟前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪30 分钟前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy1 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom2 小时前
快速开始使用 n8n
后端·面试·github
uhakadotcom2 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom2 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom2 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom2 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试