内存碎片整理实现机制

一份讲透 Java JVM、Python CPython、Go Runtime、PHP Zend MM、Redis 五种主流语言 / 系统的内存碎片整理方案

📐

总览:什么是内存碎片,为什么需要整理?

内存碎片的两类形态

外部碎片 (External Fragmentation):总空闲内存足够,但没有一块连续空间能容纳新对象。多见于 malloc/free 模式。

内部碎片 (Internal Fragmentation):分配器给的内存块大于实际需要,多余部分浪费。多见于固定大小分配(slab、buddy)。

分配对象
释放对象
形成空隙
碎片整理
紧凑连续
语言 / 系统内存管理方式碎片整理策略核心机制
JavaJVM 自动 GCCompacting(压缩/移动对象)Serial GC / Parallel GC / G1 / ZGC
Python引用计数 + GC + pymalloc块级复用 + 内存池Arena / Pool / Block 三级分配
Go并发标记-清除 GC + 分配器Size Class 分区 + 清扫Span / mcache / mcentral / mheap
PHP请求级生命周期 + Zend MMSlab 分配器 + 请求结束全释放Segment / Page / Bin 三级管理
Redisjemalloc + 主动碎片整理Active Defrag(在线搬迁)activedefrag + jemalloc 元数据

Java — JVM 垃圾回收与对象压缩

核心思路:Compacting GC — 把活着对象移动到连续区域

Java 的堆内存由 GC 自动管理。碎片整理的核心就是"标记-整理 (Mark-Compact)":GC 扫描存活对象,把它们向堆的一端移动,释放出整块连续空闲空间。

标记存活对象
计算新地址
更新引用
移动对象
连续空闲区

🔹 不同 GC 的碎片整理方案

Serial GC / Parallel GC

Mark-Compact 全过程:老年代 GC 时,将存活对象向堆底端滑动压缩。简单直接,STW 时间与堆大小成正比。

bash # 启用 Serial GC(单线程,适合客户端) -XX:+UseSerialGC # 启用 Parallel GC(多线程,吞吐量优先) -XX:+UseParallelGC -XX:ParallelGCThreads=4 # 查看 GC 是否做了 compaction jstat -gcutil <pid> 1000

G1 GC — 增量式 Region 压缩

G1 把堆分成等大小的 Region。Mixed GC 时选垃圾最多的 Region 做 Evacuation(把存活对象搬运到空闲 Region),顺手就完成了碎片整理。不需要单独的全堆 Compact,碎片整理是 Evacuation 的自然副产品。

bash # 启用 G1(JDK 9+ 默认) -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 目标暂停 200ms -XX:G1HeapRegionSize=4m # Region 大小 # 观察 Mixed GC 中的 compaction -XX:+PrintGCDetails -XX:+PrintAdaptiveSizePolicy

ZGC / Shenandoah — 并发压缩(几乎无 STW)

ZGC 使用彩色指针 (Colored Pointers) 技术。压缩时 GC 线程和 mutator 线程并发运行:用指针颜色位区分"旧地址"和"新地址",读取时自愈转发,无需长时间暂停。碎片整理完全并发完成。

bash # ZGC(JDK 15+ 生产就绪) -XX:+UseZGC -Xms2g -Xmx2g # 核心原理——染色指针的 4 个元数据位: # Marked0 / Marked1 / Remapped / Finalizable # GC 阶段依次翻转元数据位,完成并发重定位

Java 碎片整理总结

GC 算法碎片整理方式STW 时间适用场景
Serial GC全堆 Mark-Compact较长(单线程)小堆 / 客户端
Parallel GC全堆 Mark-Compact(多线程)较长吞吐量优先
CMS不压缩(Free List)低延迟(已废弃)
G1Region 级 Evacuation可控(目标暂停)大堆 / 平衡
ZGC并发压缩(染色指针)亚毫秒级超大堆 / 超低延迟
🐍

Python — pymalloc 三级内存池与复用机制

核心思路:不是"移动"对象,而是"复用"内存块

CPython 不能移动对象(因为有 C 扩展持有原始指针),所以不做传统意义上的 Compact。它的碎片整理策略是:通过精细的三级内存池(Arena → Pool → Block),让相同大小的内存请求从同一个 Pool 分配,避免碎片产生。

Arena
(256KB)
Pool
(4KB)
Block
(8~512B)
同一 Pool
等大 Block

🔹 pymalloc 的三级结构

Block:最小分配单元,大小固定为 8、16、24 … 512 字节(size class,步长 8 字节)。共 64 个 size class。

