# 浏览器全链路内存全景图

彻底搞懂浏览器内存:V8 堆只是冰山一角,GPU 显存才是游戏的大户

前置知识:2_1_v8Learn(V8 内存模型)+ 2_2_h5-rendering-mastery(GPU 渲染)+ 第01章(渲染管线)


阅读指南(初学者必看)

为什么这是最核心的章节?

很多游戏开发者遇到内存问题时,习惯性地去看 V8 堆内存。但 V8 堆只是浏览器内存的一小部分!

你看到的:V8 堆 50MB
实际占用:V8 堆 50MB + GPU 显存 200MB + DOM 节点 30MB + 图片缓冲 80MB = 360MB

游戏加载 100 张图后内存暴增,用 --trace-gc 却看不到增长
因为那是 GPU 显存,V8 完全管不着!

学完本章,你能回答:

  • 浏览器的内存由哪些部分组成?
  • 一张 512×512 的图片在 JS 堆占多少内存?在 GPU 显存占多少?
  • 为什么 V8 的 GC 管不了 GPU 显存?
  • DOM 节点不在 V8 堆里,那在哪?

本文结构

第一部分:浏览器内存全景图(建立全局认知)
第二部分:JS 堆 ↔ GPU 显存桥梁(游戏内存优化的核心)
第三部分:DOM 内存(最常见的泄漏源)
第四部分:其他内存区域(图片缓冲、音频、Code Space)

一、浏览器内存全景图

生活类比:浏览器内存就像一个工厂园区。V8 堆只是其中一个车间,整个园区还有很多其他区域。

浏览器进程
├── 主进程(Browser Process)
│   └── 全局资源(网络缓存、GPU 进程通信)
│
├── GPU 进程(GPU Process)⭐ 游戏开发者重点关注
│   ├── 纹理内存(所有图片/Canvas 的像素数据)  ← 游戏内存大户!
│   ├── 合成层缓冲区
│   └── Shader 缓存
│
├── 渲染进程(Renderer Process)× N(每个 Tab 一个)
│   ├── V8 堆(JS 对象)
│   │   ├── New Space(新生代,1~2MB)
│   │   ├── Old Space(老生代,数十~数百MB)
│   │   ├── Code Space(JIT 编译后的机器码)
│   │   ├── Large Object Space(>128KB 的大对象)
│   │   └── ...
│   ├── DOM 节点内存 ⭐ 不在 V8 堆里!
│   ├── CSS 样式内存
│   ├── 图片解码缓冲 ⭐ 加载的图片会在这里
│   └── 网络缓存
│
└── 工具进程(Utility Process)
    └── 音频解码、网络等

关键认知:你看的指标可能不对

你在 Chrome Task Manager 里看到的内存 ≠ V8 堆内存!

指标 含义 包含什么 在哪看
JS Heap Size V8 堆大小 JS 对象、闭包、字符串 DevTools Memory
Memory Footprint 渲染进程总内存 V8堆 + DOM + CSS + 图片缓冲 + ... Chrome Task Manager
GPU Memory GPU 进程内存 纹理 + 合成层 + Shader Chrome Task Manager(GPU进程行)
RSS(驻留集) 操作系统分配给进程的物理内存 所有上述的总和 process.memoryUsage().rss

用 Chrome Task Manager 对比

操作步骤:
1. 打开 Chrome → 更多工具 → 任务管理器
2. 右键列标题,勾选 "JavaScript 内存" 和 "GPU 内存"
3. 打开一个空白页 → 记录内存
4. 加载一个 WebGL 游戏 → 观察变化

示例结果:
                内存占用    JavaScript内存    GPU内存
空白页           50MB        10MB             20MB
WebGL游戏       350MB       50MB            250MB
                                     ↑           ↑
                               V8只占50MB   GPU显存占250MB!
                               比例只有14%    比例高达71%!

二、JS 堆 ↔ GPU 显存桥梁 ⭐⭐⭐

这是 H5 游戏内存优化最核心的知识点。

纹理上传链路

