编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

  • 程序编程原理

    • README
    • 序卷方法论

    • 数据的本质

    • 运行时模型

    • 并发的设计

    • 内存的真相

      • README
      • 1.虚拟内存与地址空间
      • 2.内存模型技术设计
      • 3.堆和栈内存的设计
      • 4.内存对齐与缓存局部性
        • 00.真实事故引入
          • 0.1 一行循环改方向8秒阥0.6秒
          • 0.2 两个long变量挨太近崩塌
          • 0.3 灵魂三问
          • 0.4 五个层层递进的追问
          • 0.5 探索路径
          • 0.6 为什么这个问题值得讲透
        • 01.CPU 缓存层级与"内存墙"
          • 1.1 速度的悬殊鸿沟
          • 1.2 内存墙(Memory Wall)
          • 1.3 缓存的两条黄金法则
          • 1.4 现代 CPU 的缓存层级
        • 02.Cache Line:缓存最小搬运单位
          • 2.1 为什么是 64 字节
          • 2.2 一次访问 = 一整行加载
          • 2.3 §0.1答案:行优先vs列优先
          • 2.4 Cache Line 的物理结构
          • 2.5 缓存替换策略
        • 03.内存对齐:硬件隐形税
          • 3.1 4字节int必从4倍地址起
          • 3.2 为何必须对齐:硬件代价
          • 3.3 字段重排:用顺序换空间
          • 3.4 §0.6第一题:1970代仍是核心
          • 3.5 高级对齐:SIMD 指令
        • 04.伪共享:多核隐形杀手
          • 4.1 两个无关变量互相拖慢
          • 4.2 MESI协议:Cache Line状态机
          • 4.3 伪共享的物理过程
          • 4.4 解决方案:填充
          • 4.5 §0.3 第三题答案
        • 05.数据布局优化的工程实践
          • 5.1 AoS vs SoA:两种数据布局
          • 5.2 列式存储为什么适合 OLAP
          • 5.3 字段访问模式与重排
        • 06.跨语言对照与Disruptor案例
          • 6.1 C/C++:手动战场
          • 6.2 Java:JVM 帮你管,但你要懂
          • 6.3 Go:自动重排
          • 6.4 Rust:精确控制 + 安全
          • 6.5 Disruptor:缓存对齐工业典范
        • 07.经典陷阱与生产级反模式
          • 7.1 陷阱一:忽略 sizeof 增长
          • 7.2 陷阱二:错误使用#pragma pack
          • 7.3 陷阱三:盲目"挨着放"
          • 7.4 陷阱四:填充过头
          • 7.5 陷阱五:JVM 字段重排干扰
          • 7.6 陷阱六:忽视 NUMA
          • 7.7 陷阱七:忽视Cache Line容量
        • 08.一句话总结
          • 8.1 三层认知阶梯
          • 8.2 七字真言
          • 8.3 与下篇的承接
        • 🔗 延伸阅读
      • 5.内存回收机制设计
      • 6.多种引用技术设计
      • 7.内存泄漏与诊断原理
      • 8.数据拷贝设计原理
    • 交互和系统

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 程序编程原理
  • 内存的真相
杨充
2026-05-14
目录

4.内存对齐与缓存局部性

# 4.4 内存对齐与缓存局部性

📍 本篇位置:第 4 卷 · 内存的真相 · 第 4 篇 🎯 核心矛盾:两段访问相同数据量的代码,仅仅遍历方向不同,性能差 10 倍;两个互不相干的变量,仅仅"挨得太近",多线程性能崩塌——为什么? 🧭 设计灵魂:CPU 不按字节读内存,按 Cache Line(64 字节) 读。所有写代码的姿势,最终都被 CPU 缓存这个"看不见的搬运工"塑造。让数据按 CPU 喜欢的方式排布——这是性能优化的"暗默基础" 🌐 跨语言覆盖:C 结构体 padding · Java @Contended · Go 字段重排 · Disruptor 缓存对齐 · LMAX/Aeron 高频系统设计 🔗 延伸阅读:← 4.3 堆和栈内存的设计 · → 4.5 内存回收机制设计 · → 4.2 内存模型技术设计


4.1-4.3 建立了内存的"骨架"——虚拟地址、内存模型、堆栈布局。但还有一个**性能层面的"暗默约束"**在不显眼地塑造一切——CPU 缓存。

同一段循环,仅仅把数组的访问方向从"行优先"改成"列优先",性能差 10 倍;两个互不相干的变量,仅仅因为"挨得太近",就让多线程性能崩塌。本篇揭开内存对齐、Cache Line、伪共享、空间/时间局部性背后的物理机制。

