news 2026/5/13 1:21:32

第三篇:CPU缓存——为什么有时候改了一行代码,性能差了百倍

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第三篇: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),对你的每一行代码的理解从"怎么用"贯通到"为什么这么运行"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/13 1:18:43

通化葡萄酒“甜”度爆表:拿奖 跨界 破圈,听说还要在519这天搞事情!

谁说老字号只能“倚老卖老”&#xff1f;拥有89年历史的中华老字号通化葡萄酒&#xff0c;正以一路高光打破刻板印象&#xff1a;从春节热销全线飘红、春糖签约不断、消博会出圈&#xff0c;到如今斩获国际大奖、跨界足球、牵手顶流网红、入选权威名录……一连串动作层层递进、…

作者头像 李华
网站建设 2026/5/13 1:18:42

LangGraph 为什么适合做 Agent 编排?它和 LangChain 到底差在哪?LangGraph 的最小代码示例

LangGraph 为什么适合做 Agent 编排?它和 LangChain 到底差在哪? 过去很多人做大模型应用,第一反应就是用 LangChain。 因为 LangChain 把模型调用、Prompt、工具调用、RAG、Agent 都封装好了。做一个简单 Demo 很快,几行代码就能让大模型调用工具、回答问题、查资料。 但是…

作者头像 李华
网站建设 2026/5/13 1:12:08

基于Docker与vLLM/TGI的DeepSeek大模型本地化部署与生产实践

1. 项目概述&#xff1a;从镜像名到个人AI工作流的构建最近在技术社区里&#xff0c;看到不少朋友在讨论一个名为dzhng/deep-seek的Docker镜像。乍一看这个名字&#xff0c;很容易让人联想到当下热门的AI模型服务。没错&#xff0c;这个镜像的核心&#xff0c;就是围绕DeepSeek…

作者头像 李华
网站建设 2026/5/13 1:02:18

Embedbase:一体化嵌入向量管理平台,简化AI应用开发

1. 项目概述&#xff1a;一个为AI应用量身定制的嵌入向量管理平台如果你正在构建基于大语言模型&#xff08;LLM&#xff09;的应用程序&#xff0c;无论是智能客服、文档问答还是个性化推荐&#xff0c;有一个环节你大概率绕不过去&#xff1a;嵌入向量&#xff08;Embeddings…

作者头像 李华
网站建设 2026/5/13 1:01:20

如何在macOS上实现完美桌面歌词体验:LyricsX 2.0完整指南

如何在macOS上实现完美桌面歌词体验&#xff1a;LyricsX 2.0完整指南 【免费下载链接】Lyrics Swift-based iTunes plug-in to display lyrics on the desktop. 项目地址: https://gitcode.com/gh_mirrors/lyr/Lyrics 你是否曾经在听歌时想要同步看到歌词&#xff0c;却…

作者头像 李华
网站建设 2026/5/13 1:01:14

应对Turnitin检测:英文摘要AI率飙到97%怎么救?5款工具实测盘点

大家面对turnitin检测的时候肯定都特别头疼&#xff0c;尤其非母语写长文真的很容易飘红。 我自己这段时间踩了无数个坑&#xff0c;特意熬了几天夜&#xff0c;试出来几个真正靠谱的留学生降ai方法&#xff0c;今天就把这些测试结果全部掏出来。 这篇文章会详细拆解5个主流工…

作者头像 李华