Pool:4KB,同一 Pool 内所有 Block 大小相同。Block 用双向链表管理空闲块。

Arena:256KB,包含多个 Pool。如果整个 Arena 的 Pool 全部空闲,释放回操作系统。

python # 查看 pymalloc 的内部状态(需编译时开启 Py_DEBUG) import sys # 小对象 (<= 512B) 走 pymalloc # 大对象 (> 512B) 走系统 malloc/mmap # 手动整理 Arenas——把全空闲 Arena 归还 OS # Python 3.8+ 通过该机制控制 RSS # 核心函数(Objects/obmalloc.c): # - _PyObject_Malloc: 小块走 pymalloc,大块走 malloc # - _PyObject_Free: 归还 block 到 free_list,检查 pool 是否全空 # - arena_is_free: Arena 全空 → munmap 归还 OS

Python GC 的角色(不是压缩,是回收)

CPython 主回收是引用计数,引用归零立即释放。分代 GC 只处理循环引用(0 代 → 1 代 → 2 代),不移动对象。但 gc.collect() 回收循环引用后,空闲 block 回到 pymalloc 的 free list,可以被后续同大小的分配复用,间接减少碎片。

python import gc # 查看 GC 统计 print(gc.get_stats()) # 手动触发 GC 回收循环引用 gc.collect() # 调优参数 gc.set_threshold(700, 10, 5) # 调整分代阈值 # 注意:gc.freeze() 在 3.7+ 可用 # 冻结 GC,把当前所有对象移到永久代,不再参与 GC gc.freeze() # 减少 GC 扫描负担

Python 碎片整理总结

  • pymalloc 防碎片:等大 Block 同 Pool,不会产生交叉大小的空隙
  • Arena 归还:全空闲 Arena → munmap 还给操作系统(减少 RSS)
  • 不能 Compact:对象地址不可变(C 扩展依赖),无法移动
  • 大型 String / bytes / dict:走系统 malloc,碎片依赖底层分配器
  • 替代方案:PyPy(移动 GC + 压缩)、GraalPy、Jython
🐹

Go — Size Class 分区分配 + 并发清扫

核心思路:按大小分区(Size Class),避免碎片在源头发生

Go 采用类似 TCMalloc 的架构,把对象按大小分成 67 个 Size Class。同一 Size Class 的对象在一个 Span 中分配,Span 内部不会产生跨尺寸的外部碎片。GC 是并发标记-清除,不做 Compact。

mcache
(P 本地)
mcentral
(全局 + 锁)
mheap
(Span → Page)
OS
(mmap)

🔹 分配路径与防碎片设计

微小对象 ≤ 16B
使用 tiny allocator
多个小对象合并到同一块
小对象 ≤ 32KB
走 Size Class
Span 内同大小 → 无碎片
大对象 > 32KB
直接分配 Span
大小对齐到 Page (8KB)
go // Go 运行时内存分配核心 (src/runtime/malloc.go) // 67 个 Size Class,示例: // class 0: 8 bytes → span 中每页 512 个对象 // class 5: 48 bytes → span 中每页 85 个对象 // class 10: 144 bytes → span 中每页 28 个对象 // class 66: 32768 bytes → 直接分配 // 分配时的决策: func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { if size <= maxSmallSize { // 32KB if noscan && size < maxTinySize { // 16B,微小分配器 // 几个微对象可能挤在同一块 } // 从 mcache 的对应 Size Class Span 分配 spc := sizeclass(size) // Span 内用 bitmap 标记空闲/占用 } else { // 大对象:直接分配专用 Span } }

Go GC 与碎片梳理

Go 使用三色标记-清除 (Tri-color Mark-Sweep),并发执行。GC 不做对象移动(没有 Compact),碎片整理依赖的是:

  1. 清扫 (Sweep):将垃圾 Span 回收到 mcentral / mheap 的空闲列表,后续同 Size Class 的分配复用
  2. 合页 (Coalescing):mheap 将相邻空闲 Page 合并成大块,返给 OS(MADV_DONTNEED / MADV_FREE)
  3. Scavenger 后台线程:Go 1.16+ 有专门的 page scavenger,定期检查空闲物理内存归还 OS
go // 查看 GC 和内存统计 var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("HeapObjects: %d\n", m.HeapObjects) fmt.Printf("HeapIdle: %d MB\n", m.HeapIdle/1024/1024) fmt.Printf("HeapReleased:%d MB\n", m.HeapReleased/1024/1024) // 手动触发 GC runtime.GC() // 归还内存给 OS(Go 1.16+) debug.FreeOSMemory()