# 目录介绍

  • 00.真实事故引入
  • 01.CPU 缓存层级与"内存墙"
  • 02.Cache Line:缓存最小搬运单位
  • 03.内存对齐:硬件隐形税
  • 04.伪共享:多核隐形杀手
  • 05.数据布局优化的工程实践
  • 06.跨语言对照与Disruptor案例
  • 07.经典陷阱与生产级反模式
  • 08.一句话总结

# 00.真实事故引入

# 0.1 一行循环改方向8秒阥0.6秒

我曾在一个图像处理服务排查性能问题。核心代码非常普通——遍历一个 10000×10000 的二维数组:

// 版本 A:列优先
for (int j = 0; j < 10000; j++) {
    for (int i = 0; i < 10000; i++) {
        sum += arr[i][j];           // ⚠️ 注意 [i][j] 顺序
    }
}
1
2
3
4
5
6

测得耗时:8.2 秒。

新人疑惑:访问的元素总数是 1 亿,又不算多——CPU 怎么这么慢?

我让他换一行试试:

// 版本 B:行优先
for (int i = 0; i < 10000; i++) {
    for (int j = 0; j < 10000; j++) {
        sum += arr[i][j];           // ✓ 注意 [i][j] 顺序
    }
}
1
2
3
4
5
6

测得耗时:0.6 秒。

两个版本的"逻辑等价性"100%——都是访问同一个数组的所有元素。但性能差了 14 倍。

新人懵了——"我是不是哪里写错了?"

没写错。这就是 CPU Cache 的"隐形规则":

C 语言的二维数组按"行优先"存储
arr[0][0], arr[0][1], arr[0][2], ..., arr[0][9999], arr[1][0], ...
                                                           ↑ 这里地址跳了 40000 字节

版本 A 的访问顺序是:
arr[0][0] → arr[1][0] → arr[2][0] → ...
每次访问跳 40KB → 每次访问都 cache miss

版本 B 的访问顺序是:
arr[0][0] → arr[0][1] → arr[0][2] → ...
每次访问相邻位置 → 一次 cache 加载能 hit 16 次(64 字节 / 4 字节)
1
2
3
4
5
6
7
8
9
10
11

这位新人那一刻被震撼了——他写了 8 年代码,没意识到"数组遍历方向"竟然能差 14 倍。

# 0.2 两个long变量挨太近崩塌

另一个故事。LMAX 早期,他们写了个"看似正确"的高性能队列:

public class Queue {
    private long head;       // 生产者写
    private long tail;       // 消费者写
    
    public void produce(...) { head++; }
    public void consume(...) { tail++; }
}
1
2
3
4
5
6
7

预期:生产者和消费者操作不同变量,应该并行无干扰。

实测:双核 CPU 上吞吐量比单线程还低!

根因——head 和 tail 都是 8 字节,挨在一起总共 16 字节,全在同一个 64 字节 Cache Line 里。

CPU 0:写 head → 整条 Cache Line 标记为"脏"
       MESI 协议:通知 CPU 1 那条 Cache Line 失效
CPU 1:写 tail → 发现自己的 Cache Line 失效 → 重新从内存加载
       写完 → 标记为"脏" → 通知 CPU 0 失效
CPU 0:再写 head → ...

→ 两个 CPU 互相"打"对方的 Cache Line
→ 每次操作都伴随 Cache 失效和重新加载
→ 比单线程还慢!
1
2
3
4
5
6
7
8
9

这就是"伪共享(False Sharing)"——变量在"逻辑上"完全独立,但因为"物理上"挨太近,被 CPU 当成"共享"了。

LMAX 的解法——在 head 和 tail 之间填充 56 字节:

public class Queue {
    private long head;
    private long p1, p2, p3, p4, p5, p6, p7;   // ★ 7 个 long = 56 字节填充
    private long tail;
    private long p8, p9, p10, p11, p12, p13, p14;
}
1
2
3
4
5
6

修复后吞吐量飙升 10 倍——这就是 Disruptor 著名的"6M+ ops/s"性能的核心秘密之一。

# 0.3 灵魂三问

这两个事故让我反复追问:

  1. 为什么 CPU 缓存对软件几乎"透明"——但软件却被它如此严苛地约束? —— 它是隐形的,但代价是真实的
  2. 缓存的最小单位为什么是 64 字节?为什么不能"按需"读? —— 这个"硬件约束"如何反过来塑造数据结构设计
  3. 为什么"两个变量挨太近"反而是个问题? —— 这违反了"局部性原理"教给我们的所有直觉