JavaScript 侧                     GPU 侧
──────────────                    ────────
let img = new Image();           
img.src = "hero.png";            
    │                            
    ▼ 浏览器下载图片             
    │                            
    ▼ 图片解码为位图(CPU 内存)   
    │  hero.png → 512×512×4 = 1MB 
    │                            
    ▼ texImage2D 上传到 GPU       
    ├──────────────────────────▶ GPU 纹理(显存)
    │                             512×512×4 = 1MB
    │  img 对象在 JS 堆只占        
    │  一个引用(几十字节)         
    │                            
    ▼ 图片位图可能被浏览器缓存     
                                  纹理一直在显存中,
                                  直到 gl.deleteTexture()

逐步理解:一张图片的内存之旅

// 第1步:创建 Image 对象
const img = new Image();
// JS 堆:+几十字节(一个引用对象)
// GPU 显存:0

// 第2步:设置 src,浏览器开始下载
img.src = 'hero.png';
// JS 堆:+几十字节(不变)
// GPU 显存:0(还没下载完)

// 第3步:下载完成,浏览器解码图片
img.onload = () => {
  // JS 堆:+几十字节(不变!Image 对象大小不变)
  // CPU 内存:+1MB(解码后的位图数据,512×512×4)
  // GPU 显存:0(还没上传到 GPU)

  // 第4步:上传到 GPU(WebGL)
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
  // JS 堆:+几十字节(texture 是一个 WebGLTexture 引用)
  // GPU 显存:+1MB(像素数据已上传到 GPU)
  // CPU 内存:位图可能被浏览器缓存,也可能释放
};

关键数据:常见游戏资源的内存占用

资源 JS 堆占用 GPU 显存占用 说明
new Image()(未加载) 几十字节 0 只是一个引用
512×512 PNG 图片 几十字节 1MB 512×512×4 bytes RGBA8
2048×2048 纹理 几十字节 16MB 2048×2048×4 bytes
2048×2048 + Mipmap 几十字节 ~21MB 额外约 33%
Canvas 512×512 几十字节 1MB Canvas 本身也是纹理
100 个 512×512 精灵 几 KB ~100MB 真正的内存大户!
一个 Cocos 场景 几 MB 200~500MB 包含纹理、网格、动画

为什么 V8 的 GC 管不了 GPU 显存?

V8 GC 的视角:
  img 对象 = 几十字节 → "很小,不值得回收"
  texture 引用 = 几十字节 → "很小,不值得回收"

真实情况:
  img 关联的 GPU 纹理 = 1MB~16MB → 这才是大头!
  但 V8 看不到!GC 不会主动调用 gl.deleteTexture()!

三条关键规则

  1. GPU 显存必须手动释放gl.deleteTexture(texture)
  2. Cocos Creator 的释放cc.assetManager.release(asset)asset.decRef()
  3. 引用计数归零 ≠ 立即释放 GPU 资源:引擎会在适当时机批量释放

Cocos Creator 中的纹理生命周期

// 加载纹理
cc.assetManager.loadBundle('textures', (err, bundle) => {
  bundle.load('hero/hero-sprite', cc.SpriteFrame, (err, spriteFrame) => {
    this.sprite.spriteFrame = spriteFrame;
    // 此时:
    // - JS 堆:spriteFrame 对象(几KB)
    // - GPU 显存:纹理像素数据(几MB~几十MB)
  });
});

// 释放纹理
// 方式1:通过资源管理器释放
cc.assetManager.releaseAsset(spriteFrame);
// 引用计数 -1,归零时释放 GPU 资源

// 方式2:手动释放
spriteFrame.decRef();
// 同上,引用计数 -1

// ⚠️ 注意:直接设 null 不会释放 GPU 显存!
this.sprite.spriteFrame = null;  // 只释放了 JS 引用
// GPU 显存还在!必须用 releaseAsset 或 decRef!

纹理压缩格式

用压缩格式可以大幅减少显存占用:

格式 压缩比 适用平台 质量 游戏中的建议
RGBA8(未压缩) 1:1 全平台 无损 开发阶段
ETC2 4:1 Android / Web 有损 Android 首选
ASTC 4:1~8:1 移动端 可调质量 iOS / 高端 Android
BCn (DXT) 4:1 桌面端 有损 PC / 桌面浏览器
PVRTC 4:1~8:1 iOS 有损 旧 iOS 设备
// Cocos Creator 构建时自动压缩纹理
// 项目设置 → 构建发布 → 纹理压缩
// 选择对应平台的压缩格式即可