Go 碎片整理总结

  • Size Class 隔离:不同大小对象分到不同 Span → 外部碎片极少
  • 内部碎片:对象大小对齐到 Size Class 的粒度,会有少量内部碎片
  • 不 Compact:不移动对象(有栈指针和 CGO 约束)
  • 合页 + Scavenger:空闲 Page 合并后退还 OS
  • 优化技巧:sync.Pool 复用对象、减少指针减少 GC 压力
🐘

PHP — Zend Memory Manager + 请求级生命周期

核心思路:每个请求一个隔离内存池,请求结束全部释放

PHP 的"碎片整理"几乎靠天吃饭:因为每个 HTTP 请求结束就全量释放,碎片不会跨请求累积。长期进程(Swoole / RoadRunner / 常驻进程)才真正需要关注碎片。

🔹 Zend MM 的三级内存结构

Segment
(256KB × N)
Page
(4KB)
Bin / Small
(≤ 3KB)
请求结束
全量释放

PHP 7+ 的 Slab 分配器(降低碎片的关键)

Zend MM 在 PHP 7 做了重大改进:把小于 3KB 的内存分配按大小分成 30 个 Bin(Slab),每个 Bin 只分配固定大小的块。这从根本上消除了小对象之间的外部碎片。

c // Zend MM Bin 尺寸设计 (zend_alloc.c) // 30个 bin: 8/16/24/32/40/48/56/64/80/96/112/128/160/192/ // 224/256/320/384/448/512/640/768/896/1024/1280/ // 1536/1792/2048/2560/3072 // 分配 ≤ 3KB 的对象时: // 1. 算出 size → 对应的 bin number // 2. 从该 bin 的 free list 取一个 slot // 3. 如果 free list 为空 → 申请新 page,切成等大 slot // 本 page 内所有 slot 同大小 → 不产生外部碎片 // 释放时: // slot 挂回对应 bin 的 free list // 如果整个 page 的 slot 全空 → page 回收到空闲列表

请求级内存隔离 —— 最简单有效的"碎片整理"

传统 PHP-FPM 模式下,每个请求分配在独立的内存池中(emalloc vs malloc),请求结束整个池直接 mmap/munmap 或标记清空。不会有残留碎片。

但常驻进程模式(Swoole、RoadRunner、Workerman)下,内存池持续存在,碎片才会累积。解决办法是:定期重启 Worker、限制请求数、主动调用 gc_collect_cycles() 回收循环引用。

php // 查看当前进程内存 echo memory_get_usage() / 1024 . " KB\n"; // 回收循环引用(PHP 5.3+) gc_collect_cycles(); // 查看 GC 状态 var_dump(gc_status()); // Swoole 中限制 Worker 处理请求数 // server->set(['max_request' => 10000]); // 处理 1 万请求后自动重启 Worker,重置内存池 // PHP 7.3+ preloading(OPcache 预加载) // 共享内存在所有请求间复用,减少分配压力

PHP 碎片整理总结

  • FPM 模式:请求结束全清,碎片不是问题
  • Bin / Slab:等大分配 → 无外部碎片;内部碎片上限是 slot size(对齐浪费)
  • 大块分配 (> 3KB):直接分页,可能出现外部碎片
  • 常驻进程:注意设置 max_request 自动重启,或定时 gc_collect_cycles()
  • PHP 8.x 改进:JIT 编译的代码内存单独管理,不影响数据堆
🔴

Redis — Active Defrag 主动在线碎片整理

核心思路:遍历 jemalloc 的 bin,找到碎片页,把数据搬到连续区域

Redis 4.0 之前只能靠重启来整理碎片。4.0 引入 Active Defrag:在服务不中断的情况下,逐步扫描、搬迁碎片数据到连续内存页,同时更新引用(类似 Java G1 的 Evacuation,但粒度更细)。

扫描 jemalloc
活跃 bin
识别碎片页
利用率低
分配新页
搬迁数据
更新引用
(dict/ziplist…)
释放旧页
归还 OS

🔹 Active Defrag 实现细节

Step 1 —— 获取 jemalloc 碎片统计

调用 je_mallctl("stats.arenas.<i>.bins.<j>.slabs", ...) 拿到每个 bin 中各 slab 的已用大小和总大小,计算利用率。利用率低于阈值(默认 10%)的 slab 标记为碎片页。

Step 2 —— 搬迁数据(处理各种数据结构)