# 0.4 五个层层递进的追问

要把"内存对齐和缓存"讲透,需要递进回答:

  1. CPU 为什么需要缓存? —— 内存墙问题的物理本质
  2. Cache Line 是什么? —— 缓存运作的物理单位
  3. 内存对齐为什么强制要求? —— 硬件实现的真实代价
  4. 多核时代发生了什么质变? —— MESI 协议如何改写了"独立变量"的概念
  5. 怎么写"缓存友好"的代码? —— 从 AoS/SoA 到 Disruptor 的工程实践

# 0.5 探索路径

flowchart LR
    A[CPU 与内存的速度差] --> B[多级缓存]
    B --> C[Cache Line 64 字节]
    C --> D[空间/时间局部性]
    D --> E[内存对齐]
    E --> F[多核 MESI]
    F --> G[伪共享]
    G --> H[数据布局优化]
    
    style C fill:#cfe2ff
    style G fill:#f8d7da
    style H fill:#d4edda
1
2
3
4
5
6
7
8
9
10
11
12

# 0.6 为什么这个问题值得讲透

我想抛三个问题:

  1. 为什么"内存对齐"这种 1970 年代提出的概念,到 2024 年依然是性能核心? —— 因为硬件的物理边界没变。
  2. 为什么 Disruptor、Aeron、LMAX 这些"金融级"高性能系统,把"缓存对齐"放在和"算法"同等重要的地位? —— 因为算法决定上限,缓存决定下限。
  3. 为什么大多数"性能优化"只优化算法,不优化数据布局? —— 因为数据布局是"暗默"的——它不影响功能,只影响性能。

读完本章你会懂:写"高性能代码"的本质,是"配合 CPU 而不是对抗 CPU"——而要配合,先得理解 CPU 在搬什么、怎么搬。


# 01.CPU 缓存层级与"内存墙"

# 1.1 速度的悬殊鸿沟

现代 CPU 和内存的速度差:

寄存器:       0.3 ns(1 周期)
L1 缓存:       1 ns(3 周期)
L2 缓存:       3 ns(10 周期)
L3 缓存:       10 ns(40 周期)
主内存:        100 ns(300+ 周期)
SSD(NVMe):   100 μs(300,000 周期!)
HDD:          10 ms(30,000,000 周期!)
1
2
3
4
5
6
7

这就是计算机工程的"残酷真相"——CPU 比内存快 100 倍,每等一次内存就浪费 300 个指令周期。

flowchart LR
    R[寄存器<br/>1 周期] --> L1[L1<br/>3 周期]
    L1 --> L2[L2<br/>10 周期]
    L2 --> L3[L3<br/>40 周期]
    L3 --> MEM[主内存<br/>300 周期]
    
    style R fill:#d4edda
    style L1 fill:#cfe2ff
    style MEM fill:#f8d7da
1
2
3
4
5
6
7
8
9

# 1.2 内存墙(Memory Wall)

David Patterson 1995 年提出内存墙概念:

CPU 速度每年提升 60%(摩尔定律)
内存速度每年仅提升 7%
两者差距越拉越大——CPU 越来越多时间在"等内存"
1
2
3

这就是为什么需要多级缓存——把"常用数据"放在 CPU 旁边,避免每次都跑去主内存。

# 1.3 缓存的两条黄金法则

CPU 缓存能起效,依赖两个"局部性原理":

1. 时间局部性(Temporal Locality)

最近访问过的数据,很快会再次访问
→ 缓存最近用过的数据
1
2

例:循环里反复访问的变量、栈顶的局部变量。

2. 空间局部性(Spatial Locality)

访问某地址时,附近的地址很快也会被访问
→ 一次加载一整段(不只是一个字节)
1
2

例:数组遍历、结构体访问。

这两条法则是缓存设计的"宪法"——所有 Cache Line、预取(prefetching)、替换策略,都源自它们。

# 1.4 现代 CPU 的缓存层级

flowchart TB
    CORE0[Core 0] --> L1D0[L1d 32KB]
    CORE0 --> L1I0[L1i 32KB]
    L1D0 --> L20[L2 256KB]
    L1I0 --> L20
    
    CORE1[Core 1] --> L1D1[L1d 32KB]
    CORE1 --> L1I1[L1i 32KB]
    L1D1 --> L21[L2 256KB]
    L1I1 --> L21
    
    L20 --> L3[L3 共享 8-30 MB]
    L21 --> L3
    L3 --> MEM[主内存]
    
    style L3 fill:#cfe2ff
    style MEM fill:#fff3cd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

