伪共享问题如何解决?

伪共享问题是多核处理器环境下常见的性能瓶颈之一,特别是在多线程编程中。想要解决它,就必须先了解缓存行的概念。

缓存行

缓存行是指在 CPU 缓存中最小的数据单位,通常包含一定数量的字节(例如,常见的缓存行大小为 64 字节)。当处理器从主内存读取数据时,它不仅读取请求的字节,还会读取周围的数据形成一个缓存行,以减少未来的内存访问次数。这样做的好处是可以减少内存访问的频率,提高缓存命中率,从而提升性能。

缓存行对齐

缓存行对齐是指将数据的存储位置调整为缓存行大小的整数倍,以便CPU可以一次加载整个数据。

为什么需要缓存行对齐?

当CPU访问主存中的数据时,它通常会加载整个缓存行到缓存中。如果数据没有正确地对齐到缓存行边界,那么CPU可能需要多次访问主存以获取完整的数据。这被称为缓存行分裂(Cache Line Splitting),它会导致额外的性能开销。通过缓存行对齐,我们可以减少缓存行分裂的发生,从而提高程序的性能。

如何在JVM中应用缓存行对齐?

在JVM中,我们可以通过调整对象的布局来实现缓存行对齐。以下是一些建议:

  • 避免在对象内部跨越缓存行边界的访问:如果对象的字段跨越了缓存行边界,那么访问这些字段可能会导致缓存行分裂。为了避免这种情况,我们应该确保对象的所有字段都位于同一个缓存行内。

  • 使用填充(Padding):我们可以使用特定类型的字段(如long或double)作为填充,以确保对象的大小是缓存行大小的整数倍。这样,无论对象如何排列,其边界都将与缓存行边界对齐。
    示例见结尾。

  • 考虑对象的访问模式:当多个对象被频繁地一起访问时,我们应该考虑将这些对象排列在一起,以便它们可以一次性加载到缓存中。

  • 使用太阳花(Sunflower)策略:这是一种对象布局策略,通过将对象分组到不同的"太阳花"中,可以确保在同一时间访问的对象位于不同的缓存行中,从而减少缓存行争用。

  • 使用数组:将多个变量存储在一个数组中,每个变量占据数组中的一个元素,这样它们自然分布在不同的缓存行中。

    java 复制代码
    class ThreadSafeData {
      volatile long[] variables = new long[2]; // 假设缓存行大小为 64 字节,每个 long 占 8 字节
      // 线程 1 访问 variables[0]
      // 线程 2 访问 variables[1]
    }

使用填充技术的示例:

java 复制代码
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注解,有了这个注解解决伪共享就变得简单多了。

java 复制代码
@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% 以上的命中率。

相关推荐
公贵买其鹿6 分钟前
List深拷贝后,数据还是被串改
java
xlsw_3 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
岁月变迁呀4 小时前
Redis梳理
数据库·redis·缓存
神仙别闹4 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭4 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫5 小时前
泛型(2)
java
超爱吃士力架5 小时前
邀请逻辑
java·linux·后端
南宫生5 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石5 小时前
12/21java基础
java
李小白665 小时前
Spring MVC(上)
java·spring·mvc