Java与CPU缓存是如何亲密接触的!

简介: 在解释【伪共享】这个概念之前,我们先来运行一段代码,小编的电脑上有4个core。 这个程序的逻辑是4个线程共享同一个数组读写不同下标的变量。
img_7b33581098df1159d96d2d721e5a09ff.png

在解释【伪共享】这个概念之前,我们先来运行一段代码,小编的电脑上有4个core。 


img_0b7652522aa1a9c1795e44b8753ea61c.png


img_6494eece8d7aa6e1508194628ff5e9f0.png

这个程序的逻辑是4个线程共享同一个数组读写不同下标的变量。每个线程循环1亿次读写,也就是+1操作。然后统计4个线程同时跑完总共花的时间。 


img_34e33c390b4c1504dc44dfd6ffbb1670.png

下面我们来看看在小编的电脑上运行的结果:


img_b2fe29cf207331ca857659b33894c58d.png

然后我把SharingLong里面的注释代码去掉,再跑了一下: 


img_a972b9e73cf969628d5fa6bd1f34aa51.png

在性能上注释前后差别高达5比1,为什么会在性能上会产生如此大的差别呢?

这就是本篇要讲的主题【伪共享】,英文名叫False Sharing。而SharingLong里面的注释行一般称之为【缓存行填充】,英文名叫Cache Line Padding。

首先我们来计算一下SharingLong对象占用的内存空间,我们不考虑64位的情景,Java的对象都有一个2个word的头部,第一个word存储对象的hashcode和一些特殊的位标志,如GC的分代年龄、偏向锁标记等,第二个word存储对象的指针地址,一个word就是32位。然后加上v和6个p变量,总共就是8个long的长度,也就是64字节。

接下来我们要引入CPU缓存的概念。 


img_18d992ed6963f16fdcba602e8c4b8f32.png

现代的处理器一般都有3级缓存结构,L1、L2和L3,CPU直接访问主存是一个相对比较慢的操作,所以通过3级缓存来提升访存性能。我们将3个缓存当成一个整体来看待,它就是CPU缓存。缓存的制造成本非常昂贵,它一般要比主存空间小的多。

CPU在读主存的时候,会先将主存的一块数据加载到缓存上,然后在缓存上读取。当CPU写主存的时候,它会首先写缓存,在未来的某个时间点再一次性将缓存的数据全部刷回主存,这样就可以提高写操作的性能。因为计算机程序数据操作的局部性,CPU连续的指令倾向于访问相邻地址空间的数据,所以后续的读写操作有很大的概率可以直接在缓存上拿到数据。如果缓存上不存在,那就再去主存上加载进来。

缓存虽然小,但是也不是太小,CPU在加载主存数据时,如果一次性将整个Cache填满,但是接下来的指令访问的数据又不在缓存上,就会导致读浪费。另外如果只修改了其中几个字节的数据,但是得回写整个Cache到内存,这又会导致写浪费。

所以现代的CPU缓存一般是分行存储的,最小处理单位是一个行,这个行的长度一般来说就是上文提到的64字节,我们称之为【缓存行】。 


img_23c82c4cd54ecc1428d3e6e707bfb005.png

SharingLong对象中v的值是volatile类型的,意味着CPU要保证v变量在不同线程之间的读写可见行。当CPU对v变量进行修改的时候会将数据立即回写至主存并将相应的缓存行置为失效。这样后续对v变量进行的读写操作都需要重新从内存中加载缓存行,这样就保证了其它线程读到的数据是最新的。

这点跟我们平常在Java基础教科书里提到的有点不一样。教科书里面为了便于新手理解,不会提及缓存,一般只会说volatile变量直接读写内存。

如果内存里有两个volatile变量在相邻的地址,两个cpu分别对v1和v2进行读和写操作,会发生什么情况呢?首先我们分解执行动作。图中的h表示对象头。 


img_bfb1c0c2b22b33d9b6d367a4a0595f38.png

1、CPU1对v1进行读操作,将内存里的v1加载到缓存行里。

2、CPU2对v2进行读操作,将内存里的v2加载到缓存行里。

3、CPU1对v1进行写操作,将缓存里的v1修改,然后回写到主存再将缓存行置为失效。

4、CPU2对v2进行写操作,将缓存里的v2修改,然后回写到主存再将缓存行置为失效。

步骤1肯定先于步骤3,步骤2肯定先于步骤4。它们发生的顺序可能是 1->2->3->4 ,相当于两个CPU交叠运行,步骤1加载缓存行,步骤2发现数据就在缓存行里还是最新的,就省去了加载缓存行操作了,这时读操作做到了【共享】。紧接着步骤3正常进行写操作,然后步骤4来了,CPU2发现缓存行失效了,所以还得重新加载缓存行,然后再回写到主存再将缓存行置为失效。这里就发生了重复加载缓存行的现象,也即【写竞争】。如果不是volatile变量,步骤3的写操作是不会立即回写内存的,缓存行也就不会立即置为失效,这个时候步骤4来了CPU可以直接对缓存进行写操作,而不会出现浪费现象。我们称这种现象为【伪共享】,就是说这两个变量虽然共享同一个缓存行,但是它们之间会发生写竞争。

如果顺序是1->3->2->4,步骤1和步骤3的读操作这时就没能实现共享,还是会有浪费。

当系统的线程数越多时,写竞争越激烈,这种浪费就越多。