关键观察:

L1/L2 是"私有"的——每个核独立
L3 是"共享"的——所有核共用
L1 数据/指令分离(哈佛架构思想)

→ 多核间共享数据必须经过 L3 或更慢
→ 这就是 CPU 间通信的"成本来源"
1
2
3
4
5
6

# 02.Cache Line:缓存最小搬运单位

# 2.1 为什么是 64 字节

§0.4 第二题。Cache Line(缓存行)是缓存读写的最小单位——在 x86_64、ARM64 上都是 64 字节。

为什么是 64?

太小(如 16 字节):
  → 元数据(tag、状态位)相对开销大
  → 不能充分利用空间局部性
  
太大(如 256 字节):
  → 加载耗时长(每次都要搬一大块)
  → 多核共享冲突概率大(伪共享)
  → 缓存命中粒度太粗
  
64 字节是 1990 年代权衡后的"魔数"——一直沿用至今
1
2
3
4
5
6
7
8
9
10

# 2.2 一次访问 = 一整行加载

char arr[1024];
char x = arr[0];   // 看似只读 1 字节
1
2

实际:

CPU 检查 arr[0] 在不在 L1 → 不在
→ 从主内存加载 arr[0]~arr[63] 共 64 字节到 L1
→ 返回 arr[0]
1
2
3

所以:

char x = arr[0];   // L1 miss,加载 arr[0..63]
char y = arr[1];   // L1 hit!(arr[1] 已在 Cache Line 里)
char z = arr[63];  // L1 hit!
char w = arr[64];  // L1 miss!(下一个 Cache Line)
1
2
3
4

# 2.3 §0.1答案:行优先vs列优先

回到§0.1 的二维数组问题:

// 数组在内存中的实际布局(行优先):
//   arr[0][0]  arr[0][1]  ...  arr[0][9999]   ← 同一行连续 40000 字节
//   arr[1][0]  arr[1][1]  ...
1
2
3

版本 A(列优先访问):

访问 arr[0][0] → 加载 arr[0][0..15] 到 Cache(16 个 int = 64 字节)
访问 arr[1][0] → arr[1][0] 不在 Cache → 加载 arr[1][0..15]
访问 arr[2][0] → 同样 miss → 加载 arr[2][0..15]
...

→ 每次访问都 miss,每次加载 64 字节但只用 4 字节
→ 实际利用率 4/64 = 6.25%
→ 慢得不可救药
1
2
3
4
5
6
7
8

版本 B(行优先访问):

访问 arr[0][0] → 加载 arr[0][0..15]
访问 arr[0][1] → hit
访问 arr[0][2] → hit
...
访问 arr[0][16] → miss,加载下一组

→ 16 次访问只 1 次 miss
→ 利用率接近 100%
1
2
3
4
5
6
7
8

这就是§0.1 性能差 14 倍的根本原因——违反了空间局部性。

# 2.4 Cache Line 的物理结构

┌─────────────────────────────────────┐
│   Tag(高位地址)│  数据(64 字节)  │
└─────────────────────────────────────┘
   ↑ 用来识别"这条 Line 装的是哪段内存"
1
2
3
4

Cache 查找的物理过程:

虚拟地址 0x12345678 → 翻译后物理地址 0x9A000080
拆分:
  0x9A000080 >> 6 = 0x26800002    ← Cache Line 编号
  0x80 & 0x3F = 0x00              ← Cache Line 内偏移

到 L1 找编号 0x26800002 的 Line:
  找到 → hit
  没找到 → miss → 去 L2 → ...
1
2
3
4
5
6
7
8

# 2.5 缓存替换策略

L1 容量有限(32KB → 512 条 Line)——满了怎么办?

LRU(Least Recently Used):淘汰最近最少用的
LFU(Least Frequently Used):淘汰最少访问的
随机:直接随便挑一条扔掉
1
2
3

实际硬件用的是 LRU 的近似算法——精确 LRU 太贵,用伪 LRU 或 NRU。


# 03.内存对齐:硬件隐形税

# 3.1 4字节int必从4倍地址起

struct Bad {
    char a;        // 1 字节
    int b;         // 4 字节 ← 必须从 4 字节对齐的地址开始
    char c;        // 1 字节
};

sizeof(Bad) = 12   // ⚠️ 不是 1+4+1=6
1
2
3
4
5
6
7

实际内存布局:

