浅谈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」你真的了解垃圾回收机制吗

相关推荐
正小安1 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
小飞猪Jay3 小时前
C++面试速通宝典——13
jvm·c++·面试
_.Switch3 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光3 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   3 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   3 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web3 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常3 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇4 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr4 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui