缓存溢出与虚假共享

简介

缓存溢出(Cache Thrashing)是任何缓存机制中都会出现的一种缺陷,即缓存不断被填充和驱逐,而客户端进程却无法利用缓存的优势。这将导致有用数据以不可取的方式被驱逐。

CPU 缓存行机制中出现的虚假共享(False Sharing)就是缓存中断的一个常见例子。在深入研究虚假共享之前,我们先来了解一下常见 CPU 架构中的缓存行机制。然后,我们可以看看如何在 Java 中解决虚假共享问题。

CPU 缓存

CPU 缓存一般由三个缓存组成: L1、L2 和 L3,其中 L1 离 CPU 最近,L3 离主存储器最近。每个 CPU 都有自己的缓存,位于 CPU 和主内存之间。

L1 缓存是最快的缓存,通常位于处理器芯片中。其容量通常在 8KB-64KB 之间,使用的 SRAM 比主存储器通常使用的 DRAM 更快。

L2 缓存介于 L1 缓存和 RAM 之间,存储容量更大,通常在 64KB-4MB 之间。

L3 缓存位于 L2 缓存之后,更接近 RAM。L3 缓存一般存在于主板上,而不是处理器上,用于保存多个内核之间的共同数据。

在获取数据时,首先查找 L1 缓存,然后是 L2 缓存,最后是 L3 缓存,如果 CPU 缓存中找不到数据,则从主存储器中读取。

在多核环境中,CPU 缓存有不同的策略来实现一定程度的一致性。例如,复制缓存在所有节点上保存所有密钥,而分布式缓存只在部分节点上保存密钥,以提供冗余和容错,同时提供更具可扩展性的解决方案。

缓存行

当 CPU 从主内存或高速缓存中读取数据时,它不会读取单个字节,而是读取一个字节块,通常为 64 字节。我们称这种字节块为缓存行。缓存行很可能存储了多个变量,因为它由多个字节组成。

如果 CPU 需要访问的变量已经作为前一个缓存行的一部分被获取,那么它的读取速度会更快。但是,由于 CPU 基于缓存行保持一致性,如果缓存行中的任何一个字节发生变化,所有缓存行都会失效,而且这种失效会发生在集群中的所有 CPU 上。如果不同的 CPU 需要处理共享在同一缓存行上的变量,就会造成缓存中断,并导致错误共享。

虚假共享

当多个 CPU 或 CPU 内核正在处理存储在同一缓存行上的变量,而每个 CPU 正在处理的变量实际上并不相同时,就会出现错误共享。这将导致某个 CPU 的缓存行失效,即使它对自己感兴趣的变量没有任何改变。

例如,假设我们在多核环境中有 2 个线程,分别在 2 个不同的内核上运行。如果线程 1 和线程 2 都读取了一个共享变量 X,那么这两个线程都会将该变量放入各自的 CPU 缓存行中。如果线程 1 更新了变量 X,线程 1 的缓存行必须失效。这也会触发线程 2 的缓存行失效。这是一种预期行为,被称为真正的共享。如果我们将 X 变量设置为易失性,就能保证这种行为的发生。另一方面,对于非易失性变量,由于我们不会强制 cpu 刷新其内存屏障,因此我们可能无法看到失效。

假设我们在与 X 相同的缓存行中有一个额外的 Y 变量,线程 1 更新了 X,而线程 2 只想对 Y 进行操作,但由于我们读取了 64 字节的内存块,所以两个线程都有保存 X 和 Y 的缓存行。在这种情况下,由于线程 2 对变量 X 的变化不感兴趣,因此线程 2 被告知要使其缓存无效。这种情况称为错误共享。

如果错误共享开始多次发生,我们的系统就会出现性能问题,因为 CPU 将需要等待缓存行被加载,而在此期间它将进行多次迭代。这就是所谓的 "失速"(stall),它会导致静音性能问题。

为了解决这个性能问题并防止错误共享,我们可以尝试使用 Java 8 中的 Contended 注释。

@Contended 注解

使用 JEP 142,我们可以通过 @Contented 注释来注解我们认为可以进行错误共享的字段或类。JEP 142 通常依赖于在类加载时进行填充,并触及分配代码(确保对象的分配正确对齐)、JIT(了解哪些分配需要对齐)和 GC(确保对象在 GC 之后保持对齐)。

该注释是 JVM 将注释部分放入不同缓存行的提示。结果可能包含填充或填充与对齐分配的组合。其副作用当然是增加内存使用量,因为我们使用了额外的空间进行填充。

请注意,默认情况下 Contended 注释在用户 classpath 上不起作用,只对 bootclasspath 上的类起作用。因此,我们需要在 JVM 启动时添加 -XX:-RestrictContended VM 参数。这是因为它是一个内部 API,如果无法证明其真正的性能优势,实际上不应该要求使用它。

public class ContendedModel {
    @jdk.internal.vm.annotation.Contended
    volatile int int1;
    @jdk.internal.vm.annotation.Contended
    volatile int int2;
}

在上面的例子中,我们希望将 int1 保存在一个填充的缓存行中,而将 int2 保存在另一个填充的缓存行中。

如果我们想将两个变量保留在同一缓存行中,可以通过定义 Contended 注释值来强制保留,如下所示。

public class ContendedModel {
    @jdk.internal.vm.annotation.Contended("ContendedModel_group1")
    volatile int int1;
    @jdk.internal.vm.annotation.Contended("ContendedModel_group1")
    volatile int int2;
}

请注意,要使用内部 Contended 注释,我们需要在模块编译器选项中添加" - add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED "选项。

我们可以使用 jol 工具查看 Contended 注释是否有效。为此添加以下依赖关系。

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.9</version>
</dependency>

然后使用 ContendedMemoryLayout 类运行以下代码。

public class ContendedMemoryLayout {
    public static void main(String[] args) {
        System.out.println(VM.current().details());
        System.out.println(ClassLayout.parseClass(ContendedModel.class).toPrintable());
    }
}

默认情况下,它将打印以下输出和虚拟机详细信息。我们可以看到,最后一行没有进行填充,也没有空格损失。

...
falsesharing.ContendedModel object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     N/A
  8   4        (object header: class)    N/A
 12   4    int ContendedModel.int1       N/A
 16   4    int ContendedModel.int2       N/A
 20   4        (object alignment gap)    
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

让我们使用 -XX:-RestrictContended JVM 参数运行相同的代码。

falsesharing.ContendedModel object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     N/A
  8   4        (object header: class)    N/A
 12 128        (alignment/padding gap)   
140   4    int ContendedModel.int1       N/A
144 128        (alignment/padding gap)   
272   4    int ContendedModel.int2       N/A
276   4        (object alignment gap)    
Instance size: 280 bytes
Space losses: 256 bytes internal + 4 bytes external = 260 bytes total

这次我们看到,由于 Contended 注解的填充作用,我们损失了 256 字节的空间。Contended 注解让我们确保 ContendedModel 类的 int1 和 int2 变量通过应用一些填充被放置在不同的缓存行中。

检查完 Contended 注解的内存布局效果后,让我们运行一个示例,通过不同的线程来操作 ContendedModel 对象的两个变量,看看错误共享的效果。

下面的 ContendedExample 类使用 ContendedModel 对象并将其传递给 2 个不同的线程。第一个线程处理 int1 变量并在循环中设置新值,第二个线程处理 int2 变量并在循环中设置新值。

public class ContendedExample {
    private static final long NUM_OF_ITERATION = 10_000_000_000L;

    public static void main(String[] args) {
        ContendedModel contendedModel = new ContendedModel();
        Thread thread1 = new Thread(new Thread1Runnable(contendedModel));
        Thread thread2 = new Thread(new Thread2Runnable(contendedModel));
        thread1.start();
        thread2.start();
    }

    public static class Thread1Runnable implements Runnable {
        ContendedModel contendedModel;

        public Thread1Runnable(ContendedModel contendedModel) {
            this.contendedModel = contendedModel;
        }