偏移:  0  1  2  3  4  5  6  7  8  9 10 11
值:   [a][p][p][p][b][b][b][b][c][p][p][p]
        ↑     padding ↑           ↑ padding ↑
       
a:偏移 0
b:偏移 4(前面填 3 字节 padding 让 b 4 字节对齐)
c:偏移 8
末尾:补到 12(让结构体整体 4 字节对齐)
1
2
3
4
5
6
7
8

# 3.2 为何必须对齐:硬件代价

§0.4 第三题。为什么硬件强制对齐?

朴素疑问:CPU 不就是按字节读吗?

真相:CPU 按 Cache Line(64 字节)读,但内存总线一次传输是 8/16/32/64 字节——必须从总线宽度的整数倍地址开始。

读 4 字节 int:
  对齐情况:一次内存事务搞定 ✓
  不对齐情况:跨越两条 Cache Line → 两次内存事务 → 慢一倍 ✗
  极端情况(x86 旧版):硬件错误(trap)
1
2
3
4

ARM 的策略:早期 ARM 不允许未对齐访问,会直接 bus error 崩溃。现代 ARM 允许,但有性能代价。

x86 的策略:允许,但隐藏代价——CPU 内部多做几次访问。

# 3.3 字段重排:用顺序换空间

把上面的Bad重排:

struct Good {
    int b;         // 4 字节
    char a;        // 1 字节
    char c;        // 1 字节
    // padding 2 字节
};

sizeof(Good) = 8   // ✓ 节省 4 字节
1
2
3
4
5
6
7
8

铁律:把大字段放前面,小字段放后面——减少 padding。

适用范围:

Java:HotSpot 自动重排(按字段大小降序)
Go:自动重排
C/C++/Rust:必须手动重排!(不会自动)
1
2
3

这是 Rust/C 程序员日常的优化——尤其在嵌入式、内核、网络协议领域。

# 3.4 §0.6第一题:1970代仍是核心

原因:硬件总线的物理边界没变
1970 年代:32 位总线 → 4 字节对齐
2024 年:64/128/256 字节 SIMD → 更严格的对齐
即将到来:512 位 AVX → 64 字节对齐才能用满

→ 硬件越来越快,对齐要求越来越严,永远不会过时
1
2
3
4
5
6

# 3.5 高级对齐:SIMD 指令

__m256i v = _mm256_load_si256(...);   // ⚠️ 必须 32 字节对齐
__m256i v = _mm256_loadu_si256(...);  // u 表示 unaligned,慢但能跑
1
2

SIMD 时代的对齐要求:

指令 对齐要求
SSE 16 字节
AVX2 32 字节
AVX-512 64 字节

未对齐的代价:从硬件错误(崩溃)到 2 倍延迟(看 CPU)。


# 04.伪共享:多核隐形杀手

# 4.1 两个无关变量互相拖慢

回到§0.2 的故事。两个 long 字段在同一 Cache Line 里——MESI 协议让它们"被迫共享"。

# 4.2 MESI协议:Cache Line状态机

stateDiagram-v2
    [*] --> Invalid
    Invalid --> Exclusive: 我独占读
    Invalid --> Shared: 别人也有
    Exclusive --> Modified: 我写了
    Modified --> Shared: 别人来读
    Shared --> Modified: 我写了(其他副本失效)
    Modified --> Invalid: 别人写了
    Shared --> Invalid: 别人写了
    Exclusive --> Invalid: 别人写了
1
2
3
4
5
6
7
8
9
10

MESI 四态:

状态 含义
Modified 当前 CPU 改过,且只我有
Exclusive 当前 CPU 独占,但没改过
Shared 多个 CPU 都有副本,没改过
Invalid 失效

# 4.3 伪共享的物理过程

Cache Line:[head][tail]    (一条 Line 里有两个独立变量)

CPU 0:写 head
  L1 当前状态:S
  → 转到 M(其他 CPU 的副本被通知失效)
  → 通过总线发"失效"消息给 CPU 1

CPU 1:写 tail(但 tail 在同一条 Line!)
  L1 当前状态:I(被 CPU 0 上一步失效了)
  → 必须从主内存或 L3 重新读这条 Line
  → 慢!
  → 读完转到 M,通知 CPU 0 失效

CPU 0:再写 head
  → 同样的循环,重新加载 → ...

→ 两个 CPU 像"乒乓球"一样把同一条 Line 推来推去
→ 性能比单线程还差
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这就是"伪共享"的杀伤力——它隐形、致命、让多线程"反向优化"。

# 4.4 解决方案:填充

方案一:手动填充

