Back to Blog

V8 内存管理原理深度解析

深入解析V8内存管理机制:涵盖对象分配策略、垃圾回收算法(Scavenge 标记清除、标记整理)、隐藏类优化及 JavaScript 性能最佳实践。

27 min read
By 何启邦

概述

V8 是 Google 开发的高性能 JavaScript 和 WebAssembly 引擎,采用了先进的内存管理策略来实现快速的对象分配和高效的垃圾回收。本文档基于最新的 V8 源代码进行深度剖析,解答以下核心问题:

  1. 对象创建时的内存分配: 当创建包含字符串、数字、布尔值的 Object 时,V8 如何分配内存?
  2. 属性变化时的内存管理: 频繁改变对象属性时,V8 如何管理内存?
  3. 对象生命周期管理: 频繁增删对象时,V8 如何管理内存?

V8 内存架构

整体架构图

Loading diagram...

源码引用:

  • Heap 核心定义: src/heap/heap.h
  • 堆分配器: src/heap/heap-allocator.h:36-150
  • 新生代空间: src/heap/new-spaces.h:43-170

核心组件

1. Heap (堆)

主要的内存管理器,负责协调所有内存空间的分配和回收。

源码定义:

// src/heap/heap.h
class V8_EXPORT_PRIVATE Heap {
 public:
  // 主要的内存空间
  NewSpace* new_space_;
  OldSpace* old_space_;
  CodeSpace* code_space_;
  LargeObjectSpace* lo_space_;
  // ...
};

2. HeapAllocator (堆分配器)

统一的内存分配接口,支持所有分配类型。

源码定义:

// src/heap/heap-allocator.h:36-52
class V8_EXPORT_PRIVATE HeapAllocator final {
  V8_WARN_UNUSED_RESULT V8_INLINE AllocationResult
  AllocateRaw(int size_in_bytes, AllocationType allocation,
              AllocationOrigin origin = AllocationOrigin::kRuntime,
              AllocationAlignment alignment = kTaggedAligned,
              AllocationHint hint = AllocationHint());
};

3. Factory (对象工厂)

提供便捷的 API 来创建各种类型的堆对象。

源码引用: src/heap/factory.h:140-1414


对象内存分配机制

问题 1: 创建包含字符串、数字、布尔值的 Object 时,V8 如何分配内存?

当你在 JavaScript 中创建一个对象:

let obj = {
  name: "Hello",  // 字符串
  age: 25,        // 数字
  active: true    // 布尔值
};

分配流程图

Loading diagram...

详细分析

1. JSObject 的内存布局

+------------------+
|      Map         | ← 指向 Hidden Class (对象形状)
+------------------+
|  Properties      | ← 指向 PropertyArray 或 Hash (内联属性存储区)
+------------------+
|  Elements        | ← 指向数组元素 (如果有)
+------------------+
|  in-object       |
|  property 0      | ← name (可能内联存储)
+------------------+
|  in-object       |
|  property 1      | ← age (可能内联存储)
+------------------+
|  in-object       |
|  property 2      | ← active (可能内联存储)
+------------------+

源码引用:

  • JSObject 定义: src/objects/js-objects.h:371-1034
  • Map (Hidden Class): src/objects/map.h:237-1099

2. 不同类型值的存储策略

a) 字符串 (String)

V8 中字符串有多种表示形式,根据长度和内容选择不同的存储方式:

Loading diagram...

源码定义:

// src/objects/string.h:119-783
class String : public Name {
 public:
  enum Encoding { ONE_BYTE_ENCODING, TWO_BYTE_ENCODING };

  // 字符串最大长度
  static const uint32_t kMaxLength = v8::String::kMaxLength;

  uint32_t length_;  // 字符串长度
};

分配过程:

  1. 对于短字符串 (< 13 characters),直接在 New Space 分配 SeqOneByteStringSeqTwoByteString
  2. 字符串内容紧跟在对象头后面,采用连续内存布局
  3. 字符串被 intern (字符串池化) 以节省内存

源码引用: src/objects/string.h:840-1001

内存分配示例:

// src/heap/factory.h:285-292
V8_WARN_UNUSED_RESULT MaybeHandle<String> NewStringFromUtf8(
    base::Vector<const char> str,
    AllocationType allocation = AllocationType::kYoung);
b) 数字 (Number)

V8 对数字采用双重表示策略来优化性能和内存:

Loading diagram...