// 效果:
// 原始:100MB 显存
// ETC2 压缩:25MB 显存(4:1)
// ASTC 压缩:12.5MB~25MB 显存(4:1~8:1)

三、DOM 内存

DOM 节点不在 V8 堆中!

V8 堆                   C++ 堆(浏览器内部)
──────                   ──────────────────
JS 引用 ──────────────▶ DOM 节点(C++ 对象)
{ node: HTMLElement }    { type, attributes, children, style... }
                         ↑ 这部分内存不在 V8 堆里!

一个 DOM 节点占用多少内存?

估算:一个简单 DOM 节点 ≈ 100~500 字节
- 标签名、属性:几十字节
- 样式信息:几十到几百字节
- 事件监听器列表:几十字节
- 父子兄弟引用:几十字节

复杂节点(带大量样式和事件)可能达到 1KB+

10000 个 DOM 节点 ≈ 1~5MB

这看起来不多?但问题是 DOM 操作的开销不在内存,而在渲染管线

Detached DOM(脱离文档的 DOM 节点)= 最常见的泄漏

// ❌ 泄漏:元素从 DOM 移除了,但 JS 还持有引用
const list = document.getElementById('list');
const items = list.querySelectorAll('.item'); // JS 持有引用
list.innerHTML = ''; // DOM 移除了,但 items 数组还引用着它们
// → 这些 DOM 节点变成 Detached DOM,GC 回收不了!

为什么 GC 收不了?

GC 的可达性分析:
  全局变量 items ──▶ NodeList ──▶ [HTMLElement, HTMLElement, ...]
                                        │
                                        │ 这些元素已不在 DOM 树中
                                        │ 但 JS 还通过 items 引用它们
                                        │
                                        ▼
                                   Detached DOM
                                   (C++ 堆中的内存)
                                   GC 标记为"可达",不回收

Chrome DevTools 中查看 Detached DOM

1. 打开 DevTools → Memory 面板
2. 点击 "Take Heap Snapshot"
3. 在搜索框输入 "Detached"
4. 红色标记的就是 Detached DOM
5. 点击查看 Retainers(引用链),找到谁在引用它

常见产生 Detached DOM 的场景

// 场景1:事件回调引用 DOM 元素
class GameUI {
  constructor() {
    this.panel = document.getElementById('panel');
    this.items = this.panel.querySelectorAll('.item'); // 引用子元素
    this.panel.remove(); // 移除了 panel
    // this.items 还引用着子元素 → Detached DOM
  }
}

// 场景2:闭包引用 DOM 元素
function setupUI() {
  const button = document.getElementById('btn');
  // 闭包引用了 button
  setTimeout(() => button.click(), 1000);
  button.remove(); // 移除了 button,但闭包还引用
}

// 场景3:jQuery 数据缓存
// jQuery 的 $.data() 会在内部缓存中保存元素引用
// 即使元素从 DOM 移除,缓存还在
$('#element').data('gameState', state);
$('#element').remove(); // DOM 移除了,但 $.data 缓存还在

修复方法

// ✅ 修复1:移除 DOM 之前先清空引用
class GameUI {
  destroy() {
    this.items = null;  // 先清空引用
    this.panel.remove(); // 再移除 DOM
  }
}

// ✅ 修复2:使用 WeakRef(ES2021)
const elementRef = new WeakRef(document.getElementById('panel'));
// WeakRef 不会阻止 GC 回收

// ✅ 修复3:用事件委托代替逐个绑定
document.addEventListener('click', (e) => {
  if (e.target.matches('.game-btn')) {
    handleClick(e.target);
  }
});
// 不需要保存每个按钮的引用

四、其他内存区域

图片解码缓冲

// 浏览器加载图片后的内存布局
const img = new Image();
img.src = 'hero.png';
img.onload = () => {
  // 此时内存中有:
  // 1. 原始文件数据(HTTP 缓存)── 几十KB(压缩后)
  // 2. 解码后的位图(CPU 内存)── 1MB(512×512×4)
  // 3. 可能的 GPU 副本(显存)── 1MB(如果用于渲染)
  // 总计:~2MB(一张图片!)
};

