# 浏览器全链路内存全景图
彻底搞懂浏览器内存: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()!
三条关键规则:
- GPU 显存必须手动释放:
gl.deleteTexture(texture) - Cocos Creator 的释放:
cc.assetManager.release(asset)或asset.decRef() - 引用计数归零 ≠ 立即释放 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显存桥梁