强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

OCaml 教程 / 内存模型与 GC

内存模型与 GC

OCaml 使用自动内存管理(垃圾回收),但了解其内存模型和 GC 机制对于编写高性能代码、避免内存泄漏和与 C 互操作至关重要。


内存布局:块(Block)与标签(Tag)

OCaml 运行时中,所有堆上分配的值都表示为块(block),由一个头部(header)和载荷(payload)组成:

┌──────────┬──────────────────────────────┐
│  Header  │         Payload              │
│ (2 word) │     (N words)                │
├──────────┼──────────────────────────────┤
│ Size|Tag │ field[0] field[1] ... field[N-1] │
└──────────┴──────────────────────────────┘

Header:
  - Size (22 bits): 载荷中的 word 数
  - Color (2 bits): GC 标记(白色/灰色/黑色)
  - Tag (8 bits): 块类型标识

常见标签值

Tag含义示例
0Normal/Record(a, b, c) 元组
0构造器(无参数)None, []
1-245带参数构造器Some x, x :: xs
246Lazy/延迟求值lazy expr
247Closurefun x -> x + 1
248ObjectOCaml 对象
249Infix(闭包内部)编译器内部
250Forward(前向指针)GC 内部
251Abstract(抽象数据)Bigarray
252String"hello"
253Custom(自定义)int64, float array
254Double(浮点数)3.14
255Double arrayfloat array 元素

值表示

基本类型

OCaml 使用统一的值表示(unboxed representation):

┌─────────────────────────────────────────────────┐
│               OCaml Value (64-bit word)          │
├─────────────────────┬───────────────────────────┤
│    Tag (最低位)      │         Payload           │
├─────────────────────┼───────────────────────────┤
│    1 (立即整数)      │    63-bit integer          │
│    0 (指针)          │    堆地址(8字节对齐)      │
└─────────────────────┴───────────────────────────┘
OCaml 类型表示方式示例
int63-bit 立即数(最低位为 1)420x55
char立即整数'a'0x61(标记位后)
bool立即整数true1, false0(常量)
float堆上分配的 64-bit 浮点块Tag 254
string堆上分配的字节数组Tag 252
tuple堆上的块,每个字段是一个 valueTag 0
list[] 是立即值 0,:: 是 Tag 0 的 2 字段块
array堆上的块(指针数组或浮点数组)Tag 0 或 255

⚠️ 注意:OCaml 的 int 在 64 位系统上是 63 位(最低位用作标记),这意味着最大整数值为 2^62 - 1

浮点数组优化

(* 常规数组 — 每个元素是 boxed float *)
let arr1 = [| 1.0; 2.0; 3.0 |]  (* 每个 float 独立堆分配 *)

(* Float.Array — unboxed 浮点数组 *)
let arr2 = Float.Array.make 3 1.0  (* 连续内存,无额外分配 *)
类型内存布局性能
float array指针数组 → 各 float 块缓存不友好
Float.Array连续 64-bit 浮点数组缓存友好

💡 提示:数值计算场景优先使用 Float.ArrayBigarray,避免 float array 的间接寻址。


Minor GC(年轻代)

OCaml 使用分代 GC,新分配的对象首先进入年轻代(minor heap)。

分配策略

Minor Heap (通常 256KB - 几 MB)
┌─────────────────────────────────────────────┐
│  已用区域          │     空闲区域            │
│  ←──── hp         │     limit ────→         │
│  objects...       │     available           │
└─────────────────────────────────────────────┘

分配 = bump pointer (hp += size)
超快:只需一次指针加法 + 溢出检查

Minor GC:Copying Collection

当年轻代满时,触发 Minor GC:

  1. 将年轻代中存活的对象复制到老年代
  2. 年轻代整体清空(指针归位)
Minor GC 过程:
   ┌──────────────────┐
   │ Young Generation  │  A(存活) B(死) C(存活) D(死)
   └──────────────────┘
              │
              ▼ Copy 存活对象
   ┌──────────────────┐
   │ Old Generation    │  ... A C  ← A, C 被复制到此处
   └──────────────────┘
   ┌──────────────────┐
   │ Young Generation  │  (清空,重新分配)
   └──────────────────┘