        public void run() {
            long start = System.nanoTime();
            long i = NUM_OF_ITERATION;
            while (--i != 0) {
                this.contendedModel.int1 = (int) i;
            }
            System.out.println("End of thread 1! Last value of int1 is " + contendedModel.int1 + ". It took " + (System.nanoTime() - start) / 1000000000.0 + " seconds!");
        }
    }

    public static class Thread2Runnable implements Runnable {
        ContendedModel contendedModel;

        public Thread2Runnable(ContendedModel contendedModel) {
            this.contendedModel = contendedModel;
        }

        public void run() {
            long start = System.nanoTime();
            long i = NUM_OF_ITERATION;
            while (--i != 0) {
                this.contendedModel.int2 = (int) i;
            }
            System.out.println("End of thread 2! Last value of int2 is " + contendedModel.int2 + ". It took " + (System.nanoTime() - start) / 1000000000.0 + " seconds!");
        }
    }
}

首先,我将删除 ContendedModel 类中的 Contended 注释来运行该类,如下所示。

public class ContendedModel {
    volatile int int1;
    volatile int int2;
}

然后我会得到以下日志输出。每个线程从 10_000_000_000L 开始计数到 0 并操作 int1 和 int2 变量大约需要 12 秒。

End of thread 2! Last value of int2 is 1. It took 11.878212417 seconds!
End of thread 1! Last value of int1 is 1. It took 11.913652584 seconds!

现在,我将为 int1 和 int2 变量添加 Contended 注解,如下所示。

public class ContendedModel {
    @jdk.internal.vm.annotation.Contended
    volatile int int1;
    @jdk.internal.vm.annotation.Contended
    volatile int int2;
}

这次运行 ContendedExample 类后,我将得到以下日志输出。正如你所看到的,这次每个线程从 10_000_000_000L 计数到 0 并操作 int1 和 int2 变量大约花了 4 秒钟。

End of thread 2! Last value of int2 is 1. It took 4.019336625 seconds!
End of thread 1! Last value of int1 is 1. It took 4.019737542 seconds!

在这里,我们可以清楚地看到错误共享的影响。在第一次运行中,int1 和 int2 变量被保存在同一缓存行中,造成了错误共享,并阻止了在操作 int1 和 int2 变量时在 CPU 缓存中缓存值。然而,在第二次运行中使用了 Contended 注释,它应用了填充,使每个变量被保存在不同的缓存行中,这样不同的线程在处理不同的变量 int1 和 int2 时就不会相互影响。

请注意,这些结果与平台有很大关系,我是在苹果 M1 Pro 机器上运行这些示例的。不过,由于错误共享可能发生在任何操作系统中,因此在任何操作系统架构中使用 Contended 注释(如上例)时,都能看到性能优势。

另一个值得注意的地方是在 int1 和 int2 变量中使用了 volatile 关键字。与非易失性变量相比,当使用 volatile 变量时,我们会发现 Contended 注解大大提高了性能。这是因为当变量被定义为 volatile 时,它就会被强制从内存读出或写入内存。该内存可以是 CPU 缓存内存,也可以是主内存。这完全取决于硬件和硬件中使用的缓存一致性协议。因此,由于需要将易失性变量的值写入内存,更新和读取易失性变量的速度会更慢。有了 Contended 注释,就可以防止缓存的错误共享和不必要的失效,这样变量的读取就可以从缓存本身而不是内存中完成,从而提高整体性能。

请注意,Contended 注解已在许多 JDK 类中使用,如 Thread、ForkJoinPool 和 ConcurrentHashMap。在大多数情况下,客户端 java 代码中可能不需要使用 Contended 注解,但了解它的作用仍有助于发现潜在的性能问题。

结论

错误共享是高速缓存中断的一个例子,它发生在 CPU 高速缓存行失效时,变量无意中被保留在同一高速缓存行上。通过使用争用注解,JVM 可以处理堆上的分配,并就争用对象向 JIT 和 GC 发出警告,从而防止不希望发生的缓存行失效。根据 CPU 缓存架构和缓存一致性协议的不同,防止错误共享可带来重要的性能优势。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值