专栏文章
专栏文章
计算机原理专栏
1. 计算机原理专栏 #01:CPU 与指令执行 2. 计算机原理专栏 #02:存储体系 3. 计算机原理专栏 #03:IO 与总线 4. 计算机原理专栏 #04:内存模型

计算机原理专栏 #02:存储体系

发布于 2026-06-05 06:26 👁 13 次阅读
#computer-architecture#hardware

存储体系

本文从存储器层次结构出发,逐层深入讲解 Cache 工作原理、替换策略、多核一致性(MESI)、伪共享、内存对齐、虚拟内存与 TLB,最后延伸到 NUMA 架构。重点关注这些底层机制对 Java 并发编程、JVM 调优的实际影响。


目录

章节 说明
存储器层次结构 寄存器到磁盘的完整金字塔
Cache 工作原理 直接映射/组相联/全相联
Cache 替换策略 LRU/LFU/随机替换
多核缓存一致性:MESI 写失效协议与状态机
伪共享(False Sharing) 性能杀手与解决方案
内存对齐 为什么要对齐,对性能的影响
虚拟内存 页表/TLB/缺页中断
NUMA 架构 多路服务器的内存访问模型
程序员视角的内存管理 栈与堆、GC、内存 Bug、Java 内存模型

存储器层次结构

memory hierarchy

存储器按速度从快到慢、容量从小到大构成层次结构,每层只与相邻层交互:

存储器 芯片类型 特点
寄存器 触发器 最快,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  ]

访问流程:

  1. 用 Index 定位 Cache Line
  2. 检查有效位(Valid Bit)是否为 1
  3. 比较 Tag,确认是否命中
  4. 用 Offset 读取具体字节

缺点:不同内存块可能映射到同一 Cache Line,频繁互相驱逐(Cache 抖动)。

组相联 Cache(Set Associative Cache)

将 Cache 分成多个组(Set),每组有 N 路(N-Way),内存块映射到固定的组,但可以放在组内任意一路。

现代 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(已失效) 数据无效,不可使用

关键状态流转

MESI 协议与多线程编程中的读写锁思想高度相似:S 状态对应多读,M 状态对应独占写。

Store Buffer 与内存屏障

为什么需要 Store Buffer

严格遵守 MESI 协议时,CPU 写一个处于 Shared 状态的缓存行,必须:

  1. 发出 Invalidate 消息给所有持有该行的其他核
  2. 等待所有核回复 Acknowledgement
  3. 才能真正写入

这个等待过程耗时极长(缓存行跨核传输比寄存器操作慢几个数量级),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);           // ④
}                           }

可能的执行序列:

  1. CPU0 执行 a=1:a 不在 CPU0 的 Cache,放入 Store Buffer,发出 Read Invalidate 消息
  2. CPU1 执行 while(b==0):b 不在 CPU1 的 Cache,发出 Read 消息
  3. CPU0 执行 b=1:b 已在 CPU0 的 Cache(Exclusive),直接写入,b=1 立即对外可见
  4. CPU0 把含新 b 值的 Cache Line 发给 CPU1
  5. CPU1 读到 b=1,退出循环,执行 assert(a==1)
  6. 此时 CPU1 Cache 里 a 还是旧值 0 → 断言失败!
  7. (CPU0 的 Read Invalidate Ack 迟迟未到,a=1 还困在 Store Buffer 里)

根本原因a=1b=1 在 CPU0 的视角是顺序的,但 b=1a=1 更早对其他核可见(因为 b 直接写 Cache,而 a 要等 Ack)。

内存屏障:冲刷 Store Buffer

写屏障(Store Barrier / smp_wmb):CPU0 在 a=1b=1 之间插入写屏障,效果是:

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 之间插入读屏障,效果是:

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 前缀的硬件语义:

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) 的工作原理:

这种方式叫 LL/SC(Load-Linked / Store-Conditional),比 x86 的 LOCK 更灵活,但需要软件处理重试逻辑。

CAS 与内存屏障的关系

CAS 本身保证了操作的原子性,但不自动提供内存可见性。Java 的 compareAndSet 语义是:

CAS 成功 = Acquire + Release 语义(相当于全屏障)

底层实现:

; 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 字节边界

对齐规则

对 Java 的影响

JVM 自动处理对象内部字段的对齐,但开发者需注意:


虚拟内存

为什么需要虚拟内存

  1. 内存保护:进程之间内存隔离,防止相互踩踏
  2. 地址空间扩展:程序可以使用比物理内存更大的地址空间
  3. 内存共享:多个进程可以映射同一物理内存(如共享库)

页表(Page Table)

虚拟内存按页(Page) 划分,通常 4KB 一页。页表记录虚拟页号到物理页号的映射:

虚拟地址 = [虚拟页号 | 页内偏移量]
                ↓ 查页表
物理地址 = [物理页号 | 页内偏移量]

多级页表:Linux x86-64 使用 4 级页表,避免为每个进程分配完整的单级页表(32 位单级页表需 4MB/进程)。多级页表利用虚拟地址空间的稀疏性,大幅节省内存,代价是地址转换需要多次内存访问。

TLB(Translation Lookaside Buffer)

TLB 是页表的硬件缓存,存放最近使用的虚拟页号→物理页号映射。

对性能的影响:频繁访问大量不连续内存页会导致 TLB 抖动(TLB Thrashing),性能骤降。

缺页中断(Page Fault)

访问的虚拟页不在物理内存中时触发缺页中断,OS 从磁盘加载对应页面到内存(Swap In),代价极高(~10ms)。

对 Java 的影响


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                 // 返回

栈分配的性能优势

Stack Overflow 成因

// 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 时回收。

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 与内存碎片

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
内存屏障 volatilesynchronizedUnsafe.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 感知分配。


参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论