Smi (Small Integer):

  • 范围: -2^30 到 2^30-1 (32位系统) 或 -2^31 到 2^31-1 (64位系统)
  • 不占用堆内存, 值直接编码在指针中
  • 通过最后一位标记为 0 来区分对象指针

HeapNumber:

  • 用于不能表示为 Smi 的数字 (浮点数、大整数)
  • 在堆上分配,包含一个 64 位 double 值

源码定义:

// src/objects/heap-number.h
class HeapNumber : public PrimitiveHeapObject {
 public:
  // 64位双精度浮点数
  inline double value() const;
  inline void set_value(double value);

 private:
  double value_;  // 实际存储的数值
};

源码引用:

  • HeapNumber: src/objects/heap-number.h
  • Smi: src/objects/smi.h
c) 布尔值 (Boolean)

布尔值在 V8 中是单例对象,不需要每次创建:

// V8 内部有两个预分配的布尔对象
ReadOnlyRoots::true_value()   // true
ReadOnlyRoots::false_value()  // false
  • truefalse 是在 Read-Only Space 中预先创建的单例
  • 所有布尔值引用都指向这两个对象之一
  • 完全不消耗额外的堆内存

源码引用: src/roots/roots.h

3. 对象创建的完整流程

Loading diagram...

源码示例:

// src/heap/factory.cc
Handle<JSObject> Factory::NewJSObject(DirectHandle<JSFunction> constructor,
                                      AllocationType allocation) {
  // 1. 获取对象的 Map (Hidden Class)
  DirectHandle<Map> map(constructor->initial_map(), isolate());

  // 2. 从堆中分配内存
  Handle<JSObject> js_obj = NewJSObjectFromMap(map, allocation);

  // 3. 初始化属性存储
  InitializeJSObjectFromMap(map, js_obj, ...);

  return js_obj;
}

对象属性变化的内存管理

问题 2: 频繁改变对象属性时,V8 如何管理内存?

当你频繁修改对象属性:

let obj = { x: 1 };
obj.y = 2;           // 添加属性
obj.z = 3;           // 再添加
delete obj.y;        // 删除属性
obj.x = "changed";   // 改变类型

Hidden Class (Map) 与 Inline Caching

V8 使用 Hidden Class (在源码中称为 Map) 来跟踪对象的形状(shape):

Loading diagram...

源码定义:

// src/objects/map.h:237-360
class Map : public HeapObject {
 public:
  // 实例大小
  DECL_INT_ACCESSORS(instance_size)

  // 内联属性起始偏移
  DECL_INT_ACCESSORS(inobject_properties_start_or_constructor_function_index)

  // 属性描述符
  DECL_ACCESSORS(instance_descriptors, Tagged<DescriptorArray>)

  // 转换信息 (用于属性添加时的 Map 转换)
  DECL_ACCESSORS(raw_transitions,
                 Tagged<UnionOf<Smi, MaybeWeak<Map>, TransitionArray>>)
};

属性存储策略

1. In-Object Properties (内联属性)

对于对象创建时就确定的属性,V8 会在对象内部预留空间:

JSObject {
  map: Map*
  properties: PropertyArray*
  elements: FixedArray*
  [in-object-0]: value1  ← 直接存在对象内
  [in-object-1]: value2
  [in-object-2]: value3
  ...
}

优点: 访问速度快,减少指针跟踪 缺点: 对象大小固定,超过预留空间需要切换到外部存储

源码引用: src/objects/map.h:252-301

2. Out-of-Object Properties (外部属性)

当属性数量超过内联空间时,使用 PropertyArray:

JSObject {
  map: Map*
  properties: → PropertyArray {
                  length: 10
                  [0]: value4
                  [1]: value5
                  ...
                }
  elements: FixedArray*
  [in-object-0]: value1
  [in-object-1]: value2
  [in-object-2]: value3
}

源码定义:

// src/objects/property-array.h:18-88
class PropertyArray : public HeapObject {
 public:
  inline int length() const;
  inline Tagged<JSAny> get(int index) const;
  inline void set(int index, Tagged<Object> value);

  // 支持原子操作
  inline Tagged<Object> Swap(int index, Tagged<Object> value, SeqCstAccessTag tag);
  inline Tagged<Object> CompareAndSwap(int index, Tagged<Object> expected,
                                       Tagged<Object> value, SeqCstAccessTag tag);
};

源码引用: src/objects/property-array.h:18-88

属性变化时的内存管理流程

Loading diagram...

关键优化策略

1. Slack Tracking (松弛跟踪)

V8 会监控对象创建后的属性添加模式:

// src/objects/map.h:358-395
// 在对象创建后的前几次实例化时,V8 会跟踪实际使用的属性数量
// 然后调整 Map 的 instance_size 以减少内存浪费

static const int kSlackTrackingCounterStart = 7;
static const int kGenerousAllocationCount =
    kSlackTrackingCounterStart - kSlackTrackingCounterEnd + 1;

void Map::StartInobjectSlackTracking();
int Map::ComputeMinObjectSlack(Isolate* isolate);

工作原理:

  1. 最初分配较大的 in-object 空间 (预留 slack)
  2. 跟踪前 7 个实例的实际使用情况
  3. 调整后续对象的大小,减少内存浪费

源码引用: src/objects/map.h:358-395

2. Map Transitions (Map 转换)

V8 构建一个 转换树 来复用 Map:

初始 Map {}
    |
    +--add 'x'--> Map {x}
    |               |
    |               +--add 'y'--> Map {x, y}
    |               |               |
    |               |               +--add 'z'--> Map {x, y, z}
    |               |
    |               +--add 'z'--> Map {x, z}
    |
    +--add 'y'--> Map {y}
                    |
                    +--add 'x'--> Map {y, x}

多个对象可以共享相同的 Map,只要它们的形状相同。

源码定义:

// src/objects/map.h:498-505
// 存储从当前 Map 到其他 Map 的转换信息
DECL_ACCESSORS(raw_transitions,
               Tagged<UnionOf<Smi, MaybeWeak<Map>, TransitionArray>>)

// 原型信息 (用于原型链上的 Map)
DECL_ACCESSORS(prototype_info, Tagged<UnionOf<Smi, PrototypeInfo>>)

源码引用: src/objects/map.h:498-535

3. Fast Mode vs Dictionary Mode

Loading diagram...

Fast Mode:

  • 使用固定布局,属性通过偏移量快速访问
  • Map + DescriptorArray + PropertyArray/In-Object
  • 适合属性稳定的对象

Dictionary Mode:

  • 使用哈希表 (NameDictionary) 存储属性
  • 适合属性频繁增删的对象

源码引用:

// src/objects/js-objects.h:749-773
// 将对象转换为字典模式
V8_EXPORT_PRIVATE static void NormalizeProperties(
    Isolate* isolate, DirectHandle<JSObject> object,
    PropertyNormalizationMode mode, int expected_additional_properties,
    const char* reason);

// 将字典模式迁移回快速模式
V8_EXPORT_PRIVATE static void MigrateSlowToFast(
    DirectHandle<JSObject> object, int unused_property_fields,
    const char* reason);

频繁增删对象的内存管理

问题 3: 频繁增删对象时,V8 如何管理内存?

当你频繁创建和销毁对象:

for (let i = 0; i < 1000000; i++) {
  let temp = { x: i, y: i * 2 };
  // temp 将很快不可达
}

分代垃圾回收 (Generational GC)

V8 采用分代假设 (Generational Hypothesis):

  • 假设: 大多数对象的生命周期很短
  • 策略: 将堆分为新生代和老年代,采用不同的回收策略
Loading diagram...

新生代 (New Space) - Scavenge算法

新生代采用 Scavenger (清道夫) 算法,基于 Cheney 的半空间复制算法:

Loading diagram...

算法步骤:

  1. From Space 中分配新对象
  2. GC 触发时,遍历根对象
  3. 将存活对象复制到 To Space
  4. 更新所有指针
  5. 交换 From Space 和 To Space 的角色
  6. 原 From Space 整体清空,变成新的 To Space

优点:

  • 速度非常快 (1-10 ms)
  • 不产生内存碎片
  • 适合生命周期短的对象

源码定义:

// src/heap/new-spaces.h:43-170
class SemiSpace final : public Space {
 public:
  // 返回当前页的起始地址
  Address page_low() const { return current_page_->area_start(); }

  // 返回当前页的结束地址
  Address page_high() const { return current_page_->area_end(); }

  // 当前容量
  size_t current_capacity() const { return current_capacity_; }

  SemiSpaceId id() const { return id_; }  // kFromSpace or kToSpace
};

源码引用: src/heap/new-spaces.h:43-170

老年代 (Old Space) - Mark-Compact

对于存活时间较长的对象,使用 Mark-Compact (标记-压缩) 算法:

Loading diagram...