⚠️ 注意:Minor GC 是 stop-the-world 的,但由于年轻代很小,通常耗时在微秒级别。


Major GC(老年代)

老年代使用标记-清除(Mark-Sweep)与标记-整理(Mark-Compact)的混合策略。

Mark-Sweep 阶段

Mark-Sweep:
1. Mark: 从根集合遍历,标记所有可达对象
2. Sweep: 扫描整个老年代,回收未标记对象

Mark-Compact 阶段

周期性进行压缩,消除内存碎片:

Mark-Compact:
1. Mark: 同上
2. Compact: 将存活对象移动到连续区域
3. Update: 更新所有指针

增量标记

Major GC 使用增量标记,与 mutator(用户代码)交替执行:

时间线:
mutator ─── GC ─── mutator ─── GC ─── mutator ─── GC ───
         slice    slice    slice    slice    slice

每个 GC slice 完成一部分标记工作,减少停顿时间。


写屏障(Write Barrier)

OCaml 的 GC 使用写屏障追踪老年代到年轻代的指针:

(* 当老年代对象 A 的字段被修改为指向年轻代对象 B 时 *)
A.field <- B  (* 触发写屏障 *)

写屏障的工作:

  1. 检查修改是否涉及跨代指针
  2. 如果是,记录到 remembered set
  3. Minor GC 时扫描 remembered set,避免遗漏存活对象
┌─────────────┐    pointer    ┌─────────────┐
│   Old Gen   │ ───────────── │  Young Gen  │
│  object A   │               │  object B   │
└─────────────┘               └─────────────┘
       │
       ▼ 写屏障记录
  Remembered Set

⚠️ 注意:写屏障有运行时开销。频繁修改引用的代码应关注性能影响。


Finalizer(终结器)

终结器在对象被 GC 回收前执行,用于释放外部资源:

(* 创建带终结器的值 *)
let create_resource () =
  let fd = Unix.openfile "/tmp/data" [Unix.O_RDWR] 0o644 in
  Gc.finalise (fun fd -> Unix.close fd) fd;
  fd

(* 使用 WeakRef + finalise 更安全 *)
let safe_create () =
  let state = ref (Some (Unix.openfile "/tmp/data" [Unix.O_RDWR] 0o644)) in
  Gc.finalise (fun r ->
    match !r with
    | Some fd -> Unix.close fd; r := None
    | None -> ()
  ) state;
  state

⚠️ 注意

  1. 终结器不保证何时执行(甚至不保证执行)
  2. 终结器中不能触发 GC(不能分配大对象)
  3. 终结器执行顺序不确定
  4. 终结器中的异常会被静默忽略

💡 提示:更可靠的方式是使用显式资源管理(with_ 模式):

let with_file path f =
  let fd = Unix.openfile path [Unix.O_RDWR] 0o644 in
  Fun.protect ~finally:(fun () -> Unix.close fd) (fun () -> f fd)

Gc 模块 API

OCaml 标准库的 Gc 模块提供 GC 控制接口:

(* 查看 GC 统计信息 *)
let stats = Gc.stat ()
let () = Printf.printf "Minor collections: %d\n" stats.minor_collections
let () = Printf.printf "Major collections: %d\n" stats.major_collections
let () = Printf.printf "Heap words: %d\n" stats.heap_words
let () = Printf.printf "Live words: %d\n" stats.live_words

(* 修改 GC 参数 *)
let () = Gc.set { (Gc.get ()) with
  minor_heap_size = 1024 * 1024;  (* 1M words *)
  major_heap_increment = 100;      (* 增长比例 % *)
  space_overhead = 120;            (* 允许的额外空间 % *)
  max_overhead = 500;              (* 触发 compaction 的碎片 % *)
  allocation_policy = 2;           (* 2 = best-fit *)
}

(* 手动触发 GC *)
let () = Gc.compact ()    (* 完整压缩 *)
let () = Gc.full_major () (* 完整 major collection *)

