CPU 是计算机的核心,所有运算和程序最终都由它来执行;主内存 (RAM) 是数据存储的地方
随着技术的发展,CPU 的计算速度越来越快,但它和主内存的交互速度 (I/O) 相比于它的计算速度来说相差太大
所以才有了中间媒介:高速缓存,它很好的解决了 CPU 与主内存速度之间的矛盾,它们三者之间的关系如下图所示:
从图中可以看出 IO 速度和存储大小成反比,IO 速度越快的缓存也越小;IO 速度越慢的缓存也越大
一级缓存 (L1) 最小也最快,它紧靠着使用它的 CPU 内核,只能被一个单独的 CPU 内核使用
二级缓存 (L2) 大一点,也慢一点,它紧靠着一级缓存,同样的只能被一个单独的 CPU 内核使用
三级缓存 (L3) 在现代多核机器中更普遍,更大,也更慢,它可以被单个插槽上的所有 CPU 内核共享
最后,主内存保存着程序运行的所有数据,最大,也最慢 (除硬盘外),它可以被所有插槽上的所有 CPU 内核共享
当 CPU 执行运算时,先去 L1 查找所需的数据,如果没有找到再去 L2 查找,如果还没有找到再去 L3 查找,最后如果缓存中都没有找到才会去主内存中拿,同时也会把从主内存中拿到的数据进行缓存,方便下次直接从缓存中访问
介绍了缓存和 CPU、主内存的关系以及它的特点,再来看看它内部的构造
缓存是由缓存行构成,通常是 64 字节 (常用处理器缓存行是 64 字节,比较老的处理器缓存行是 32 字节),并且它有效地引用主内存中的一块地址
Java 中 long 类型的变量是 8 字节,因此一个缓存行中可以存 8 个 long 类型的变量,如下图所示:
CPU 和缓存交互的单位是缓存行,换句话说,CPU 每次从缓存中加载数据,必须一次加载一整个缓存行,也就是 64 字节
这种免费加载对于处理内存中连续的元素有好处,如:累加数组中的元素和,如下面代码所示:
public static void main(String[] args)
long[] arr = new long[8];
long sum = 0L;
for (int i = 0; i < 8; i++) {
sum += arr[i];
}
}
对于上面的代码,CPU 只需执行一次加载操作即可将所需数据全部加载到缓存中;但这种好处仅限于连续内存的情况,比如链表就无法享受这种好处,因为链表中的数据在内存中不连续
上面介绍了缓存的内部结构,即由缓存行构成,而且内存和缓存之间交互的最小单位是缓存行
现在假设一种场景:有两个 long 类型的变量在内存中连续存储,CPU1 只对变量 a 有写操作,CPU2 只对变量 b 有写操作,具体如下图所示:
注意:由于 L1 是 CPU 核心独占,所以这里把 L1 和 CPU 画到一起了
CPU1 和 CPU2 均从主内存中一次性加载了 64 字节,因为这 64 字节中包含了各自需要操作的变量 a 和 b
如果 CPU1 对变量 a 进行了写操作,还未写回主内存,这时会将其它包含了变量 a 的缓存行设置为失效状态,如 CPU2 中的缓存行
如果此时 CPU2 要对变量 b 进行写操作,首先会发现自己加载的缓存行已经失效,所以会丢弃已拥有的缓存,重新从主内存中加载,具体过程如下图所示:
关于这个过程推荐一个可视化网站 VivioJS MESI animation help
这样就出现了问题,变量 b 和变量 a 完全不相干,每次却因为变量 a 的更新导致更新变量 b 时需要从主内存重新读取,它被缓存未命中拖慢了
当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享
下面用一个具体的例子来感受一下伪共享对性能的影响:
public class FalseSharingTest {
static class Pointer {
volatile long x;
volatile long y;
}
public static void main(String[] args) throws InterruptedException {
testPointer(new Pointer());
}
private static void testPointer(Pointer pointer) throws InterruptedException {
long start = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
pointer.x++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
pointer.y++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(System.currentTimeMillis() - start);
}
}
根据上面的介绍可以知道对象Pointer
中的两个字段x
和y
在一个缓存行中,耗时为 62474!!
通过在x
和y
之间填充 7 个 long 类型字段,让它们俩不在同一个缓冲行中
static class Pointer {
volatile long x;
long p1, p2, p3, p4, p5, p6, p7;
volatile long y;
}
修改后的耗时为 15222,有了很明显的提升!!
创建一个属于自己的 Long 类型,它刚好 64 字节,自己创建的 Long 类型一定独占一个缓存行,就是有点费空间
static class Pointer {
MyLong x = new MyLong();
MyLong y = new MyLong();
}
// 自创 Long 类型
static class MyLong {
volatile long value;
long p1, p2, p3, p4, p5, p6, p7;
}
同时把pointer.x++;
修改为pointer.x.value++;
;把pointer.y++;
修改为pointer.y.value++;
修改后的耗时为 18280,相比于原始版本也有了很明显的提升!!
使用注解@sun.misc.Contended
也可以达到和方法一或方法二一样的效果
但是默认使用这个注解是无效的,需要在 JVM 启动参数中加上-XX:-RestrictContended
才会生效
misc.Contended .
static class MyLong {
volatile long value;
}
修改后的耗时为 13285,有了很明显的提升!!
注意:以上三种方法中的前两种是通过添加字段的形式实现的,加的字段并没有使用,所以可能会被 JVM 优化调,所以建议使用第三种方法