存储体系
本文从存储器层次结构出发,逐层深入讲解 Cache 工作原理、替换策略、多核一致性(MESI)、伪共享、内存对齐、虚拟内存与 TLB,最后延伸到 NUMA 架构。重点关注这些底层机制对 Java 并发编程、JVM 调优的实际影响。
目录
| 章节 | 说明 |
|---|---|
| 存储器层次结构 | 寄存器到磁盘的完整金字塔 |
| Cache 工作原理 | 直接映射/组相联/全相联 |
| Cache 替换策略 | LRU/LFU/随机替换 |
| 多核缓存一致性:MESI | 写失效协议与状态机 |
| 伪共享(False Sharing) | 性能杀手与解决方案 |
| 内存对齐 | 为什么要对齐,对性能的影响 |
| 虚拟内存 | 页表/TLB/缺页中断 |
| NUMA 架构 | 多路服务器的内存访问模型 |
| 程序员视角的内存管理 | 栈与堆、GC、内存 Bug、Java 内存模型 |
存储器层次结构
存储器按速度从快到慢、容量从小到大构成层次结构,每层只与相邻层交互:
| 存储器 | 芯片类型 | 特点 |
|---|---|---|
| 寄存器 | 触发器 | 最快,CPU 流水线直接使用 |
| L1/L2/L3 Cache | SRAM(静态随机存储) | 6~8 个晶体管/bit,速度快,密度低,昂贵 |
| 主内存 | DRAM(动态随机存储) | 1 晶体管 + 1 电容/bit,需定时刷新,密度高,便宜 |
| SSD | NAND Flash | 无机械结构,随机读写快 |
| HDD | 磁性盘面 | 机械寻道,随机 IO 慢,顺序 IO 尚可 |
关键数据(数量级参考):
| 操作 | 延迟 |
|---|---|
| L1 Cache 命中 | 1ns |
| L2 Cache 命中 | 4ns |
| L3 Cache 命中 | 10ns |
| 主内存访问 | 100ns |
| SSD 随机读 | 100μs |
| HDD 随机读 | 10ms |
CPU 和内存的速度差距已达 120 倍(约 120 个 CPU Cycle),这是引入 Cache 的根本动因。
Cache 工作原理
Cache Line
CPU 不按字节从内存读取数据,而是以Cache Line为单位批量加载。Intel x86 的 Cache Line 大小通常为 64 字节。
这解释了一个经典现象:
int[] arr = new int[64 * 1024 * 1024];
// 循环 1:每个元素都访问
for (int i = 0; i < arr.length; i++) arr[i] *= 3; // 50ms
// 循环 2:每隔 16 个元素访问(16 × 4B = 64B = 1 个 Cache Line)
for (int i = 0; i < arr.length; i += 16) arr[i] *= 3; // 46ms
两个循环加载的 Cache Line 数量相同,所以时间相近。
直接映射 Cache(Direct Mapped Cache)
每个内存块固定映射到一个 Cache Line,通过取模运算实现:
Cache Line 索引 = 内存块地址 mod Cache Line 总数
内存地址拆分为三部分:
[ 组标记 Tag | 索引 Index | 偏移量 Offset ]
访问流程:
- 用 Index 定位 Cache Line
- 检查有效位(Valid Bit)是否为 1
- 比较 Tag,确认是否命中
- 用 Offset 读取具体字节
缺点:不同内存块可能映射到同一 Cache Line,频繁互相驱逐(Cache 抖动)。
组相联 Cache(Set Associative Cache)
将 Cache 分成多个组(Set),每组有 N 路(N-Way),内存块映射到固定的组,但可以放在组内任意一路。
- 2 路组相联:每组 2 个 Cache Line,减少冲突
- 8 路组相联:Intel L1 Cache 常用配置
- 全相联(Fully Associative):内存块可放在任意 Cache Line,灵活但硬件复杂
现代 CPU 基本不使用直接映射 Cache,而是使用组相联 Cache。
Cache 替换策略
当 Cache 满且需要加载新数据时,需要选择一个 Cache Line 驱逐:
| 策略 | 说明 | 优缺点 |
|---|---|---|
| LRU(最近最少使用) | 驱逐最长时间未访问的 Cache Line | 命中率高,硬件实现成本较高 |
| LFU(最不常使用) | 驱逐访问频率最低的 Cache Line | 适合热点数据稳定的场景 |
| 随机替换 | 随机选一个驱逐 | 实现简单,性能接近 LRU |
| FIFO | 驱逐最早加载的 Cache Line | 不考虑访问频率,效果较差 |
实际 CPU(如 Intel)通常使用伪 LRU(Pseudo-LRU) 近似实现,在硬件复杂度和命中率之间取得平衡。
写策略
| 策略 | 说明 | 使用场景 |
|---|---|---|
| 写直达(Write-Through) | 写 Cache 的同时写内存 | 数据一致性要求高 |
| 写回(Write-Back) | 只写 Cache,标记为"脏",驱逐时才写回内存 | 减少内存带宽占用,性能更好 |
多核缓存一致性:MESI
问题背景
多核 CPU 中,每个核有独立的 L1/L2 Cache,共享 L3 Cache 和主内存。当核 1 修改了某数据写入自己的 L2 Cache(写回策略,尚未写主内存),核 2 从主内存读取同一数据,就会读到旧值,这就是缓存一致性问题。
MESI 协议
MESI 是一种写失效(Write Invalidate) 协议,通过总线嗅探(Bus Snooping) 机制实现。每个 Cache Line 有四种状态:
| 状态 | 英文 | 含义 |
|---|---|---|
| M | Modified(已修改) | 数据已修改,与主内存不一致("脏"),独占 |
| E | Exclusive(独占) | 数据与主内存一致,只有当前核持有 |
| S | Shared(共享) | 数据与主内存一致,多个核共同持有 |
| I | Invalidated(已失效) | 数据无效,不可使用 |
关键状态流转:
- 独占(E)状态下写入 → 直接变为已修改(M),无需通知其他核
- 共享(S)状态下写入 → 先广播 RFO(Request For Ownership) 使其他核的副本失效(变为 I),再写入,变为已修改(M)
- 收到其他核的 RFO → 自己的 Cache Line 变为已失效(I)
MESI 协议与多线程编程中的读写锁思想高度相似:S 状态对应多读,M 状态对应独占写。
Store Buffer 与内存屏障
为什么需要 Store Buffer
严格遵守 MESI 协议时,CPU 写一个处于 Shared 状态的缓存行,必须:
- 发出 Invalidate 消息给所有持有该行的其他核
- 等待所有核回复 Acknowledgement
- 才能真正写入
这个等待过程耗时极长(缓存行跨核传输比寄存器操作慢几个数量级),CPU 会白白空转。
解决方案:在每个 CPU 核与其 Cache 之间加一个私有的 Store Buffer:
CPU Core → Store Buffer → Cache → 互联总线 → 其他核的 Cache
写操作先放入 Store Buffer 就继续执行,不等其他核确认。当 Invalidate Ack 到达后,再把 Store Buffer 的内容刷入 Cache。
Store Forwarding:解决自读一致性
Store Buffer 引入了一个问题:CPU 自己后续读同一地址时,数据还在 Store Buffer 里,Cache 里是旧值。
a = 1; // 放入 Store Buffer,Cache 里 a 还是 0
b = a + 1; // 如果从 Cache 读 a,得到 0,b = 1,断言失败!
assert(b == 2);
硬件通过 Store Forwarding 解决:CPU 读数据时先查自己的 Store Buffer,命中则直接使用,不去 Cache。这保证了单核的自洽性,但不影响其他核对该写操作的可见性。
Store Buffer 导致的跨核乱序
Store Forwarding 解决了自读问题,但跨核可见性仍有问题:
// 初始:a = 0, b = 0
// a 的缓存行在 CPU1,b 的缓存行在 CPU0
// CPU0 执行 foo() // CPU1 执行 bar()
void foo() { void bar() {
a = 1; // ① while (b == 0) continue; // ③
b = 1; // ② assert(a == 1); // ④
} }
可能的执行序列:
- CPU0 执行
a=1:a 不在 CPU0 的 Cache,放入 Store Buffer,发出 Read Invalidate 消息 - CPU1 执行
while(b==0):b 不在 CPU1 的 Cache,发出 Read 消息 - CPU0 执行
b=1:b 已在 CPU0 的 Cache(Exclusive),直接写入,b=1 立即对外可见 - CPU0 把含新 b 值的 Cache Line 发给 CPU1
- CPU1 读到
b=1,退出循环,执行assert(a==1) - 此时 CPU1 Cache 里 a 还是旧值 0 → 断言失败!
- (CPU0 的 Read Invalidate Ack 迟迟未到,a=1 还困在 Store Buffer 里)
根本原因:a=1 和 b=1 在 CPU0 的视角是顺序的,但 b=1 比 a=1 更早对其他核可见(因为 b 直接写 Cache,而 a 要等 Ack)。
内存屏障:冲刷 Store Buffer
写屏障(Store Barrier / smp_wmb):CPU0 在 a=1 和 b=1 之间插入写屏障,效果是:
- 屏障之前的写(
a=1)必须先从 Store Buffer 刷入 Cache,才能执行屏障之后的写(b=1) - 即使
b=1不需要等待,也要憋住,直到a=1的 Invalidate Ack 收到
void foo() {
a = 1;
smp_wmb(); // 写屏障:确保 a 的写刷入 Cache 后,再写 b
b = 1;
}
Invalidate Queue:另一个延迟来源
Store Buffer 优化了写方,但读方也有类似问题。
收到 Invalidate 消息的 CPU 必须把对应 Cache Line 标记为 Invalid,处理完才能回复 Ack。这个过程如果 Cache 很忙,Ack 会很慢,反过来拖慢写方(写方在等 Ack)。
解决方案:引入 Invalidate Queue:
其他核发来的 Invalidate 消息 → 放入 Invalidate Queue → 立即回复 Ack(还没真正处理)
↓ 异步处理
真正将 Cache Line 标记为 Invalid
收到 Invalidate 消息后立即回复 Ack,把真正的处理推迟到 Invalidate Queue 里异步完成。
新的问题:CPU1 回复了 Ack,但 Invalidate Queue 里的失效还没处理,后续读该地址时仍然命中旧的 Cache Line:
// a 的旧值在 CPU1 Cache(Shared),CPU0 发了 Invalidate 给 CPU1
// CPU1 把 Invalidate 放入队列,立即回复 Ack,但还没真正失效
// CPU0:收到 Ack,以为 CPU1 已失效,写入 a=1,再写 b=1
// CPU1:看到 b=1,去读 a → 命中旧的 Cache Line(Invalidate 还在队列里没处理)
// → 断言失败!
读屏障(Load Barrier / smp_rmb):CPU1 在读 b 和读 a 之间插入读屏障,效果是:
- 强制先排空 Invalidate Queue,处理所有待处理的失效通知
- 之后的读才能执行,保证读到的是最新值
void bar() {
while (b == 0) continue;
smp_rmb(); // 读屏障:先处理完 Invalidate Queue,再读 a
assert(a == 1);
}
两种屏障的完整配对
void foo() { void bar() {
a = 1; while (b == 0) continue;
smp_wmb(); ←配对→ smp_rmb();
b = 1; assert(a == 1); // 保证看到 a=1
} }
| 屏障类型 | 针对的问题 | 硬件动作 | 典型指令 |
|---|---|---|---|
| 写屏障(Store Barrier) | Store Buffer 导致写乱序 | 冲刷 Store Buffer | smp_wmb(), SFENCE(x86), STLR(ARM) |
| 读屏障(Load Barrier) | Invalidate Queue 导致读到旧值 | 排空 Invalidate Queue | smp_rmb(), LFENCE(x86), LDAR(ARM) |
| 全屏障(Full Barrier) | 两者都有 | 冲刷 Store Buffer + 排空 Invalidate Queue | smp_mb(), MFENCE(x86), DMB ISH(ARM) |
各 CPU 架构的差异
不同架构对 Store Buffer 和 Invalidate Queue 的暴露程度不同:
| 架构 | Store Buffer | Invalidate Queue | 暴露程度 | 需要的屏障 |
|---|---|---|---|---|
| x86 / x86-64 | 有,但硬件保证 Store-to-Load Forwarding | 基本透明(硬件自动处理) | 低,只有 Store-Load 重排可见 | 只需全屏障(MFENCE)处理 Store-Load |
| ARM / AArch64 | 有,且对程序员暴露 | 有,且对程序员暴露 | 高,几乎所有重排都可能发生 | 需要大量 DMB ISH,volatile 性能代价高 |
| PowerPC | 有 | 有 | 高,类似 ARM | SYNC(全屏障)、LWSYNC(轻量屏障) |
| RISC-V | 取决于实现 | 取决于实现 | 由实现决定 | FENCE 指令,语义类似全屏障 |
x86 的 TSO(Total Store Order)模型承诺:除 Store-Load 重排外,其他重排对程序员不可见。这是因为 x86 硬件在内部做了大量保证,让 Store Buffer 和 Invalidate Queue 的副作用对软件透明。ARM 则是"能省则省",把屏障的责任交给软件(或 JVM)。
CAS 硬件实现原理
CAS(Compare-And-Swap) 是实现无锁数据结构的基础原语,Java 中 AtomicInteger.compareAndSet() 等底层都依赖它。
语义
CAS(addr, expected, new_val):
if *addr == expected:
*addr = new_val
return true
else:
return false
关键:上面的「读-比较-写」三步必须是原子的,不能被其他核打断。
x86 实现:LOCK 前缀 + CMPXCHG
x86 通过 LOCK CMPXCHG 指令实现 CAS:
; Java: compareAndSet(expected=0, new=1) on address [rdi]
MOV EAX, 0 ; expected 放入 EAX(CMPXCHG 隐式使用 EAX)
MOV ECX, 1 ; new_val 放入 ECX
LOCK CMPXCHG [rdi], ECX
; 若 [rdi] == EAX(0),则写入 ECX(1),ZF=1(成功)
; 若 [rdi] != EAX, 则 EAX = [rdi],ZF=0(失败)
LOCK 前缀的硬件语义:
- 锁住缓存行(现代 CPU,当地址已在 Cache 中)或锁住总线(旧版 CPU)
- 保证操作期间其他核无法读写该缓存行
- 隐含全屏障语义(相当于
MFENCE),不需要额外插入屏障
ARM 实现:LDXR / STXR 独占访问对
ARM 没有 LOCK 前缀,使用**独占访问(Exclusive Access)**机制:
; Java: compareAndSet(expected=0, new=1) on address [x0]
retry:
LDXR W1, [x0] ; Load-Exclusive:读值,同时在硬件标记"独占监视器"
CMP W1, W2 ; 比较 expected
B.NE fail ; 不相等则失败
STXR W3, W4, [x0] ; Store-Exclusive:尝试写入,若"独占监视器"仍有效则成功(W3=0)
CBNZ W3, retry ; W3!=0 说明期间有其他核写过,重试
; 成功
fail:
; 失败
独占监视器(Exclusive Monitor) 的工作原理:
LDXR执行时,CPU 在硬件层面标记"我正在独占监视这个地址"- 如果在
STXR执行之前,其他核对同一缓存行做了任何写操作(触发 MESI 的 Invalidate),独占监视器会被清除 STXR发现监视器已清除,返回失败(W3=1),软件重试
这种方式叫 LL/SC(Load-Linked / Store-Conditional),比 x86 的 LOCK 更灵活,但需要软件处理重试逻辑。
CAS 与内存屏障的关系
CAS 本身保证了操作的原子性,但不自动提供内存可见性。Java 的 compareAndSet 语义是:
CAS 成功 = Acquire + Release 语义(相当于全屏障)
底层实现:
- x86:
LOCK CMPXCHG自带全屏障语义,无需额外屏障 - ARM:需要在
LDXR/STXR周围加DMB ISH,或使用带 Acquire/Release 语义的变体指令LDAXR(Load-Acquire-Exclusive)和STLXR(Store-Release-Exclusive)
; ARM 带内存序语义的 CAS(Java volatile 语义)
retry:
LDAXR W1, [x0] ; Load-Acquire-Exclusive:读 + Acquire 屏障
CMP W1, W2
B.NE fail
STLXR W3, W4, [x0] ; Store-Release-Exclusive:写 + Release 屏障
CBNZ W3, retry
ABA 问题
CAS 的一个经典陷阱:
初始:addr = A
线程1:读到 A,准备 CAS(A → B),被挂起
线程2:CAS(A → B) 成功,又 CAS(B → A)
线程1:恢复,CAS(A → B) 成功——但 addr 其实已经被改过两次了!
Java 的 AtomicStampedReference 通过附加版本号解决(每次修改版本号+1,CAS 同时比较值和版本号)。
对 Java 并发的影响
volatile 关键字在 JVM 层面会插入内存屏障,底层对应 CPU 的 MESI 协议操作,确保写操作的可见性(触发 RFO,使其他核的缓存行失效)。AtomicXxx 类的 CAS 操作底层使用 LOCK CMPXCHG(x86)或 LDAXR/STLXR(ARM),兼具原子性和全屏障语义。
伪共享(False Sharing)
问题描述
两个不相关的变量恰好落在同一个 Cache Line(64 字节) 内,多核并发修改时,即使各自修改不同变量,也会因为 MESI 协议频繁触发 RFO,导致 Cache Line 在核间反复失效和重新加载,性能急剧下降。
// 伪共享示例:x 和 y 在同一 Cache Line
class Point {
volatile long x; // 偏移 0
volatile long y; // 偏移 8,与 x 同在 64 字节 Cache Line
}
// 线程 A 频繁写 x,线程 B 频繁写 y
// → 两者不断互相使对方的 Cache Line 失效
解决方案
填充(Padding):在变量之间插入无用字节,使两个变量分布在不同 Cache Line:
class PaddedPoint {
volatile long x;
long p1, p2, p3, p4, p5, p6, p7; // 填充 56 字节
volatile long y;
long q1, q2, q3, q4, q5, q6, q7; // 填充 56 字节
}
Java 8+ 可使用 @sun.misc.Contended 注解(需 JVM 参数 -XX:-RestrictContended)自动填充。
典型案例:Disruptor 框架的 RingBuffer 通过 Cache Line 填充,避免伪共享,实现极高的无锁并发吞吐量。
内存对齐
为什么要对齐
CPU 读取内存时以字长(Word) 为单位,通常是 4 字节或 8 字节。如果数据跨越两个字的边界,CPU 需要两次内存读取再拼接,性能下降约 50%。
地址: 0 1 2 3 4 5 6 7
数据: [----int 对齐----] → 1 次读取
数据: [----int 未对齐----] → 2 次读取,跨 4 字节边界
对齐规则
int(4 字节):地址必须是 4 的倍数long/double(8 字节):地址必须是 8 的倍数- 结构体/对象:按最大成员的对齐要求对齐,末尾可能填充
对 Java 的影响
JVM 自动处理对象内部字段的对齐,但开发者需注意:
- 对象头(Object Header)占用 12 或 16 字节,会影响字段的实际偏移
- 使用 JOL(Java Object Layout)工具可以查看对象内存布局
- 数组元素天然对齐,但对象数组存储的是引用(指针),不是对象本身
虚拟内存
为什么需要虚拟内存
- 内存保护:进程之间内存隔离,防止相互踩踏
- 地址空间扩展:程序可以使用比物理内存更大的地址空间
- 内存共享:多个进程可以映射同一物理内存(如共享库)
页表(Page Table)
虚拟内存按页(Page) 划分,通常 4KB 一页。页表记录虚拟页号到物理页号的映射:
虚拟地址 = [虚拟页号 | 页内偏移量]
↓ 查页表
物理地址 = [物理页号 | 页内偏移量]
多级页表:Linux x86-64 使用 4 级页表,避免为每个进程分配完整的单级页表(32 位单级页表需 4MB/进程)。多级页表利用虚拟地址空间的稀疏性,大幅节省内存,代价是地址转换需要多次内存访问。
TLB(Translation Lookaside Buffer)
TLB 是页表的硬件缓存,存放最近使用的虚拟页号→物理页号映射。
- TLB 命中:地址转换只需 ~1ns
- TLB 未命中(TLB Miss):需遍历多级页表,可能需要 4 次内存访问(~400ns)
对性能的影响:频繁访问大量不连续内存页会导致 TLB 抖动(TLB Thrashing),性能骤降。
缺页中断(Page Fault)
访问的虚拟页不在物理内存中时触发缺页中断,OS 从磁盘加载对应页面到内存(Swap In),代价极高(~10ms)。
对 Java 的影响
- JVM 堆使用大页(Huge Page,2MB 或 1GB)可减少 TLB Miss,通过
-XX:+UseHugePages开启 - 频繁 Full GC 导致大量对象迁移,可能加剧 TLB 抖动
mmap系统调用(Java NIO MappedByteBuffer)直接将文件映射到虚拟地址空间,避免数据在内核缓冲区和用户空间之间复制
NUMA 架构
问题背景
多路服务器(如 2 路、4 路)中,多个 CPU 插槽各自有本地内存,通过 QPI(Quick Path Interconnect)互联。访问本地内存(Local Memory) 延迟低,访问远端内存(Remote Memory) 延迟高出 40%~100%,这就是非一致内存访问(NUMA,Non-Uniform Memory Access)。
NUMA 架构示意
Node 0 Node 1
┌──────────────┐ ┌──────────────┐
│ CPU 0,1,2,3 │ ←─QPI─→ │ CPU 4,5,6,7 │
│ Local RAM │ │ Local RAM │
└──────────────┘ └──────────────┘
对软件开发的影响
| 场景 | 问题 | 解决方案 |
|---|---|---|
| JVM 堆跨 NUMA 节点 | GC 线程访问远端内存,延迟升高 | -XX:+UseNUMA 启用 NUMA 感知分配 |
| 线程迁移 | OS 调度线程到不同 NUMA 节点 | 绑核(CPU Affinity) |
| 数据库 Buffer Pool | 跨节点访问热点页 | NUMA-aware 内存分配 |
在 64 核以上的服务器上,NUMA 架构对性能的影响不可忽视,需要通过
numactl等工具进行 NUMA 绑定。
程序员视角的内存管理
本章节从应用程序开发者的视角,补充栈与堆的分配机制、垃圾回收原理、常见内存 Bug,以及 Java 内存模型与 C++ 的对比。内容来源:极客时间《编程高手必学的内存知识》。
进程内存布局
一个进程的用户空间从低地址到高地址依次为:
┌─────────────────┐ 低地址
│ 代码段(.text)│ 机器指令,可读可执行
├─────────────────┤
│ 数据段(.data) │ 已初始化的全局/静态变量
├─────────────────┤
│ BSS 段 │ 未初始化的全局/静态变量(运行时填 0)
├─────────────────┤
│ 堆(Heap) │ ↑ 向高地址增长(malloc/new)
│ │
│ (空闲区域) │
│ │
│ 栈(Stack) │ ↓ 向低地址增长(函数调用帧)
├─────────────────┤
│ 内存映射区域 │ 共享库、mmap 文件
├─────────────────┤
│ 内核空间 │ OS 保留
└─────────────────┘ 高地址
Section(磁盘)vs Segment(内存):多个 Section 会合并映射为一个 Segment,例如 .text、.rodata 合并为只读可执行 Segment;.data、.bss 合并为读写 Segment。
栈内存:分配性能与 Stack Overflow
栈帧(Stack Frame) 是函数的活动记录,包含:局部变量、函数参数、返回地址、保存的寄存器。
// 典型的函数调用汇编(x86-64)
push %rbp // 保存调用者的栈基址
mov %rsp, %rbp // 建立新栈帧
sub $0x10, %rsp // 为局部变量预留 16 字节空间
...
leaveq // 恢复调用者栈帧
retq // 返回
栈分配的性能优势:
- 分配只需移动栈指针(SP 寄存器),O(1) 时间
- 无需垃圾回收,函数返回时自动释放
- 局部性好,Cache 命中率高
Stack Overflow 成因:
- 无终止条件的递归:每次调用都消耗栈帧,最终耗尽栈空间
- 栈上分配超大数组:
char buf[1024*1024]直接在栈上分配 1MB - OS 在栈末端设置禁止读写的保护页,触碰即产生 SIGSEGV
// Java 中典型的 StackOverflowError
void infinite() {
infinite(); // 无终止条件的递归
}
结论:栈内存分配极快,但容量有限(默认 1~8MB)。堆分配灵活但需要管理。
堆内存:malloc 与内存池
malloc 的实现原理:
glibc 的 malloc 不直接使用系统调用(sbrk/mmap)逐次申请小块内存,而是批量申请大块内存后在用户态精细管理:
| 算法 | 数据结构 | 碎片 | 分配效率 | 释放效率 |
|---|---|---|---|---|
| 简单算法(Naive) | 单链表 | 外部碎片严重 | O(n) | O(n) |
| 分桶管理(Binning) | 多个链表(按大小分桶) | 内部碎片 | O(1) | O(1) |
| 伙伴系统(Buddy) | 二叉树 + 链表 | 内部碎片 | O(log n) | O(log n),自动合并 |
glibc malloc 实际策略:分桶管理,1~4B 放第一个链表,5~8B 放第二个,以此类推。每个桶内用 Naive 算法,分配时按需拆分。
Tcmalloc(Google) 的核心改进:引入线程本地缓存(Thread Local Cache),多线程并发分配时各自在本地缓存中查找,只有缓存耗尽才加锁访问全局管理器,大幅降低锁竞争。
常见堆内存 Bug:
| Bug 类型 | 描述 | 后果 |
|---|---|---|
| 内存泄漏(Memory Leak) | 申请后忘记释放 | 内存持续增长,OOM |
| 野指针(Wild Pointer) | 未初始化的指针 | 读写随机内存,不可预期崩溃 |
| 悬空指针(Dangling Pointer) | 指向已释放内存的指针 | Use-After-Free,安全漏洞 |
| Double Free | 同一块内存释放两次 | 堆结构损坏,崩溃 |
| Buffer Overflow | 写超过分配区域 | 覆盖相邻数据,安全漏洞(栈溢出攻击) |
// Double Free 示例
int* p = (int*)malloc(sizeof(int));
char* q = (char*)p; // q 和 p 指向同一块内存
free(p);
free(q); // *** Error: double free or corruption ***
垃圾回收(GC)原理
GC 的核心问题:识别哪些对象是垃圾(不再被引用)。
两大类 GC 算法:
1. 引用计数(Reference Counting)
每个对象头部维护一个计数器,引用增加时 +1,引用消失时 -1,计数为 0 时回收。
- 优点:回收及时,无 STW(Stop-The-World)停顿
- 缺点:无法处理循环引用;计数器更新有额外开销
- 使用者:Python、Swift、C++
shared_ptr
2. 可达性分析(Reachability Analysis)
从根集合(Root Set)(栈上变量、静态变量、JNI 引用等)出发,遍历所有可达对象,不可达的即为垃圾。
根集合 → 对象 A → 对象 B → 对象 C
↘ 对象 D
对象 E(不可达)→ 垃圾
常见 GC 算法对比:
| 算法 | 思路 | 停顿 | 碎片 | 空间利用率 |
|---|---|---|---|---|
| Mark-Sweep(标记清除) | 标记可达对象,清除不可达 | STW | 有碎片 | 高 |
| Scavenge/Copying(复制) | 将存活对象复制到另一半空间 | STW | 无碎片 | 50%(另一半闲置) |
| Mark-Compact(标记整理) | 标记后移动存活对象到连续区域 | STW(移动耗时) | 无碎片 | 高 |
| 分代 GC(Generational) | 按对象年龄分区,新生代频繁 GC | 短暂 STW | 视算法 | 高 |
| G1 GC | 分区(Region)回收,优先回收垃圾多的区 | 可预测停顿时间 | 低 | 高 |
| ZGC/Shenandoah | 并发标记+并发移动,几乎无 STW | <10ms | 无 | 高 |
GC 评价维度:分配效率、回收效率、内存碎片、空间利用率、停顿时长、实现复杂度。
GC 与内存碎片:
- 复制算法天然无碎片,但空间利用率只有 50%
- 标记清除会产生外部碎片,长时间运行后分配大对象可能失败
- 分代 GC 通过将新生代(大量短命对象)和老年代(少量长命对象)分开处理,大幅降低 GC 频率和停顿
Java 内存模型(JMM)vs C++ 内存模型
| 维度 | Java(JMM) | C++ |
|---|---|---|
| 内存管理 | JVM 自动 GC,无需手动 free | 手动 malloc/free 或智能指针 |
| 内存安全 | 数组越界抛异常,无野指针 | 程序员负责,越界是 UB |
| volatile 语义 | 可见性 + 禁止重排序(含内存屏障) | 仅禁止编译器优化,不保证可见性 |
| 并发模型 | JMM 定义 happens-before 规则 | C++11 原子操作 + memory_order |
| 对象布局 | JVM 控制,有对象头(12/16B) | 编译器控制,可 #pragma pack |
| 内存屏障 | volatile、synchronized、Unsafe.fullFence() |
std::atomic_thread_fence() |
Java volatile 的底层:
// Java
volatile boolean flag = false;
// 写操作:插入 StoreStore + StoreLoad 屏障
flag = true;
// 读操作:插入 LoadLoad + LoadStore 屏障
if (flag) { ... }
JVM 在 x86 上写 volatile 变量时插入 lock addl 指令(兼作全屏障),在 ARM 上使用 stlr(release 写)和 ldar(acquire 读)。
内存带宽与 CPU Cache Line 的关系
内存带宽瓶颈:现代 DDR4 内存带宽约 30~50 GB/s,而 L1 Cache 带宽可达 1 TB/s。大量 Cache Miss 会导致程序受内存带宽限制(Memory-Bound),而非 CPU 计算限制(CPU-Bound)。
Cache Line 对数据结构设计的影响:
// 不友好:AoS(Array of Structures),遍历 x 时每次加载整个 Point(16B),y 浪费
class Point { double x; double y; }
Point[] points = new Point[N];
// 友好:SoA(Structure of Arrays),遍历 x 时连续访问,Cache 命中率高
double[] xs = new double[N];
double[] ys = new double[N];
实践建议:热点路径上的数据结构优先考虑 SoA 布局,避免跨 Cache Line 访问;使用
@Contended隔离高竞争变量;JVM 参数-XX:+UseNUMA在多路服务器上启用 NUMA 感知分配。
参考资料
- 《深入浅出计算机组成原理》— 极客时间,郑晔
- 《计算机组成与设计:硬件/软件接口》— 第 5 章
- What Every Programmer Should Know About Memory — Ulrich Drepper
- 《深入理解计算机系统》(CSAPP)— 第 6 章
- 《编程高手必学的内存知识》— 极客时间,海纳
- Memory Barriers: a Hardware View for Software Hackers — Paul E. McKenney(Store Buffer / Invalidate Queue 最权威的硬件视角解析)
- Linux Kernel Memory Barriers — David Howells, Paul McKenney et al.(内核内存屏障完整规范,涵盖所有屏障类型和使用场景)
- ARM Architecture Reference Manual — ARMv8(LDXR/STXR 独占访问、DMB/DSB 屏障指令规范)
- Latency Numbers Every Programmer Should Know — Colin Scott(交互式延迟可视化,可按年份拖动查看从 L1 Cache 到磁盘各层的延迟数据变化趋势)
评论 (0)
发表评论