一份讲透 Java JVM、Python CPython、Go Runtime、PHP Zend MM、Redis 五种主流语言 / 系统的内存碎片整理方案
外部碎片 (External Fragmentation):总空闲内存足够,但没有一块连续空间能容纳新对象。多见于 malloc/free 模式。
内部碎片 (Internal Fragmentation):分配器给的内存块大于实际需要,多余部分浪费。多见于固定大小分配(slab、buddy)。
| 语言 / 系统 | 内存管理方式 | 碎片整理策略 | 核心机制 |
|---|---|---|---|
| Java | JVM 自动 GC | Compacting(压缩/移动对象) | Serial GC / Parallel GC / G1 / ZGC |
| Python | 引用计数 + GC + pymalloc | 块级复用 + 内存池 | Arena / Pool / Block 三级分配 |
| Go | 并发标记-清除 GC + 分配器 | Size Class 分区 + 清扫 | Span / mcache / mcentral / mheap |
| PHP | 请求级生命周期 + Zend MM | Slab 分配器 + 请求结束全释放 | Segment / Page / Bin 三级管理 |
| Redis | jemalloc + 主动碎片整理 | Active Defrag(在线搬迁) | activedefrag + jemalloc 元数据 |
Java 的堆内存由 GC 自动管理。碎片整理的核心就是"标记-整理 (Mark-Compact)":GC 扫描存活对象,把它们向堆的一端移动,释放出整块连续空闲空间。
Mark-Compact 全过程:老年代 GC 时,将存活对象向堆底端滑动压缩。简单直接,STW 时间与堆大小成正比。
# 启用 Serial GC(单线程,适合客户端)
-XX:+UseSerialGC
# 启用 Parallel GC(多线程,吞吐量优先)
-XX:+UseParallelGC -XX:ParallelGCThreads=4
# 查看 GC 是否做了 compaction
jstat -gcutil <pid> 1000
G1 把堆分成等大小的 Region。Mixed GC 时选垃圾最多的 Region 做 Evacuation(把存活对象搬运到空闲 Region),顺手就完成了碎片整理。不需要单独的全堆 Compact,碎片整理是 Evacuation 的自然副产品。
# 启用 G1(JDK 9+ 默认)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标暂停 200ms
-XX:G1HeapRegionSize=4m # Region 大小
# 观察 Mixed GC 中的 compaction
-XX:+PrintGCDetails -XX:+PrintAdaptiveSizePolicy
ZGC 使用彩色指针 (Colored Pointers) 技术。压缩时 GC 线程和 mutator 线程并发运行:用指针颜色位区分"旧地址"和"新地址",读取时自愈转发,无需长时间暂停。碎片整理完全并发完成。
# ZGC(JDK 15+ 生产就绪)
-XX:+UseZGC -Xms2g -Xmx2g
# 核心原理——染色指针的 4 个元数据位:
# Marked0 / Marked1 / Remapped / Finalizable
# GC 阶段依次翻转元数据位,完成并发重定位
| GC 算法 | 碎片整理方式 | STW 时间 | 适用场景 |
|---|---|---|---|
| Serial GC | 全堆 Mark-Compact | 较长(单线程) | 小堆 / 客户端 |
| Parallel GC | 全堆 Mark-Compact(多线程) | 较长 | 吞吐量优先 |
| CMS | 不压缩(Free List) | — | 低延迟(已废弃) |
| G1 | Region 级 Evacuation | 可控(目标暂停) | 大堆 / 平衡 |
| ZGC | 并发压缩(染色指针) | 亚毫秒级 | 超大堆 / 超低延迟 |
CPython 不能移动对象(因为有 C 扩展持有原始指针),所以不做传统意义上的 Compact。它的碎片整理策略是:通过精细的三级内存池(Arena → Pool → Block),让相同大小的内存请求从同一个 Pool 分配,避免碎片产生。
Block:最小分配单元,大小固定为 8、16、24 … 512 字节(size class,步长 8 字节)。共 64 个 size class。
Pool:4KB,同一 Pool 内所有 Block 大小相同。Block 用双向链表管理空闲块。
Arena:256KB,包含多个 Pool。如果整个 Arena 的 Pool 全部空闲,释放回操作系统。
# 查看 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
CPython 主回收是引用计数,引用归零立即释放。分代 GC 只处理循环引用(0 代 → 1 代 → 2 代),不移动对象。但 gc.collect() 回收循环引用后,空闲 block 回到 pymalloc 的 free list,可以被后续同大小的分配复用,间接减少碎片。
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 扫描负担
Go 采用类似 TCMalloc 的架构,把对象按大小分成 67 个 Size Class。同一 Size Class 的对象在一个 Span 中分配,Span 内部不会产生跨尺寸的外部碎片。GC 是并发标记-清除,不做 Compact。
// 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 使用三色标记-清除 (Tri-color Mark-Sweep),并发执行。GC 不做对象移动(没有 Compact),碎片整理依赖的是:
// 查看 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()
sync.Pool 复用对象、减少指针减少 GC 压力PHP 的"碎片整理"几乎靠天吃饭:因为每个 HTTP 请求结束就全量释放,碎片不会跨请求累积。长期进程(Swoole / RoadRunner / 常驻进程)才真正需要关注碎片。
Zend MM 在 PHP 7 做了重大改进:把小于 3KB 的内存分配按大小分成 30 个 Bin(Slab),每个 Bin 只分配固定大小的块。这从根本上消除了小对象之间的外部碎片。
// 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() 回收循环引用。
// 查看当前进程内存
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 预加载)
// 共享内存在所有请求间复用,减少分配压力
Redis 4.0 之前只能靠重启来整理碎片。4.0 引入 Active Defrag:在服务不中断的情况下,逐步扫描、搬迁碎片数据到连续内存页,同时更新引用(类似 Java G1 的 Evacuation,但粒度更细)。
调用 je_mallctl("stats.arenas.<i>.bins.<j>.slabs", ...) 拿到每个 bin 中各 slab 的已用大小和总大小,计算利用率。利用率低于阈值(默认 10%)的 slab 标记为碎片页。
对碎片页中的 key-value 根据不同数据类型分别搬迁。搬迁前后数据内容不变,但地址变了——需要更新所有引用指向新地址。
# ===== 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
# 查看当前碎片率
INFO memory
# mem_fragmentation_ratio = used_memory_rss / used_memory
# > 1.5 表示碎片严重
# 查看碎片整理状态
INFO stats | grep defrag
# 手动触发一次碎片整理(4.0+)
MEMORY PURGE
# 查看内存详细信息
MEMORY STATS
增量式运行:不是一次性扫完全堆,而是在每次 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)。
| 维度 | 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 参数 | 通常不需要 | 通常不需要 | 常驻进程需要 | 需配置开启 |