现在我们能明白为什么去掉注释后,程序会变慢,因为存在写竞争现象,数组中相邻的SharingLong.v共享了同一个缓存行。

那加上p1~p6这6个变量的意义是什么呢?我们看图。 


img_46408e2a3763b0af3914383c02cb377f.png

我们发现加上6个long变量后,v1和v2将分别占用自己的缓存行,互不干扰,所以写竞争也就不存在了,效率自然就提升了。

不过缺点也是有的,就是缓存的利用率降低了,一个缓存行的空间才使用了1/4。这就是典型的空间换时间的场景。

例子中我们使用了volatile变量,那如果改成普通变量呢?我们运行一下,结果如下: 


img_3dfb728eb139a7d65c9744668c00a64f.png

相当惊人,耗时上居然少了3个量级,这就是volatile在性能上的代价。普通变量不需要保证线程之间的读写的可见性,CPU对缓存修改后不需要立即回写内存,不存在写操作缓存穿透现象。而读操作也不需要总是重新从内存加载,那这个效率几乎完全就是缓存访问的效率,而对volatile变量的读写操作则接近内存访问的效率,差距自然如此明显。

你也许会问,知道这些有什么蛋用!

确是没什么蛋用,因为在现实世界,大部分操作都涉及到IO操作。根据水桶效应,其它环节优化到了极致,也无法提升整体的质量。

但是也不完全所有的应用都是IO操作型的,有一些场景下那是纯粹的内存操作。那么对于纯内存操作来说,理解【伪共享】知识可以帮你从性能上提升几倍甚至是几个数量级。

著名的disruptor框架正是使用了缓存行填充技术,才使得它的环形数组队列能如此高效。看wiki上的性能报告,disruptor的RingBuffer相比Java内置的ArrayBlockingQueue在OPS上高出近一个数量级,在队列延迟上则低了接近3个数量级。

原文链接

目录
打赏
0
0
0
0
9
分享
相关文章
让星星⭐月亮告诉你,当我们在说CPU一级缓存二级缓存三级缓存的时候,我们到底在说什么?
本文介绍了CPU缓存的基本概念和作用,以及不同级别的缓存(L1、L2、L3)的特点和工作原理。CPU缓存是CPU内部的存储器,用于存储RAM中的数据和指令副本,以提高数据访问速度,减少CPU与RAM之间的速度差异。L1缓存位于处理器内部,速度最快;L2缓存容量更大,但速度稍慢;L3缓存容量最大,由所有CPU内核共享。文章还对比了DRAM和SRAM两种内存类型,解释了它们在计算机系统中的应用。
187 1
Java 如何确保 JS 不被缓存
【10月更文挑战第19天】在 Java 中,可以通过设置 HTTP 响应头来确保 JavaScript 文件不被浏览器缓存。方法包括:1. 使用 Servlet 设置响应头,通过 `doGet` 方法设置 `Expires`、`Cache-Control` 和 `Pragma` 头;2. 在 Spring Boot 中配置拦截器,通过 `NoCacheInterceptor` 类和 `WebConfig` 配置类实现相同功能。这两种方法都能确保每次请求都能获取到最新的 JavaScript 内容。
|
2月前
|
Java中的分布式缓存与Memcached集成实战
通过在Java项目中集成Memcached,可以显著提升系统的性能和响应速度。合理的缓存策略、分布式架构设计和异常处理机制是实现高效缓存的关键。希望本文提供的实战示例和优化建议能够帮助开发者更好地应用Memcached,实现高性能的分布式缓存解决方案。
45 9
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
如何找出Java进程占用CPU高的元凶
本文记录了一次Java进程CPU占用率过高的问题和排查思路。
JavaEE初阶——初识EE(Java诞生背景,CPU详解)
带你从零入门JAVAEE初阶,Java的发展历程认识什么是cpu,cpu的工作原理,cpu是如何进行计算的,cpu的架构,指令集,cpu的核心,如何提升cpu的算力,cpu的指令,,cup的缓存,cpu的流水线
|
5月前
|
Java面试题之cpu占用率100%,进行定位和解决
这篇文章介绍了如何定位和解决Java服务中CPU占用率过高的问题,包括使用top命令找到高CPU占用的进程和线程,以及使用jstack工具获取堆栈信息来确定问题代码位置的步骤。
241 0
Java面试题之cpu占用率100%,进行定位和解决
【Java】服务CPU占用率100%,教你用jstack排查定位
本文详细讲解如何使用jstack排查定位CPU高占用问题。首先介绍jstack的基本概念:它是诊断Java应用程序线程问题的工具,能生成线程堆栈快照,帮助找出程序中的瓶颈。接着,文章通过具体步骤演示如何使用`top`命令找到高CPU占用的Java进程及线程,再结合`jstack`命令获取堆栈信息并进行分析,最终定位问题代码。
584 1
【Java】服务CPU占用率100%,教你用jstack排查定位
Java 如何确保 JS 不被缓存
大家好,我是 V 哥。本文探讨了 Java 后端确保 JavaScript 不被缓存的问题,分析了文件更新后无法生效、前后端不一致、影响调试与开发及安全问题等场景,并提供了使用版本号、设置 HTTP 响应头、配置静态资源缓存策略和使用 ETag 等解决方案。最后讨论了缓存的合理使用及其平衡方法。
121 0