第三篇:CPU缓存——为什么有时候改了一行代码,性能差了百倍
从一个让人困惑的测试开始
publicclassCacheDemo{publicstaticvoidmain(String[]args){int[][]arr=newint[10000][10000];// 测试1:按行遍历longstart1=System.nanoTime();for(inti=0;i<10000;i++){for(intj=0;j<10000;j++){arr[i][j]=1;// 先走列,再走行}}longtime1=System.nanoTime()-start1;// 测试2:按列遍历longstart2=System.nanoTime();for(intj=0;j<10000;j++){for(inti=0;i<10000;i++){arr[i][j]=1;// 先走行,再走列}}longtime2=System.nanoTime()-start2;System.out.println("按行遍历: "+time1/1000000+"ms");System.out.println("按列遍历: "+time2/1000000+"ms");// 输出结果:按行遍历约20ms,按列遍历约300ms// 同样的计算量,差了15倍!}}两个测试访问的是同一个二维数组,计算量完全一样——都是一亿次赋值。但按行遍历只需要20毫秒,按列遍历却要300毫秒。
为什么?答案就在CPU缓存里。
一、缓存是什么——CPU和内存之间的"快捷中转站"
在上一篇文章中,我们讲了内存的分层结构。现在我们把镜头拉近,聚焦在CPU缓存这一层。
主内存很大(几十GB),但离CPU很远——访问一次约60-100纳秒。CPU的寄存器极快(<1纳秒),但容量极小(几十个字节)。如果CPU每次都从主内存取数据,等待时间会是计算时间的几百倍。这就像你在一个巨型图书馆里查一本书,每次都要跑到最远端的书架——大部分时间花在走路上,而不是看书上。
CPU缓存就是为了解决这个问题而设计的。它夹在寄存器和主内存之间,容量比寄存器大很多(几十KB到几MB),但速度依然极快(几纳秒)。CPU把最近访问过的数据从主内存复制到缓存里,下次再用时直接从缓存取,不用再跑一趟主内存。
CPU核心的速度视角: 寄存器(<1ns) ──→ L1缓存(~1ns)──→ L2缓存(~3ns) ──→ L3缓存(~12ns) ↓ 主内存(~60ns)L1缓存的容量只有32KB,访问一次要1纳秒。主内存容量几十GB,访问一次却要60纳秒。如果你的数据在L1缓存里,CPU几乎可以全速运行。如果数据每次都要去主内存拿,CPU大多数时间都在等待——这就是按列遍历比按行遍历慢15倍的根源。
二、缓存行——CPU读数据的最小单位
疑问:CPU不是按字节从缓存里读数据吗?为什么一个二维数组的遍历会有速度差异?
回答:因为CPU不是按字节读的,而是按"缓存行"读的——一次读64个连续的字节(在大多数x86处理器上)。
缓存行是什么?
CPU从主内存取数据时,不是只取你当前需要的那个字节,而是把以这个字节为基准、往后连续64个字节的空间全都一次性搬进缓存。这个64字节的连续数据块就是"缓存行"。
为什么这么做?因为计算中有一个极强的规律——“时间局部性"和"空间局部性”。时间局部性是指刚才访问过的数据,短时间内大概率还会再被访问(比如循环里的计数器)。空间局部性是指访问了一个字节,它附近的字节也很快会被访问(比如数组遍历时,下一个元素就在相邻的位置)。每次读64个字节,是在为后续的连续访问提前预取——这64个字节中的后续部分大概率很快会被用到,省掉了再次去主内存取数据的开销。
回到二维数组的例子
Java的二维数组在内存中的实际布局是这样的:arr[0][0], arr[0][1], arr[0][2], ..., arr[0][9999], arr[1][0], arr[1][1], ...——按行连续存储。
按行遍历时,访问顺序是arr[0][0] → arr[0][1] → arr[0][2] → ...——正好是内存中的连续地址。每次读一个int(4字节),它后面的60字节(15个int)也都在同一个缓存行里,被一起带进了缓存。下一次访问下一个元素时,直接从L1缓存取——约1纳秒。
按列遍历时,访问顺序是arr[0][0] → arr[1][0] → arr[2][0] → ...——这些元素在内存中相隔了一整行(10000个int = 40000字节)。每次访问都在不同的缓存行里,每次都要去主内存取新数据——约60纳秒。
同样的计算量,一次访问1纳秒(缓存命中),一次访问60纳秒(缓存未命中)。差了60倍。再加上其他因素,实际差距约15倍——这就是你看到的300ms vs 20ms的根源。
三、缓存一致性——多核CPU下的数据同步问题
在前面的文章中我们讲过:操作系统在多个线程间快速切换,让它们看起来在同时运行。但在真正的多核CPU上,多个核心可以真正同时执行各自的指令。每个CPU核心都有自己的L1和L2缓存,L3是共享的。这就带来了一个严重的问题:
如果核心A修改了它L1缓存中的某个变量,核心B的L1缓存里还存着这个变量的旧值——核心B并不知道核心A已经改了数据。不同核心的缓存间存在不一致。
多线程共享变量的真正挑战就在于此——不只是"谁先执行"的问题,还有"谁的缓存里是什么版本"的问题。这就是为什么需要volatile和synchronized——它们不仅控制代码执行的先后顺序,更重要的是强制执行缓存的同步(缓存一致性协议),确保一个核心写入后其他核心看到的是最新值。
MESI协议让不同核心的缓存保持同步
现代CPU使用一种缓存一致性协议(最经典的是MESI协议)来自动管理不同核心的缓存。MESI协议将每个缓存行的状态标记为四种之一:
| 状态 | 含义 | 可以做什么 |
|---|---|---|
| M(Modified) | 只有这个核心有,而且被改过了 | 可以读写;需要写回主内存 |
| E(Exclusive) | 只有这个核心有,内容和主内存一致 | 可以读写;随时可以变成M |
| S(Shared) | 多个核心共享,内容和主内存一致 | 只能读;如果这个核心要写,需要先通知其他核心失效 |
| I(Invalid) | 这个缓存行已失效 | 不能访问;需要从主内存或其他核心处重新加载 |
整个过程自动完成,程序员不需要干预。CPU通过总线发送消息控制各个核心的缓存行状态转换——“我要写这个变量,你们把这个缓存行失效掉”——其他核心收到后将自己的缓存行标记为I,下次读取时重新从主内存加载最新数据。这就是上篇文章中提到volatile时提到的内存屏障——但它不是只靠软件屏障来实现的,而是依靠CPU硬件间的消息协议来协同保障的。
四、伪共享——缓存一致性的性能陷阱
伪共享是并发程序中性能下降的重要原因——两个线程修改不同的变量,但因为它们恰好被放在了同一个缓存行里,导致缓存行在不同核心的缓存间反复失效、重新加载。
publicclassFalseSharingDemo{// 两个线程各修改自己的计数器,互不影响staticclassCounter{volatilelongcount1=0;// 线程A频繁修改这个volatilelongcount2=0;// 线程B频繁修改这个}publicstaticvoidmain(String[]args)throwsInterruptedException{Countercounter=newCounter();Threadt1=newThread(()->{for(inti=0;i<100_000_000;i++){counter.count1++;// 线程A只改count1}});Threadt2=newThread(()->{for(inti=0;i<100_000_000;i++){counter.count2++;// 线程B只改count2}});// 两个线程逻辑上完全不冲突——它们操作的是不同的变量// 但实际上它们会彼此拖慢,因为count1和count2在同一个缓存行里!}}两个线程修改不同的变量,逻辑上各改各的完全不冲突。但因为count1和count2在同一个缓存行(64字节)里,它们在物理层面被绑定在了一起。核心A修改count1时会把整个缓存行标记为M;核心B接着要修改count2,必须先让核心A的缓存行失效后从主内存重新加载,加载完后核心B才能修改count2。核心B修改count2后,核心A又需要重新加载。如此反复——两个线程不断在彼此的缓存行上踩来踩去。
这就是伪共享——两个线程操作的是逻辑上独立的变量,但因为物理上共享同一个缓存行,导致缓存一致性协议让性能急剧下降。
如何避免?
最简单的方法是在两个变量之间填充64字节的占位数据,让它们分布在不同缓存行上:
// 填充后:count1和count2分别在不同缓存行,两个核心可以并发修改互不干扰staticclassPaddedCounter{volatilelongcount1=0;longp1,p2,p3,p4,p5,p6,p7;// 填充7个long = 56字节volatilelongcount2=0;}性能差距:伪共享版本一次写操作约60纳秒(缓存行不断在主内存和核心间传输),填充后约1纳秒(各自在自己的L1缓存中独立操作)。这就是为什么@Contended注解在JDK内部广泛使用——Striped64中的Cell类就是用这个方式避免伪共享的。
五、CPU缓存如何影响你写的每行代码
理解缓存之后,再看这些日常代码,你会看到完全不同的东西:
// 好的做法:连续访问,缓存友好for(inti=0;i<n;i++){sum+=arr[i];// 按序访问,缓存命中率高}// 不好的做法:跳跃访问,缓存不友好for(inti=0;i<n;i+=stride){sum+=arr[i];// 大步跳跃,每次跳出一个缓存行,缓存命中率低}// 好的做法:对象布局紧凑,一个缓存行容纳多个对象classPoint{intx,y;// 8字节 + 8字节 = 16字节,一个缓存行能装4个Point}// 不好的做法:对象布局分散,每个对象都散落在一个缓存行的边界classFatObject{longa,b,c,d,e,f,g,h;// 64字节,一个对象就占满一个缓存行intflag;// 为了访问这个4字节的flag,CPU还得额外加载一整个缓存行}这些优化不是"理论上的",而是真实感知得到的。按行遍历比按列遍历快15倍,这就是缓存命中率的威力。伪共享让并发性能下降几十倍,这就是缓存一致性协议的代价。理解缓存,你才能写出真正高性能的代码。
总结
整个故事串起来是这样的:
CPU和主内存之间有巨大的速度鸿沟——寄存器1纳秒,主内存60纳秒,差了60倍。缓存(L1/L2/L3)是填充这个鸿沟的"快捷中转站",把CPU最近和即将要用的数据从主内存提前搬进缓存。CPU读取数据时,不是按字节读,而是按缓存行(64字节)读——利用空间局部性,把当前所需数据周围的连续数据一起带进来。
多个CPU核心各自有独立的L1/L2缓存,MESI协议通过四种状态(M/E/S/I)自动同步不同核心的缓存。多线程共享变量的真正挑战在于不同核心的缓存版本可能不同——volatile和synchronized通过触发缓存一致性协议确保各个核心看到的是最新值。
伪共享是并发性能的隐性杀手——两个线程修改逻辑上独立的变量,但因为这两个变量被放在同一个缓存行里,导致缓存行在两个核心的L1缓存间不断失效和重新加载,性能下降几十倍。在关键变量间填充占位空间,将它们分布到不同缓存行,是解决伪共享的标准手段。
理解这些不是为了背面试题。你写的每行代码,每一次数组遍历、每一次对象布局、每一次多线程并发,背后都在驱动着缓存行的加载、缓存命中与未命中、缓存一致性协议的协同或踩踏。知道这些底层机制,你就能写出更适合CPU缓存架构的代码。
这个专栏只想说清楚一件事:每行代码由谁执行,怎样执行。配合后端技术内核的五个专栏(Java基础、JVM、并发编程、MySQL、Redis),对你的每一行代码的理解从"怎么用"贯通到"为什么这么运行"。