核心阶段:

  1. 标记 (Marking):

    • 从根对象开始,标记所有可达对象
    • 使用三色标记法 (白-灰-黑)
    • 支持增量标记,减少停顿
  2. 清除 (Sweeping):

    • 遍历堆,识别未标记的对象
    • 将空闲区域添加到 FreeList
  3. 压缩 (Compacting):

    • 移动存活对象,消除内存碎片
    • 更新所有指针引用

源码引用:

  • Mark-Compact 实现: src/heap/mark-compact.h:58-476
  • 标记状态: src/heap/marking-state.h
  • 清除器: src/heap/sweeper.h

Mark-Compact 核心代码:

// src/heap/mark-compact.h:58-100
class MarkCompactCollector final {
 public:
  // 执行完整的垃圾回收
  void CollectGarbage();

  // 标记阶段
  void MarkLiveObjects();

  // 清除阶段
  void Sweep();

  // 疏散和指针更新
  void Evacuate();
  void UpdatePointersAfterEvacuation();

 private:
  MarkingWorklists marking_worklists_;  // 标记工作列表
  WeakObjects weak_objects_;            // 弱引用对象
  Heap* const heap_;                    // 堆的引用
};

GC 触发时机

Loading diagram...

触发条件:

  1. Minor GC (新生代回收):

    • New Space 分配失败
    • 显式调用 (如 --expose-gc 后的 gc())
  2. Major GC (全堆回收):

    • Old Space 内存不足
    • 新生代晋升到老年代失败
    • 显式触发 GC

源码引用:

// src/heap/heap-allocator.h:171-200
V8_WARN_UNUSED_RESULT AllocationResult RetryAllocateRawOrFailSlowPath(
    int size, AllocationType allocation, AllocationOrigin origin,
    AllocationAlignment alignment, AllocationHint hint);

void CollectGarbage(AllocationType allocation,
                    PerformHeapLimitCheck perform_heap_limit_check =
                        PerformHeapLimitCheck::kYes);

增量标记与并发标记

为了减少 GC 停顿,V8 采用了多种优化:

Loading diagram...

增量标记 (Incremental Marking):

  • 将标记工作分散到多个小步骤
  • 与 JavaScript 执行交替进行
  • 使用写屏障 (Write Barriers) 跟踪对象修改

并发标记 (Concurrent Marking):

  • 在后台线程中执行大部分标记工作
  • 主线程可以继续执行 JavaScript
  • 最终化阶段需要短暂停顿

源码引用:

  • 增量标记: src/heap/incremental-marking.h
  • 并发标记: src/heap/concurrent-marking.h

总结与最佳实践

核心要点总结

1. 对象创建的内存分配

Object {name: "Hello", age: 25, active: true}

JSObject (24-40 bytes) ← 在 New Space 分配
    ├─ Map (Hidden Class) ← 共享,描述对象形状
    ├─ "Hello" ← SeqOneByteString (堆分配)
    ├─ 25 ← Smi (无堆分配,编码在指针中)
    └─ true ← 单例对象引用 (只读空间)

关键点:

  • JSObject 本身很小,主要开销在属性值
  • 字符串根据长度和内容选择不同表示
  • 小整数 (Smi) 不占用堆内存
  • 布尔值是单例,无额外内存开销

2. 属性变化的内存管理

添加属性 → Map Transition → 可能触发 PropertyArray 分配/扩容
删除属性 → 可能触发 Fast Mode → Dictionary Mode 切换
修改类型 → Map Transition → 新的 Hidden Class

关键机制:

  • Map (Hidden Class): 跟踪对象形状,支持 Inline Caching
  • Transition Tree: 复用相同形状的 Map
  • Slack Tracking: 优化初始内存分配
  • Mode Switching: Fast/Dictionary Mode 自适应切换

3. 频繁增删对象的内存管理

新对象 → New Space (Scavenge GC, <10ms)
       ↓ 多次 GC 存活
       → Old Space (Mark-Compact GC, >100ms)

关键策略:

  • 分代回收: 新生代快速回收短生命周期对象
  • 增量/并发: 减少 GC 停顿时间
  • 晋升策略: 存活对象逐步晋升到老年代

性能优化建议

1. 对象创建优化

// ❌ 避免: 频繁改变对象形状
function createPoint(x, y, z) {
  let point = {};
  point.x = x;
  point.y = y;
  if (z !== undefined) {
    point.z = z;  // 导致不同的 Hidden Class
  }
  return point;
}

// ✅ 推荐: 保持一致的对象形状
function createPoint(x, y, z) {
  return {
    x: x,
    y: y,
    z: z !== undefined ? z : null  // 始终包含 z 属性
  };
}

2. 属性访问优化

