当前位置: 首页 > news >正文

第三篇: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已经改了数据。不同核心的缓存间存在不一致。

多线程共享变量的真正挑战就在于此——不只是"谁先执行"的问题,还有"谁的缓存里是什么版本"的问题。这就是为什么需要volatilesynchronized——它们不仅控制代码执行的先后顺序,更重要的是强制执行缓存的同步(缓存一致性协议),确保一个核心写入后其他核心看到的是最新值。

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在同一个缓存行里!}}

两个线程修改不同的变量,逻辑上各改各的完全不冲突。但因为count1count2在同一个缓存行(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),对你的每一行代码的理解从"怎么用"贯通到"为什么这么运行"。

http://www.jsqmd.com/news/805737/

相关文章:

  • 车载BLDC电机驱动设计:IPM技术选型与工程实践全解析
  • AI编程助手上下文管理工具devcontext:构建项目记忆库提升开发效率
  • Enzyme协议:DeFi资产管理智能合约架构与实战指南
  • 99美元超算Parallella实战:量子模拟的异构计算与能效优化
  • spring生命周期
  • 为什么92%的设计师在Basic计划第3周放弃?——基于1,842份用户行为日志的紧迫预警
  • 2026年四川轻奢入户门权威推荐指南:四川家装入户门/四川小区入户门/四川指纹锁门/四川新房入户门/四川旧房换门/选择指南 - 优质品牌商家
  • 2026金铲铲之战电脑版模拟器实测:选对模拟器轻松上分
  • AI时代工程师的超能力进化
  • 3分钟快速上手:如何用res-downloader高效下载视频号资源
  • 基于 Harmony6.0 的智慧学习应用页面构建实战:从组件封装到跨端 UI 设计
  • day13-C语言-指针
  • 开源OmenSuperHub:解决惠普OMEN笔记本性能限制的完整技术方案
  • 合肥元森倍健:营养榧塑膳食/香榧产地/香榧价值/香榧作用/香榧功效/香榧瘦身产品/天然榧塑膳食/天然膳食/安徽香榧种植园/选择指南 - 优质品牌商家
  • 第八篇:Spring与微服务——从SpringBoot到SpringCloud的演进
  • 专业Word文档自动化生成:从模板引擎到批量处理实战
  • 从Google Glass拆解看硬件设计:芯片选型、成本控制与可穿戴设备挑战
  • 2026年4月射洪优质装饰公司推荐指南:射洪精装修、射洪装饰公司、射洪家装、射洪装饰、射洪整装、射洪装修公司、射洪装修选择指南 - 优质品牌商家
  • 25mm×35mm的照片像素多少怎么调整?照片调尺寸方法
  • 供应链数字化转型:从线性链条到智能网络的演进与实践
  • 网盘直链解析工具完整指南:技术实现与高效下载策略
  • MCP协议实战:构建AI智能体任务管理服务器与二次开发指南
  • 快速排序的递归与非递归实现
  • 开发者必备:命令行优先的备忘录与代码片段管理工具Mnemon
  • 2026年高强级反光膜全攻略:三类反光膜、二类反光膜、五类反光膜、交通标志杆件、人防标牌、反光交通标牌、反光膜加工选择指南 - 优质品牌商家
  • 手把手带你拿下ElevenLabs Creator认证:从环境配置、语音样本提交到模型定制部署的完整流水线(含GitHub可运行脚本)
  • 2026年5月自贡建筑装饰选材指南:为何任鸟飞成为发泡陶瓷雕花口碑之选? - 2026年企业推荐榜
  • ARM MPMC静态内存控制器架构与寄存器配置详解
  • 2026Q2线上百货加盟权威选择:前置仓加盟/投资即使零售平台/投资线上百货超市/投资网上超市/投资网络超市/本低仓加盟/选择指南 - 优质品牌商家
  • 未来已来:AI驱动的数据湖仓