// 浏览器会自动释放解码缓冲吗?
// 不一定!浏览器可能缓存位图以备复用
// 如果加载了 200 张图片,解码缓冲可能占 200MB

音频缓冲

// Web Audio API 的音频缓冲
const audioCtx = new AudioContext();
const response = await fetch('bgm.mp3');
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);

// 内存占用:
// - 文件数据:几 MB(压缩的 MP3)
// - 解码后的 PCM 数据:几十 MB(未压缩的音频)
//   44100Hz × 2声道 × 4字节 × 180秒 ≈ 60MB(3分钟BGM)

Code Space(JIT 编译的代码)

// V8 会把频繁执行的 JS 代码编译成机器码
// 这些机器码存在 Code Space 中

// 什么时候 Code Space 会很大?
// - 大量 JS 代码被 JIT 编译
// - 游戏引擎 + 游戏逻辑可能有几 MB 的机器码

// 查看:
// Chrome DevTools → Memory → Heap Snapshot
// 搜索 "(compiled code)"

Typed Array 与 ArrayBuffer

// ArrayBuffer 可能分配在 V8 堆外(External Memory)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB

// 小 ArrayBuffer(<256KB):在 V8 堆内
// 大 ArrayBuffer(>=256KB):在 V8 堆外(C++ 分配)

// 这影响什么?
// - 堆内的 ArrayBuffer 会参与 GC
// - 堆外的 ArrayBuffer 不参与 GC(但也不阻塞 GC)
// - process.memoryUsage().external 包含堆外的 ArrayBuffer

网络缓存

浏览器网络缓存层级:

1. Memory Cache(内存缓存)
   - 当前页面的资源缓存
   - 关闭页面就释放
   - 查看方式:DevTools → Network → Size 列显示 "(memory cache)"

2. Disk Cache(磁盘缓存)
   - 持久化缓存
   - 不占内存
   - 查看方式:DevTools → Network → Size 列显示 "(disk cache)"

3. Service Worker Cache
   - 可编程的缓存
   - 占磁盘空间
   - 查看方式:DevTools → Application → Cache Storage

自问自答

Q:为什么 Chrome Task Manager 里游戏进程的内存比 DevTools 显示的 JS Heap 大这么多? A:因为 JS Heap 只是 V8 堆内存,不包括 GPU 显存、DOM 节点、图片解码缓冲等。Task Manager 显示的是整个进程的 RSS(驻留集大小),包含了所有这些。

Q:加载 100 张 512×512 的图片,大约需要多少显存? A:每张 512×512×4 = 1MB,100 张 = 100MB。如果加 Mipmap,约 133MB。如果用 ETC2 压缩,约 25MB。

Q:Cocos Creator 中 sprite.spriteFrame = null 能释放显存吗? A:不能!这只是把 JS 引用设为 null,不会释放 GPU 纹理。必须用 cc.assetManager.releaseAsset()asset.decRef() 来减少引用计数,归零时引擎才会释放 GPU 资源。

Q:为什么 gl.deleteTexture() 之后,GPU 内存没有立即减少? A:GPU 驱动可能会延迟释放(批量释放更高效)。可以用 gl.finish() 强制刷新 GPU 命令队列,但这会影响性能。一般不需要强制刷新。


实践任务

  • 任务1:打开 Chrome Task Manager,对比空白页和加载 50 张图片页面的内存差异(重点关注 GPU 进程)
  • 任务2:加载一个 WebGL 场景,观察 GPU 进程内存变化。移除场景后观察内存是否回落
  • 任务3:制造 Detached DOM 泄漏,用 Memory 面板定位并修复
  • 任务4:计算你当前游戏项目的纹理总显存占用(所有纹理的 width×height×4 求和)
  • 任务5:对比 RGBA8 和 ETC2 压缩纹理的显存占用差异

与其他章节的关联

本章内容 关联章节 关联点
V8 堆内存 2_1_v8Learn V8 项目的内存模型是基础
GPU 纹理 2_2_h5-rendering-mastery 纹理是渲染和内存的交叉点
合成层显存 第02章 合成层的纹理也占 GPU 显存
Detached DOM 第07章 内存泄漏排查的重要目标
纹理压缩 第09章 纹理压缩是内存优化的核心手段

上一章:04-事件循环与渲染时序 下一章:06-JS堆与GPU显存桥梁