// ❌ 避免: 动态添加/删除属性
class DynamicObject {
  constructor() {
    this.x = 1;
  }

  addProperty(name, value) {
    this[name] = value;  // 每次都触发 Map Transition
  }
}

// ✅ 推荐: 在构造函数中声明所有属性
class StaticObject {
  constructor() {
    this.x = 1;
    this.y = null;  // 预声明,即使初始为 null
    this.z = null;
  }

  setY(value) {
    this.y = value;  // 不会触发 Map Transition
  }
}

3. 避免内存泄漏

// ❌ 避免: 意外的全局变量
function createLargeObject() {
  largeArray = new Array(1000000);  // 忘记 let/const,成为全局变量
}

// ✅ 推荐: 使用严格模式和块作用域
'use strict';
function createLargeObject() {
  const largeArray = new Array(1000000);
  // largeArray 会在函数返回后被 GC 回收
}

4. 对象池模式

对于频繁创建销毁的对象,可以使用对象池:

class ObjectPool {
  constructor(createFn, resetFn, initialSize = 10) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];

    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn());
    }
  }

  acquire() {
    return this.pool.length > 0
      ? this.pool.pop()
      : this.createFn();
  }

  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

// 使用示例
const pointPool = new ObjectPool(
  () => ({ x: 0, y: 0, z: 0 }),
  (point) => { point.x = 0; point.y = 0; point.z = 0; }
);

// 避免频繁创建新对象
const point = pointPool.acquire();
point.x = 10;
// ... 使用 point
pointPool.release(point);  // 归还到池中,避免 GC

监控与调试工具

1. Chrome DevTools

  • Memory Profiler: 查看堆快照,分析内存使用
  • Performance Tab: 记录 GC 事件,分析停顿时间
  • Memory Inspector: 查看对象的内存布局

2. V8 命令行工具

# 启用 GC 日志
node --trace-gc script.js

# 打印 Hidden Class 转换
node --trace-maps script.js

# 显示内联缓存状态
node --trace-ic script.js

# 生成堆快照
node --heap-prof script.js

3. V8 内部 API (需要 --expose-gc)

// 手动触发 GC
global.gc();

// 查看堆统计信息
if (typeof v8 !== 'undefined') {
  console.log(v8.getHeapStatistics());
}

参考资源

官方文档

源码关键文件

  • 堆管理: src/heap/heap.h, src/heap/heap-allocator.h
  • 对象表示: src/objects/js-objects.h, src/objects/map.h
  • 字符串: src/objects/string.h
  • GC实现: src/heap/mark-compact.h, src/heap/scavenger.h
  • 工厂方法: src/heap/factory.h

附录: 常见问题解答

Q1: 为什么相同的对象,内存占用可能不同?

A: 原因有以下几点:

  1. Hidden Class 不同: 即使属性值相同,创建顺序不同会导致不同的 Map
  2. 内联属性数量: 有些对象可能使用 in-object properties,有些使用 PropertyArray
  3. 字符串内部化: 相同字符串可能共享内存 (interned strings)
  4. Slack 空间: 新创建的对象可能预留了额外空间

Q2: 如何判断对象是否在 Fast Mode?

A: 可以使用 V8 的内部 API:

// 需要 node --allow-natives-syntax
function isFastMode(obj) {
  return %HasFastProperties(obj);
}

或查看 Hidden Class 的转换:

node --trace-maps script.js

Q3: 什么时候应该主动调用 gc()?

A: 几乎永远不应该手动调用 GC,因为:

  1. V8 的启发式算法通常优于手动触发
  2. 手动 GC 会引入不必要的停顿
  3. 可能干扰 V8 的优化决策

唯一例外:

  • 测试和性能分析时
  • 在明确的阶段边界 (如关卡切换) 且内存压力大时

Q4: 对象属性过多会有什么影响?

A: 主要影响:

  1. Map 变得复杂: 查找属性描述符变慢
  2. 可能切换到 Dictionary Mode: 失去内联缓存优势
  3. 内存占用增加: PropertyArray 需要额外分配

建议:

  • 如果属性超过 ~20 个,考虑重构为更小的对象
  • 对于动态属性集合,直接使用 Map 数据结构

下一步

现在你已经深入了解了 V8 的内存管理机制,可以继续探索以下主题:

想要实际测试你的代码性能吗?试试 Perf Lens - 在线 JavaScript 性能测试工具,支持执行时间、内存使用和包体积分析。

V8 内存管理原理深度解析 | Perf Lens