JVM之缓存行对齐

770 阅读5分钟

1、CPU缓存

执行程序是靠CPU执行主存中代码,但是CPU和主存的速度差异是非常大的,为了降低这种差距,在架构中使用了CPU缓存,现在的计算机架构中普遍使用了缓存技术。常见一级缓存、二级缓存、三级缓存,这些缓存的数据获取访问速度如下:

从CPU到大约需要的 CPU 周期大约需要的时间
主存约60-80纳秒
QPI 总线传输(between sockets, not drawn)约20ns
L3 cache约40-45 cycles约15ns
L2 cache约10 cycles约3ns
L1 cache约3-4 cycles约1ns
寄存器1 cycle

如果要了解缓存,就必须要了解缓存的结构,以及多个CPU核心访问缓存存在的一些问题和注意事项。

image.png

每个缓存里面都是由缓存行组成的,缓存系统中以缓存行(cache line)为单位存储的。缓存行大小是64字节。由于缓存行的特性,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享(下面会介绍到)。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享问题。

需要注意,数据在缓存中不是以独立的项来存储的,它不是我们认为的一个独立的变量,也不是一个单独的指针,它是有效引用主存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。

image.png

缓存行的这种特性也决定了在访问同一缓存行中的数据时效率是比较高的。 比如当你访问java中的一个long类型的数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,因此可以非常快速的遍历这个数组。实际上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。

2、伪共享问题

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2,L3)后再进行操作,但操作完之后不知道何时会写到内存;如果对声明了volatile 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。但就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读取到处理器缓存里。

为了说明伪共享问题,下面举一个例子进行说明:两个线程分别对两个变量(刚好在同一个缓存航)分别进行读写的情况分析。

image.png

在core1上线程需要更新变量X,同时core2上线程需要更新变量Y。这种情况下,两个变量就在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新对应的变量。如果core1获得了缓存行的所有权,那么缓存子系统将会使core2中对应的缓存失效。相反,如果core2获得了所有权然后执行更新操作,core1就要使自己对应的缓存行失效。这里需要注意:整个操作过程是以缓存行为单位进行处理的,这会来来回回的经过L3缓存,大大影响了性能,每次当前线程对缓存行进行写操作时,内核都要把另一个内核上的缓存块无效掉,并重新读取里面的数据。如果相互竞争的核心位于不同的插槽,就要额外横跨插槽连接,效率可能会更低。

3、缓存行对齐

基于以上问题的分析,在一些情况下,比如会频繁进行操作的数据,可以根据缓存行的特性进行缓存行对齐(即将要操作的数据凑一个缓存行进行操作)下面使用一个示例进行说明:

2个线程同时对数组array的第1个,第2个元素进行修改,每个线程修改1千万次。

public class Cacheline_notPadding {
    public static class T {
        private volatile long x = 0L;// 占8字节
    }
 
    private static T[] array = new T[2];
 
    static {
        array[0] = new T();
        array[1] = new T();
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                array[0].x = i;// 伪共享问题+缓存一致性协议在修改数据时会消耗额外的时间
            }
        });
 
        Thread thread2 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                array[1].x = i;// 伪共享问题+缓存一致性协议在修改数据时会消耗额外的时间
            }
        });
        long startTime = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("总计消耗时间:" + (System.nanoTime() - startTime) / 100_000);
    }
}

执行该小程序,总计消耗时间为:2565。实际上,该小程序存在一个细节问题,是可以进行优化的。这个细节问题就是缓存行伪共享问题。

缓存行伪共享问题

众所周知,cpu将数据加载到缓存中的最小数据单位是行,缓存中也是以缓存行为单位进行存储的。缓存行的大小一般为32-256个字节,最常见的缓存行大小是64个字节(本文中的示例环境中的缓存行大小为64个字节)。缓存行的容量限制带来了一个问题,就是伪共享问题。如下图所示,在本文的小程序中,我们假设线程thread1,thread2分别修改的是缓存C1,C2中的array[0],array[1]。虽然线程thread1只是修改array[0],但是因为缓存行的容量是64字节,而new T()中只有一个占8字节的属性x,所以C1中的array[0]所在的缓存行在加载时也加载了array[1];C2中的array[1]所在的缓存行也是同理。但是实际上C1中的array所在的缓存行在计算时是不需要array[1]的,C2中的array所在的缓存行在计算时也不需要array[0]。这样在缓存一致性协议作用下(这里以MESI协议为例),当线程thread1修改了C1中的array[0],那么势必会通过总线通知C2作废array[1]所在的缓存行,线程thread2修改array[1]时也是如此,缓存一致性协议所带来的操作势必会带来额外的性能消耗。

image.png

缓存行对齐

那么怎么解决由于缓存行伪共享+缓存一致性协议带来的额外的性能消耗呢?答案就是“缓存行对齐”。如下图所示,如果让缓存C1中的array[0]及C2中的array[1]各占一个缓存行,那么在计算时就互不影响了

image.png

针对本文的小程序,采用缓存行对齐优化后的代码如下:
在类T中,除了成员属性x(占8个字节),再定义无任何使用意义的7个long类型的成员属性p1...p7(占56个字节),这样就会让一个T对象至少占满8+56=64个字节,这样array每个元素所在的缓存行只能容下一个T对象了,由于array中的两个元素各自独占一个缓存行,那么线程thread1和thread2在计算时就不会互相影响了。

public class Cacheline_Padding {
    public static class T {
        private long p1, p2, p3, p4, p5, p6, p7;// 占7*8字节 缓存行对齐
        private long x = 0L;// 占8字节
    }
    private static T[] array = new T[2];
 
    static {
        array[0]=new T();
        array[1]=new T();
    }
 
    public static void main(String[]args)throws InterruptedException{
        Thread thread1=new Thread(()->{
            for(long i=0;i< 1000_0000L;i++){
                array[0].x=i;// array[0]独占一个缓存行
            }
        });
 
        Thread thread2=new Thread(()->{
            for(long i=0;i< 1000_0000L;i++){
                array[1].x=i;// array[1]独占一个缓存行
            }
        });
        long startTime=System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
    }
}

执行程序,总计消耗时间:99

实际上,本文这样定义无实际使用意义的成员属性来达到缓存行对齐的方式在一些框架源码中是有运用的,如在JDK7的LinkedBlockingQueue源码及Disruptor框架。

缓存行对齐的其他方式
到了JDK8,对于缓存行对齐有了一种更加优雅的解决方式,那就是sun.misc.Contended注解,这个注解直接在类上定义就可以了。

@sun.misc.Contended// 缓存行对齐 每个T对象占64字节
public static class T {
    private long x = 0L;
}

注意:如果此注解无效,需要在JVM启动时设置-XX:-RestrictContended。