伪共享问题是多核处理器环境下常见的性能瓶颈之一,特别是在多线程编程中。想要解决它,就必须先了解缓存行的概念。
缓存行
缓存行是指在 CPU 缓存中最小的数据单位,通常包含一定数量的字节(例如,常见的缓存行大小为 64 字节)。当处理器从主内存读取数据时,它不仅读取请求的字节,还会读取周围的数据形成一个缓存行,以减少未来的内存访问次数。这样做的好处是可以减少内存访问的频率,提高缓存命中率,从而提升性能。
缓存行对齐
缓存行对齐是指将数据的存储位置调整为缓存行大小的整数倍,以便CPU可以一次加载整个数据。
为什么需要缓存行对齐?
当CPU访问主存中的数据时,它通常会加载整个缓存行到缓存中。如果数据没有正确地对齐到缓存行边界,那么CPU可能需要多次访问主存以获取完整的数据。这被称为缓存行分裂(Cache Line Splitting),它会导致额外的性能开销。通过缓存行对齐,我们可以减少缓存行分裂的发生,从而提高程序的性能。
如何在JVM中应用缓存行对齐?
在JVM中,我们可以通过调整对象的布局来实现缓存行对齐。以下是一些建议:
- 避免在对象内部跨越缓存行边界的访问:如果对象的字段跨越了缓存行边界,那么访问这些字段可能会导致缓存行分裂。为了避免这种情况,我们应该确保对象的所有字段都位于同一个缓存行内。
- 使用填充(Padding):我们可以使用特定类型的字段(如long或double)作为填充,以确保对象的大小是缓存行大小的整数倍。这样,无论对象如何排列,其边界都将与缓存行边界对齐。
示例见结尾。 - 考虑对象的访问模式:当多个对象被频繁地一起访问时,我们应该考虑将这些对象排列在一起,以便它们可以一次性加载到缓存中。
- 使用太阳花(Sunflower)策略:这是一种对象布局策略,通过将对象分组到不同的“太阳花”中,可以确保在同一时间访问的对象位于不同的缓存行中,从而减少缓存行争用。
- 使用数组:将多个变量存储在一个数组中,每个变量占据数组中的一个元素,这样它们自然分布在不同的缓存行中。
class ThreadSafeData { volatile long[] variables = new long[2]; // 假设缓存行大小为 64 字节,每个 long 占 8 字节 // 线程 1 访问 variables[0] // 线程 2 访问 variables[1] }
使用填充技术的示例:
package org.hbin;
import java.util.ArrayList;
import java.util.List;
/**
* @author Haley
* @version 1.0
* 2024/8/21
*/
public class CacheLineWithPadding {
private static long count = 10000_0000L;
public static class Data {
// 使用7个long类型填充,占用7*8=56字节
public volatile long p1, p2, p3, p4, p5, p6, p7;
public volatile long value; // 占用8字节
// public volatile long p8, p9, p10, p11, p12, p13, p14;
}
public static Data[] array = {new Data(), new Data()};
public static long test() {
Thread t1 = new Thread(() -> {
for (long i = 0; i < count; i++) {
array[0].value = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < count; i++) {
array[1].value = i;
}
});
long start = System.currentTimeMillis();
try {
t1.start();
t2.start();
t1.join();
t2.join();
} catch (Exception e) {
e.printStackTrace();
}
return System.currentTimeMillis() - start;
}
public static void main(String[] args) {
List<Long> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(test());
}
System.out.println(list);
System.out.println(list.stream().mapToLong(Long::longValue).average().orElse(0.0));
}
}
运行上面的代码,观察输出时间;注释上述代码16行public volatile long p1, p2, p3, p4, p5, p6, p7;
,两次运行代码,你可以看到运行时间明显增加了。正是因为有这一行中声明的7个long类型的数据填充,使得缓存更加高效。
放开18行注释掉的代码
public volatile long p8, p9, p10, p11, p12, p13, p14;
,运行程序并将结果和当前进行对比,性能应该也会略有提升的哟!你知道原因么?
jdk8的解决方式
jdk1.8中,官方已经提供了对伪共享的解决办法,那就是sun.misc.Contended注解,有了这个注解解决伪共享就变得简单多了。
@sun.misc.Contended
public static class Data {
public volatile long value;
}
默认情况下此注解是无效的,需要在JVM启动时开启这个注解。
设置方式:-XX:-RestrictContended
。
伪共享问题
伪共享(False Sharing)是指多线程程序中,多个线程访问不同变量,但这些变量位于同一个缓存行中。当一个线程更新这个缓存行中的一个变量时,会导致整个缓存行被刷新到主内存中,从而可能导致其他线程读取该缓存行时需要从主内存中重新加载,造成不必要的性能损失。
Disruptor项目的经典示例图演示了伪共享的问题,如下:
上图中,一个运行在处理器 core1上的线程想要更新变量 X 的值,同时另外一个运行在处理器 core2 上的线程想要更新变量 Y 的值。但是,这两个频繁改动的变量都处于同一条缓存行。
根据MESI协议(不清楚的小伙伴请自行参考我写的另一篇文章,详细介绍了MESI协议),两个线程就会轮番发送 RFO 消息,占得此缓存行的拥有权。当 core1 取得了拥有权开始更新 X,则 core2 对应的缓存行需要设为 I 状态。当 core2 取得了拥有权开始更新 Y,则 core1 对应的缓存行需要设为 I 状态。轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有 L3 缓存上是同步好的数据。要知道,读 L3 的数据非常影响性能。更坏的情况是跨槽读取,L3 都要 miss,只能从内存上加载。
表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。
伪共享的解决方案
在实际的生产开发过程中,我们一定要通过缓存行填充去解决掉潜在的伪共享问题吗?
其实并不一定。
首先伪共享是很隐蔽的,我们暂时无法从系统层面上通过工具来探测伪共享事件。其次,不同类型的计算机具有不同的微架构(如 32 位系统和 64 位系统的 java 对象所占自己数就不一样),如果涉及到跨平台的应用,那就更难以把握了,一个确切的填充方案只适用于一个特定的操作系统。
其次,缓存的资源是有限的,如果填充会浪费珍贵的 cache 资源,并不适合大范围应用。最后,目前主流的 Intel 微架构 CPU 的 L1 缓存,已能够达到 80% 以上的命中率。