对碎片页中的 key-value 根据不同数据类型分别搬迁。搬迁前后数据内容不变,但地址变了——需要更新所有引用指向新地址。

String (SDS)
直接 memcpy + 更新 dict entry 指针
List
重建 quicklist / ziplist 节点
Hash / Set / ZSet
小于阈值 → 重建 ziplist / listpack
大于阈值 → 重建 dict
Stream
重建 rax 树和 consumer group
redis.conf # ===== Active Defrag 配置 ===== # 开启主动碎片整理(默认关闭) activedefrag yes # 触发条件:碎片率 ≥ 100% + 碎片 ≥ 100MB active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 # 超过此碎片率时加大整理力度 active-defrag-threshold-upper 100 # CPU 占用控制(每次事件循环最小/最大耗时占比) active-defrag-cycle-min 5 active-defrag-cycle-max 75 # 每次扫描的 jemalloc bin 的最大搬迁量 active-defrag-max-scan-fields 1000
redis-cli # 查看当前碎片率 INFO memory # mem_fragmentation_ratio = used_memory_rss / used_memory # > 1.5 表示碎片严重 # 查看碎片整理状态 INFO stats | grep defrag # 手动触发一次碎片整理(4.0+) MEMORY PURGE # 查看内存详细信息 MEMORY STATS

Active Defrag 的巧妙之处

增量式运行:不是一次性扫完全堆,而是在每次 serverCron() 事件循环中执行一小段,通过 active-defrag-cycle-min/max 控制 CPU 占比。这样即使用户请求量大,也不会明显增加延迟。

引用更新难点:Redis 内部有很多地方持有指针——dict entry → key/value、rax node → children、quicklist node → 前后节点… 每个搬迁都要找到并更新所有这些引用。这是 Active Defrag 代码最复杂的部分。

jemalloc 版本要求:必须是 jemalloc 4.0+(支持 malloc_size 和详细的 bin 统计 API)。

Redis 碎片整理总结

  • Active Defrag 是业界标杆:在线不中断搬迁,唯一生产级方案
  • 依赖 jemalloc:libc malloc 不支持 Active Defrag
  • 增量式(非一次性):避免卡顿,可调 CPU 占比
  • 覆盖全部数据结构:String / List / Hash / Set / ZSet / Stream / Module
  • MEMORY PURGE:手动命令,立即触发一次整理
  • 碎片率 > 1.5 建议关注,> 2.0 需排查 bigkey 或过期策略
📊

终极对比:五大方案一图看懂

维度 Java Python Go PHP Redis
碎片整理方式 Compacting GC
移动存活对象
Size Class 复用
不移动对象
Size Class 隔离
不移动对象
请求级全释放
+ Slab 复用
Active Defrag
在线搬迁
是否移动对象 ✅ 是 ❌ 否 ❌ 否 ❌ 否 ✅ 是
主要碎片类型 外部碎片 内部碎片为主
外部碎片较少
内部碎片为主
外部碎片极少
内部碎片(Slab 对齐) 外部碎片(jemalloc)
对业务是否有影响 STW(串行/并行GC)
G1/ZGC 基本无感
无感 无感(并发 GC) FPM 无感
常驻进程需关注
可调 CPU 占比
基本无感
内存归还 OS ✅ 可(G1 / ZGC) ✅ Arena 归还 ✅ Scavenger ✅ 请求结束归还 ✅ Active Defrag
核心不移动原因 —(会移动) C 扩展持有
原始指针
栈指针 + CGO
约束
请求结束全清
不需要移动
—(会移动)
需手动干预 选 GC 参数 通常不需要 通常不需要 常驻进程需要 需配置开启

🔹 设计哲学差异

Java
"相信我,我能挪"
移动是正确解法,用 GC 代偿
有 STW 成本但可接受
Python / Go
"别让我挪,我分好格子"
通过 Size Class 源头防碎片
代价是少许内部碎片
PHP
"不用整理,用完就扔"
请求即生命周期
碎片没有累积的机会
Redis
"长期运行,必须在线"
不中断服务的增量搬迁
各数据结构的引用都要更新

选型建议

  • Java 服务:优先用 G1 或 ZGC,关注 GC 日志中的 fragmentation 指标
  • Python 服务:避免大量小对象循环创建;长运行考虑用 PyPy
  • Go 服务:善用 sync.Pool 减少分配;大对象尽量预分配
  • PHP 服务:FPM 模式无需操心;Swoole/RoadRunner 注意 max_request
  • 运维 Redis:内存大的实例务必开启 activedefrag;定期检查 mem_fragmentation_ratio