public class Queue {
    private long head;
    private long p1, p2, p3, p4, p5, p6, p7;   // 7×8=56 字节
    // 现在 head 独占一条 Cache Line
    
    private long tail;
    private long p8, p9, p10, p11, p12, p13, p14;
    // tail 独占下一条 Cache Line
}
1
2
3
4
5
6
7
8
9

方案二:JVM 注解(Java 8+)

@sun.misc.Contended
public class Queue {
    private long head;
    private long tail;
}

// 启动 JVM:-XX:-RestrictContended
1
2
3
4
5
6
7

@Contended 让 JVM 自动给字段加 128 字节填充。

方案三:C 语言 alignas

struct Queue {
    alignas(64) long head;   // 强制 head 单独一条 Cache Line
    alignas(64) long tail;
};
1
2
3
4

方案四:Rust crossbeam

use crossbeam_utils::CachePadded;

struct Queue {
    head: CachePadded<AtomicU64>,
    tail: CachePadded<AtomicU64>,
}
1
2
3
4
5
6

# 4.5 §0.3 第三题答案

为什么"两个变量挨太近"反而是问题?

因为 MESI 协议把"Cache Line"当作共享单位——而不是"变量"。变量逻辑上独立,物理上挨着,对 CPU 来说就是"共享"。

这就是软件世界的"塞翁失马"——空间局部性是优点(同 Cache Line 减少 miss),但在多核场景下变成缺点(伪共享放大冲突)。


# 05.数据布局优化的工程实践

# 5.1 AoS vs SoA:两种数据布局

AoS(Array of Structures,结构体数组):

struct Point { float x, y, z; };
Point arr[1000];

for (int i = 0; i < 1000; i++)
    arr[i].x *= 2;
1
2
3
4
5

内存布局:

[x0 y0 z0][x1 y1 z1][x2 y2 z2]...
1

遍历"x"时:

读 arr[0].x → 加载 64 字节 → 包含 5 个 Point(约)
读 arr[1].x → hit
...

但每条 Cache Line 里 1/3 是 x,2/3 是 y/z(用不上)
→ Cache 利用率 33%
1
2
3
4
5
6

SoA(Structure of Arrays,数组的结构体):

struct Points {
    float x[1000];
    float y[1000];
    float z[1000];
};

for (int i = 0; i < 1000; i++)
    p.x[i] *= 2;
1
2
3
4
5
6
7
8

内存布局:

x[0..999] 连续...
y[0..999] 连续...
z[0..999] 连续...
1
2
3

遍历"x"时:

读 x[0] → 加载 64 字节 → 包含 16 个 x
利用率 100%
1
2

结论:

场景 选哪个
同时访问一个对象的多个字段(如游戏:渲染一个 Point 要 x/y/z) AoS
只访问某个字段(如 ML:批量乘 x) SoA
SIMD 计算(要求字段连续) SoA

游戏引擎的演进:

2000 年代:AoS 主流(Object 思维)
2010 年代:SoA 兴起(Data-Oriented Design)
2020 年代:ECS 架构(Entity-Component-System)—— SoA 的极致
1
2
3

# 5.2 列式存储为什么适合 OLAP

SELECT AVG(price) FROM orders WHERE region = 'US';
1

行式存储(传统数据库):

[id1, region1, price1, time1][id2, region2, price2, time2]...
1

要算 price 的平均,每行要"跳过"id/region/time——大量无效读取。

列式存储(ClickHouse、Parquet):

id:       [1, 2, 3, ..., 1000]
region:   ['US', 'CN', 'US', ...]
price:    [99, 88, 77, ...]
time:     [...]
1
2
3
4

优势:

1. 只读 region 和 price 列——不浪费 I/O
2. 同一列数据类型相同 → SIMD 加速
3. 同一列值分布相似 → 压缩率更高(10-100 倍)
1
2
3

这是 OLAP 数据库性能比 OLTP 快 100 倍的核心原因。

# 5.3 字段访问模式与重排

业务规律告诉你"哪些字段经常一起访问"——把它们放一起:

struct User {
    // 经常一起访问的字段
    int id;
    int status;
    long last_active_time;
    
    // 经常一起访问的字段
    char username[32];
    char email[64];
    
