从一个让人困惑的测试开始publicclassCacheDemo{publicstaticvoidmain(String[]args){int[][]arrnewint[10000][10000];// 测试1按行遍历longstart1System.nanoTime();for(inti0;i10000;i){for(intj0;j10000;j){arr[i][j]1;// 先走列再走行}}longtime1System.nanoTime()-start1;// 测试2按列遍历longstart2System.nanoTime();for(intj0;j10000;j){for(inti0;i10000;i){arr[i][j]1;// 先走行再走列}}longtime2System.nanoTime()-start2;System.out.println(按行遍历: time1/1000000ms);System.out.println(按列遍历: time2/1000000ms);// 输出结果按行遍历约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 ↓ 主内存~60nsL1缓存的容量只有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] → ...——正好是内存中的连续地址。每次读一个int4字节它后面的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协议将每个缓存行的状态标记为四种之一状态含义可以做什么MModified只有这个核心有而且被改过了可以读写需要写回主内存EExclusive只有这个核心有内容和主内存一致可以读写随时可以变成MSShared多个核心共享内容和主内存一致只能读如果这个核心要写需要先通知其他核心失效IInvalid这个缓存行已失效不能访问需要从主内存或其他核心处重新加载整个过程自动完成程序员不需要干预。CPU通过总线发送消息控制各个核心的缓存行状态转换——“我要写这个变量你们把这个缓存行失效掉”——其他核心收到后将自己的缓存行标记为I下次读取时重新从主内存加载最新数据。这就是上篇文章中提到volatile时提到的内存屏障——但它不是只靠软件屏障来实现的而是依靠CPU硬件间的消息协议来协同保障的。四、伪共享——缓存一致性的性能陷阱伪共享是并发程序中性能下降的重要原因——两个线程修改不同的变量但因为它们恰好被放在了同一个缓存行里导致缓存行在不同核心的缓存间反复失效、重新加载。publicclassFalseSharingDemo{// 两个线程各修改自己的计数器互不影响staticclassCounter{volatilelongcount10;// 线程A频繁修改这个volatilelongcount20;// 线程B频繁修改这个}publicstaticvoidmain(String[]args)throwsInterruptedException{CountercounternewCounter();Threadt1newThread(()-{for(inti0;i100_000_000;i){counter.count1;// 线程A只改count1}});Threadt2newThread(()-{for(inti0;i100_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{volatilelongcount10;longp1,p2,p3,p4,p5,p6,p7;// 填充7个long 56字节volatilelongcount20;}性能差距伪共享版本一次写操作约60纳秒缓存行不断在主内存和核心间传输填充后约1纳秒各自在自己的L1缓存中独立操作。这就是为什么Contended注解在JDK内部广泛使用——Striped64中的Cell类就是用这个方式避免伪共享的。五、CPU缓存如何影响你写的每行代码理解缓存之后再看这些日常代码你会看到完全不同的东西// 好的做法连续访问缓存友好for(inti0;in;i){sumarr[i];// 按序访问缓存命中率高}// 不好的做法跳跃访问缓存不友好for(inti0;in;istride){sumarr[i];// 大步跳跃每次跳出一个缓存行缓存命中率低}// 好的做法对象布局紧凑一个缓存行容纳多个对象classPoint{intx,y;// 8字节 8字节 16字节一个缓存行能装4个Point}// 不好的做法对象布局分散每个对象都散落在一个缓存行的边界classFatObject{longa,b,c,d,e,f,g,h;// 64字节一个对象就占满一个缓存行intflag;// 为了访问这个4字节的flagCPU还得额外加载一整个缓存行}这些优化不是理论上的而是真实感知得到的。按行遍历比按列遍历快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对你的每一行代码的理解从怎么用贯通到为什么这么运行。