浏览器崩溃的第一性原理:内存管理的艺术

作者:京东科技 屠永涛

你是否曾经遇到过浏览器突然卡顿,甚至崩溃的情况?尤其是在打开多个标签页或运行复杂的网页应用时,浏览器似乎变得异常脆弱。这种崩溃的背后,往往与内存管理息息相关。

浏览器的内存管理机制决定了它能否高效地分配和释放资源,而 JavaScript 引擎 V8 正是这一机制的核心。本文将探讨 V8 的内存管理机制,帮助你理解浏览器崩溃的根源,并学会如何优化内存使用,避免类似问题的发生。

一、内存管理

底层语言(如 C 语言)拥有手动的内存管理原语,例如:ree()。相反,JavaScript 是在创建对象时自动分配内存,并在不再使用时自动释放内存(垃圾回收)。这种自动化机制虽然方便,但也容易让我们产生误解,认为不需要关心内存管理,从而忽略潜在的内存问题。

二、内存生命周期

无论使用何种编程语言,内存的生命周期通常都遵循以下步骤:

  • 分配内存:根据需求分配所需的内存。
  • 使用内存:对分配的内存进行读写操作。
  • 释放内存:在内存不再需要时将其释放。

在底层语言中,内存的分配和释放是显式的,开发者需要手动管理。而在高级语言如 JavaScript 中,内存的分配和释放大多是隐式的,由垃圾回收机制自动处理。

2.1 内存分配

2.1.1 值的初始化

为了不让我们费心内存分配,JavaScript 在值初次声明时自动分配内存。

javascript 复制代码
const n = 28; // 为数值分配内存
const s = "yongtao"; // 为字符串分配内存

const o = {
  a: 1,
  b: null,
}; // 为对象及其包含的值分配内存

// 为数组及其包含的值分配内存(就像对象一样)
const a = [1, null, "yongtao"];

function f(a) {
  return a + 2;
} // 为函数(可调用的对象)分配内存

// 函数表达式也会分配内存
someElement.addEventListener(
  "click",
  function () {
    someElement.style.backgroundColor = "blue";
  }
);

2.2.1 通过函数调用分配内存

有些函数调用的结果是为对象分配内存:

javascript 复制代码
const d = new Date(); // 为 Date 对象分配内存
const e = document.createElement("div"); // 为 DOM 元素分配内存

有些方法为新值或新对象分配内存:

ini 复制代码
const s = "azerty";
const s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不可变的值,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

const a = ["yeah yeah", "no no"];
const a2 = ["generation", "no no"];
const a3 = a.concat(a2);
// 有四个元素的新数组,由 a 和 a2 其中的元素连接而成。

2.2 变量读取

使用值通常涉及对分配的内存进行读写操作。无论是读取变量值、访问对象属性,还是传递函数参数,都会使用到内存中的值。

2.3 内存回收(垃圾回收)

当内存不再需要时,系统会将其释放。大多数内存管理问题都出现在这一阶段,尤其是如何确定已分配的内存何时不再需要。在底层语言中,开发者需要手动判断并释放内存,而 JavaScript 则通过垃圾回收机制自动完成这一任务。

三、V8 的垃圾回收

垃圾回收的核心任务是识别内存中的"死区",即不再使用的内存。一旦识别出这些区域,它们可以被重新用于新的内存分配或释放回操作系统。一个对象如果不再被根对象或活跃对象引用,则被视为"死的"。根对象通常是活跃的,例如局部变量、全局对象或浏览器对象(如 DOM 元素)。

例如:

csharp 复制代码
function f() {
  var obj = { x: 12 };
  g(); // might contain an infinite loop.
  return obj.x;
}

由于无法精确判断对象是否会被再次引用(这相当于停机问题),垃圾回收器采用了一种宽松的定义:如果对象可以通过某个指针链从根对象到达,则该对象是活跃的,否则就是垃圾。

3.1 V8 内存结构

V8 的内存分为以下几个主要部分:

  1. 堆内存(Heap)

    堆内存是 V8 中用于动态分配内存的区域,存储 JavaScript 对象、闭包、函数等数据。堆内存进一步分为以下几个区域:

    • 新生代:用于存储生命周期较短的对象(如临时变量、局部变量等)。分为两个半空间(From Space 和 To Space),采用 Scavenge 算法进行垃圾回收。新生代空间较小,垃圾回收频率较高。
    • 老生代::用于存储生命周期较长的对象(如全局变量、闭包等)。采用标记-清除(Mark-Sweep)和标记-整理(Mark-Compact)算法进行垃圾回收。老生代空间较大,垃圾回收频率较低。
    • 代码空间:专门用于存储 JIT(Just-In-Time)编译生成的机器代码。代码空间与其他空间分离,因为代码的生命周期通常较长,且需要高效访问。
    • 大对象空间:用于存储较大的对象(如大数组、大字符串),避免频繁复制。采用标记-清除和标记-整理算法进行垃圾回收。
    • 单元空间、属性单元空间和映射空间:些空间分别包含 Cells、PropertyCells 和 Maps。每个空间都包含大小相同的对象,并且对它们指向的对象类型有一定的限制,从而简化了垃圾回收。
  2. 栈内存(Stack)

    栈内存用于存储函数调用时的局部变量、参数和返回地址。栈内存的特点是分配和释放速度快,但空间有限。

3.2 V8 垃圾回收机制

3.2.1 栈数据的垃圾回收

栈数据的"垃圾回收"是通过函数调用和返回机制自动完成的。栈帧的内存释放是隐式的,栈是连续的内存区域,内存分配和释放通过指针移动实现。

为什么需要区分"堆"和"栈"两个存储空间?为什么不将所有数据直接存放在栈中?

JavaScript 引擎需要用栈来维护程序执行期间的上下文状态。如果栈空间过大,所有数据都存放在栈中,会影响上下文切换的效率,进而影响整个程序的执行效率。例如,当函数执行结束时,JavaScript 引擎只需将指针下移到上一个执行上下文的地址即可,栈帧的内存会自动回收。

3.2.2 堆数据的垃圾回收

代际假说是垃圾回收领域的一个重要理论,V8 的垃圾回收策略正是基于这一假说。代际假说包含两个核心观点:

  1. 大多数对象的生命周期很短,分配后很快变得不可访问。
  2. 少数对象会存活较长时间。

基于此,V8 将堆内存分为新生代和老生代两个区域。新生代存放生命周期短的对象,老生代存放生命周期长的对象。V8 的垃圾回收器分为主垃圾回收器和副垃圾回收器。

副垃圾回收器:

副垃圾回收器主要负责新生代的垃圾回收。由于大多数小对象都分配在新生代,因此该区域的垃圾回收频率较高。

新生代采用 Scavenge 算法 进行垃圾回收。该算法将新生代空间对半划分为对象区域和空闲区域。

新加入的对象存放在对象区域。当对象区域快满时,副垃圾回收器会执行以下步骤:

  1. 标记对象区域中的存活对象。
  2. 将存活对象复制到空闲区域,并有序排列,消除内存碎片。
  3. 角色翻转:对象区域变为空闲区域,空闲区域变为对象区域。

由于 Scavenge 算法需要复制存活对象,如果新生代空间过大,复制操作会耗费较多时间。因此,新生代空间通常较小。为了应对新生代空间不足的问题,V8 采用了对象晋升策略:经过两次垃圾回收后仍然存活的对象会被移动到老生代。

主垃圾回收器:

老生代的对象通常较大,使用 Scavenge 算法 进行垃圾回收效率较低。因此,主垃圾回收器采用标记-清除标记-整理算法。

标记-清除:首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历调用栈,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。标记过程和清除过程就是标记 - 清除算法 Mark-Sweep ,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。

碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法---标记-整理(Mark-Compact)

这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。V8 研究团队 2016 年的一篇博文:在一次完整的垃圾回收之后,V8 的堆增长策略会根据活动对象的数量外加一些余量来确定何时再进行垃圾回收。

全停顿和增量标记

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

有研究数据表明,如果堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。

如果在执行垃圾回收的过程中,占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法,

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

四、内存泄漏与优化

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

4.1 常见的内存泄漏场景及优化方案

4.1.1 意外的全局变量

未使用 var、let 或 const 声明的变量会隐式变为全局变量,直到页面关闭才会被释放。

示例:

csharp 复制代码
function leak() {
  leakedVar = 'This is a global variable'; // 意外的全局变量
}

优化:

始终使用 var、let 或 const 声明变量。启用严格模式("use strict"),避免意外创建全局变量。

4.1.2 未清理的定时器或回调函数

未清除的 setInterval 或 setTimeout 会持续持有引用,导致相关对象无法被回收。

示例:

scss 复制代码
let data = getData();
setInterval(() => {
  process(data); // data 一直被引用,无法释放
}, 1000);

优化:

使用 clearInterval 或 clearTimeout 清除定时器。在组件销毁或页面卸载时清理定时器。

4.1.3 未解绑的事件监听器

未移除的事件监听器会持续持有对 DOM 元素或对象的引用,导致内存泄漏。

示例:

javascript 复制代码
const button = document.getElementById('myButton');
button.addEventListener('click', () => {
  console.log('Button clicked');
});
// 如果 button 被移除,但未解绑事件监听器,会导致内存泄漏

优化:

使用 removeEventListener 解绑事件监听器。在组件销毁或页面卸载时解绑事件。

4.1.4 闭包中的引用

闭包会捕获外部函数的变量,如果闭包未被释放,这些变量也会一直存在。

示例:

javascript 复制代码
function createClosure() {
  let largeData = new Array(1000000).fill('data');
  return function() {
    console.log(largeData[0]); // largeData 一直被闭包引用
  };
}
const closure = createClosure();

优化:

免在闭包中捕获不必要的变量。在不再需要闭包时,手动解除引用(例如将闭包设置为 null)。

4.1.5 DOM 引用未释放

如果 JavaScript 中保留了 DOM 元素的引用,即使该元素从页面中移除,也无法被垃圾回收。

示例:

javascript 复制代码
let element = document.getElementById('myElement');
document.body.removeChild(element); // 从 DOM 中移除
// element 仍然被引用,无法释放

优化:

在移除 DOM 元素后,将其引用设置为 null:

ini 复制代码
element = null;

4.1.6 缓存未清理

缓存对象(如 Map 或 WeakMap)如果未正确清理,会导致内存泄漏。

示例:

javascript 复制代码
const cache = new Map();
function setCache(key, value) {
  cache.set(key, value);
}
// 如果缓存未清理,会持续增长

优化:

使用 WeakMap 或 WeakSet,它们不会阻止键对象的垃圾回收。定期清理缓存。

4.2 内存泄漏检查

4.2.1 使用 Chrome 任务管理器

Chrome 自带的任务管理器可以帮助你快速发现内存占用异常的任务。

步骤:

  1. 打开 Chrome 任务管理器:点击 Chrome 右上角的三个点(菜单按钮) > 更多工具 > 任务管理器。
  2. 查看内存占用:关注内存占用异常高的任务(如标签页、扩展程序、辅助框架等)。
  3. 检查内存增长:观察某个任务的内存占用是否持续增长(即使页面没有操作)。如果某个任务的内存占用不断增加,可能是内存泄漏。

4.2.2 使用 Chrome 开发者工具

Chrome 的开发者工具提供了强大的内存分析功能,可以帮助你定位内存泄漏。

步骤:

  1. 打开开发者工具:右键点击页面,选择 检查,或者使用快捷键:Ctrl + Shift + I(Windows/Linux)或 Cmd + Option + I(Mac)。

  2. 使用 Memory 面板:切换到 Memory 标签。选择以下工具之一进行分析:

    • Heap Snapshot:拍摄堆内存快照,分析内存分配情况。
    • Allocation instrumentation on timeline:记录内存分配的时间线,查看内存增长情况。
    • Allocation sampling:通过采样分析内存分配。
  3. 分析内存泄漏:

    • 拍摄多个堆内存快照,比较快照之间的内存变化。
    • 查找未被释放的对象(如 DOM 节点、事件监听器等)。
    • 检查 Retainers(持有者),找到导致内存泄漏的代码。

4.2.3 使用第三方工具

除了 Chrome 自带的工具,还可以使用以下第三方工具进行内存分析:

  1. Lighthouse:Chrome 的 Lighthouse 工具可以检测页面性能问题,包括内存泄漏。
  2. MemLab:Facebook 开源的 JavaScript 内存分析工具,专门用于检测内存泄漏。

五、从崩溃到优化:内存管理的终极目标

浏览器的崩溃往往源于内存管理的不足,而 V8 引擎的内存管理机制正是解决这一问题的关键。通过理解 V8 的内存分配、垃圾回收机制以及常见的内存泄漏场景,我们可以更好地优化代码,避免内存浪费和性能瓶颈。无论是开发者还是普通用户,了解这些原理都能帮助我们更好地应对浏览器崩溃问题,提升应用的整体性能和用户体验。

六、 总结

本文通过从常见的浏览器崩溃场景引出本篇文章的分享主题: V8的内存管理, 文章主要介绍了V8垃圾回收的原理、常见的内存泄漏场景及其预防方案。

最后,最重要的一点:欢迎评论区互动,一起交流学习,共同成长。

相关推荐
冬冬小圆帽6 分钟前
防抖和节流
开发语言·前端·javascript
lydxwj12 分钟前
vue3自定义hooks遇到的问题
前端·javascript·vue.js
野生的程序媛1 小时前
重生之我在学Vue--第8天 Vue 3 UI 框架(Element Plus)
前端·vue.js·ui
前端付杰1 小时前
从Vue源码解锁位运算符:提升代码效率的秘诀
前端·javascript·vue.js
然后就去远行吧1 小时前
小程序 wxml 语法 —— 37 setData() - 修改对象类型数据
android·前端·小程序
用户3203578360021 小时前
高薪运维必备Prometheus监控系统企业级实战(已完结)
前端
黄天才丶1 小时前
高级前端篇-脚手架开发
前端
乐闻x2 小时前
React 如何实现组件懒加载以及懒加载的底层机制
前端·react.js·性能优化·前端框架
小鱼冻干2 小时前
http模块
前端·node.js
悬炫2 小时前
闭包、作用域与作用域链:概念与应用
前端·javascript