    // 很少访问的字段
    char address[128];
    char bio[1024];
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

优化:把"热字段"放在前面(同一 Cache Line),"冷字段"放后面。

Linux 内核的实践——__read_mostly、__write_mostly 注解:

int sysctl_tcp_window __read_mostly;     // 几乎只读 → 放只读区
int sysctl_tcp_counter __write_mostly;   // 频繁写 → 单独 Line
1
2

# 06.跨语言对照与Disruptor案例

# 6.1 C/C++:手动战场

// alignof / alignas 控制对齐
struct alignas(64) CacheAligned {
    int x;
};

// pragma pack 强制紧凑(去除 padding)
#pragma pack(1)
struct Tight { char a; int b; };   // sizeof=5 但访问慢
#pragma pack()
1
2
3
4
5
6
7
8
9

# 6.2 Java:JVM 帮你管,但你要懂

// HotSpot 的字段重排(自动)
class Order {
    int id;
    long timestamp;
    byte status;
}
// JVM 重排为 timestamp 在前

// 强制不重排
class Order {
    @Contended int counter;
}
1
2
3
4
5
6
7
8
9
10
11
12

Java 对象头:

对象头:12-16 字节(mark word + klass pointer)
字段紧随其后
末尾对齐到 8 字节
1
2
3

这就是为什么"小对象"在 Java 里很贵——对象头开销太大。

# 6.3 Go:自动重排

type Bad struct {
    a bool      // 1 byte
    b int64     // 8 bytes
    c bool      // 1 byte
}
// Go 自动按字段大小降序重排
// 实际布局:b (8), a (1), c (1), padding (6) = 16 bytes

type Good struct {
    b int64
    a bool
    c bool
}
// 同样 16 bytes,但显式更清晰
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Go 的 unsafe.Sizeof 可以验证:

fmt.Println(unsafe.Sizeof(Bad{}))   // 16(自动重排后)
1

# 6.4 Rust:精确控制 + 安全

#[repr(C)]
struct Layout1 { ... }              // 严格按声明顺序

#[repr(packed)]
struct Layout2 { ... }              // 无 padding

#[repr(align(64))]
struct CachePadded { ... }          // 64 字节对齐
1
2
3
4
5
6
7
8

# 6.5 Disruptor:缓存对齐工业典范

LMAX Disruptor 是金融行业最著名的高性能队列,能做到 每秒 600 万消息。它的核心秘密——每个变量都做缓存对齐:

public final class Sequence extends RhsPadding {
    // 通过继承的方式做缓存填充
}

class LhsPadding {
    protected long p1, p2, p3, p4, p5, p6, p7;  // 前置填充 56 字节
}

class Value extends LhsPadding {
    protected volatile long value;              // 实际数据 8 字节
}

class RhsPadding extends Value {
    protected long p9, p10, p11, p12, p13, p14, p15;  // 后置填充
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

为什么用继承做填充?

JVM 的字段重排会把同类的字段放一起
直接在同一个 class 里写填充字段——可能被 JVM 重排到一起
通过继承——JVM 必须按继承层次布局
→ 强制保证填充紧贴 value
1
2
3
4

Disruptor 的全方位优化:

1. 缓存对齐(Sequence 类)
2. 无锁(CAS + memory barrier)
3. 预分配(环形数组)
4. 单生产者优化(更激进的 memory order)
5. 批量消费(减少 cache miss)
1
2
3
4
5

这就是§0.6 第二题的答案——金融级系统把缓存对齐放在和算法同等重要的位置,因为当算法已经最优时,唯一能再提升性能的就是数据布局。


# 07.经典陷阱与生产级反模式

# 7.1 陷阱一:忽略 sizeof 增长

struct Foo {
    char a;
    void* b;     // 8 字节,需要 8 字节对齐
    char c;
};
// sizeof = 24(不是 1+8+1=10)

// 千万级实例时——浪费 14*10000000 = 140MB
1
2
3
4
5
6
7
8

修复:重排字段。

# 7.2 陷阱二:错误使用#pragma pack

#pragma pack(1)
struct Packet {
    char type;
    int length;
};
#pragma pack()

// 节省了 3 字节空间
// 但每次访问 length → unaligned access → 慢
1
2
3
4
5
6
7
8
9

适用:网络协议、文件格式(必须紧凑)。 不适用:内存中频繁访问的数据结构。

# 7.3 陷阱三:盲目"挨着放"

// ❌ 想着"反正都常用,挨着放节省 cache"
struct Counter {
    int read_count;
    int write_count;
};

// 多线程下 → 伪共享灾难
1
2
3
4
5
6
7

修复:填充隔离。

# 7.4 陷阱四:填充过头

// ❌ 每个变量都填充
struct Over {
    alignas(64) int a;
    alignas(64) int b;
    alignas(64) int c;
    alignas(64) int d;
};
// 一个 16 字节的逻辑数据 → 占 256 字节物理空间!
1
2
3
4
5
6
7
8

只对真正"多线程并发写"的字段填充,单线程访问没必要。

# 7.5 陷阱五:JVM 字段重排干扰

class A {
    long timestamp;
    // 假设这里加了填充
    long p1, p2, p3, p4, p5, p6, p7;
    long counter;
}
// JVM 可能把 p1-p7 重排到 timestamp 和 counter 中间——但不保证!
1
2
3
4
5
6
7

正确方式:用 @Contended 或继承方式(Disruptor 风格)。

# 7.6 陷阱六:忽视 NUMA

现代多 socket 服务器:

NUMA Node 0: CPU 0-15, RAM 0
NUMA Node 1: CPU 16-31, RAM 1

CPU 0 访问 RAM 0:本地,快
CPU 0 访问 RAM 1:跨 NUMA,慢 2-3 倍
1
2
3
4
5

陷阱:线程在 Node 0 启动 → 内存分配在 Node 0 → 调度器把线程迁到 Node 1 → 后续访问全跨 NUMA。

修复:

# 绑定线程到 NUMA Node
numactl --cpunodebind=0 --membind=0 ./myapp

# Java:-XX:+UseNUMA
1
2
3
4

# 7.7 陷阱七:忽视Cache Line容量

class Heavy {
    long a, b, c, d;       // 32 字节
    long e, f, g, h;       // 又 32 字节,跨 Cache Line
}
1
2
3
4

意识到:64 字节是个硬上限——超过就是两条 Cache Line。访问"最后一个字段"和"第一个字段"是不同 cost。


# 08.一句话总结

# 8.1 三层认知阶梯

第一层(知其然):知道有 Cache Line、知道要对齐
  ↓
第二层(知其所以然):理解 MESI 协议、空间/时间局部性、伪共享原理
  ↓
第三层(知其将所以然):能根据访问模式设计 AoS/SoA、用填充消除伪共享、
                       懂 NUMA 调优、能解读 perf cache-misses
1
2
3
4
5
6

读完本章后,你应该能回答开头§0.3 提出的三个问题:

  1. CPU 缓存对软件透明,为什么软件被它如此严苛约束? → 因为缓存无形地决定了"每次内存访问"的成本——访问模式直接决定性能上下限。
  2. 为什么 64 字节? → 1990 年代权衡空间局部性收益和总线/失效开销后的"魔数"。
  3. 为什么"挨太近"是问题? → 因为 MESI 把 Cache Line 当作共享单位,逻辑独立的变量在同一 Line 时被迫物理共享。

# 8.2 七字真言

  1. CPU 按 Cache Line 读——不是按字节。
  2. 顺序访问比跳跃快——空间局部性。
  3. 大字段在前,小字段在后——减少 padding。
  4. 多线程写的变量隔离 Cache Line——避免伪共享。
  5. OLAP 用列存——访问模式决定布局。
  6. NUMA 要绑定——跨 Node 访问慢 2-3 倍。
  7. 用 perf 验证——别凭直觉优化。

# 8.3 与下篇的承接

至此我们走过了内存布局的"硬件约束"——4.1 虚拟地址 / 4.2 内存模型 / 4.3 堆栈 / 4.4 缓存对齐。它们是程序员"看得见或看不见"的物理边界。

下一篇 4.5 内存回收机制设计 我们要进入**"内存的生命周期"**——分配出去的内存怎么回收?GC 的设计哲学是什么?这是软件层最复杂的工程问题之一。


# 🔗 延伸阅读

  • 同卷上篇:4.3 堆和栈内存的设计
  • 同卷下篇:4.5 内存回收机制设计
  • 同卷相关:4.2 内存模型技术设计(MESI 协议的并发视角)
  • 经典文献:
    • What Every Programmer Should Know About Memory(Ulrich Drepper, 2007)—— 内存设计的圣经,至今最权威的资料
    • Computer Architecture: A Quantitative Approach(Hennessy & Patterson)—— 第 2 章缓存系统
    • The LMAX Architecture(Martin Fowler)—— Disruptor 设计哲学
    • Designing Data-Intensive Applications(Martin Kleppmann)—— 列式存储章节
    • Mechanical Sympathy(Martin Thompson 博客)—— 高频交易系统设计
    • Data-Oriented Design(Richard Fabian)—— 游戏引擎方向
上次更新: 2026/06/07, 10:26:12
3.堆和栈内存的设计
5.内存回收机制设计

← 3.堆和栈内存的设计 5.内存回收机制设计→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式