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

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

值表示

基本类型

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

┌─────────────────────────────────────────────────┐
│               OCaml Value (64-bit word)          │
├─────────────────────┬───────────────────────────┤
│    Tag (最低位)      │         Payload           │
├─────────────────────┼───────────────────────────┤
│    1 (立即整数)      │    63-bit integer          │
│    0 (指针)          │    堆地址(8字节对齐)      │
└─────────────────────┴───────────────────────────┘
OCaml 类型 表示方式 示例
int 63-bit 立即数(最低位为 1) 420x55
char 立即整数 'a'0x61(标记位后)
bool 立即整数 true1, false0(常量)
float 堆上分配的 64-bit 浮点块 Tag 254
string 堆上分配的字节数组 Tag 252
tuple 堆上的块,每个字段是一个 value Tag 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_size 256K words 年轻代大小
major_heap_increment 15% 老年代增长比例
space_overhead 80% 允许的额外空间比例
max_overhead 500% 触发压缩的碎片率
allocation_policy 0 0=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

扩展阅读