GC 调优参数

参数默认值说明
minor_heap_size256K words年轻代大小
major_heap_increment15%老年代增长比例
space_overhead80%允许的额外空间比例
max_overhead500%触发压缩的碎片率
allocation_policy00=first-fit, 1=best-fit, 2=best-fit + 适应

💡 提示

  • 增大 minor_heap_size 减少 Minor GC 频率,但增大单次停顿
  • 减小 space_overhead 让 GC 更积极回收,但增加 GC 开销
  • 大内存应用考虑设 allocation_policy = 2

弱引用(Weak)

弱引用不阻止 GC 回收其指向的对象,适合实现缓存:

(* 创建弱引用 *)
let weak_ref = Weak.create 1
let () = Weak.set weak_ref 0 (Some "hello")

(* 读取弱引用(可能返回 None) *)
match Weak.get weak_ref 0 with
| Some value -> Printf.printf "Value: %s\n" value
| None -> Printf.printf "Value was collected\n"

(* 使用 Ephemeron 实现弱哈希表 *)
module WeakCache = Ephemeron.K1.Make(struct
  type t = string
  let equal = String.equal
  let hash = Hashtbl.hash
end)

let cache = WeakCache.create 64
let () = WeakCache.add cache "key1" (compute_expensive_value "key1")

弱引用的实际用途

场景说明
缓存允许 GC 在内存压力下回收缓存条目
观察者模式避免观察者阻止被观察对象被回收
Intern(字符串驻留)共享相同字符串的唯一副本

内存泄漏排查

尽管有 GC,OCaml 仍可能出现内存泄漏:

常见泄漏原因

  1. 全局引用
(* ❌ 泄漏:全局 list 持续增长 *)
let history = ref []
let add_event e = history := e :: !history

(* ✅ 定期清理或使用有界数据结构 *)
  1. 闭包捕获
(* ❌ 闭包持有对大对象的引用 *)
let make_handler big_data =
  fun () -> process big_data  (* big_data 不会被回收 *)

(* ✅ 只捕获需要的数据 *)
let make_handler big_data =
  let summary = summarize big_data in
  fun () -> process_summary summary
  1. 缓存无限制增长
(* ❌ *)
let cache : (string, string) Hashtbl.t = Hashtbl.create 256

(* ✅ 使用 LRU 缓存或弱引用 *)
module LRU = Lru.M.Make(String)

排查工具

# 使用 OCaml 内存分析
OCAML_GC_STATS=1 ./my_program

# 使用 memprof(采样 profiling)
let () =
  Memprof.start ~sampling_rate:1e-4 {
    alloc_minor = fun _ -> None;
    alloc_major = fun _ -> None;
    promote = fun _ -> None;
    dealloc_minor = fun _ -> ();
    dealloc_major = fun _ -> ();
  }

⚠️ 注意:OCaml 的 profiling 工具生态不如 Go/Java 完善。复杂场景可能需要结合 perfvalgrind 或商业工具。


业务场景

场景:高吞吐量 Web 服务器

(* 调优 GC 参数以减少延迟 *)
let setup_gc_for_server () =
  Gc.set { (Gc.get ()) with
    minor_heap_size = 4 * 1024 * 1024;  (* 4M words, 减少 minor GC *)
    space_overhead = 150;                 (* 允许更多空间换取更少 GC *)
    allocation_policy = 2;               (* best-fit *)
  }

(* 使用对象池减少分配 *)
module Pool = struct
  let pool = Array.init 1024 (fun _ -> Buffer.create 4096)
  let idx = ref 0
  let acquire () =
    let i = !idx mod 1024 in
    incr idx;
    Buffer.clear pool.(i);
    pool.(i)
end

场景:科学计算

(* 使用 Bigarray 进行数值计算,避免 GC 压力 *)
open Bigarray

let compute n =
  let arr = Array1.create Float64 C_layout n in
  for i = 0 to n - 1 do
    arr.{i} <- float_of_int i *. 3.14
  done;
  Array1.fold_left (+.) 0.0 arr

扩展阅读