JVM学习笔记 - 02JMM

382 阅读6分钟

硬件层数据一致性

关于Cache Line缓存行如下图所示:

老CPU会使用总线锁来保证数据一致性,即在L3 Cache层,要读取数据时,CPU的一个核(或虚拟核)就会锁住该内存。

新CPU用各种各样的一致性协议,例如intell的CPU中,使用缓存锁MESI + 总线锁。

MESI

MESI表示用四种状态来标记缓存行,每一种状态的简单解释如下:

Modeified: 该缓存行只被缓存在该CPU的缓存中,并且是被修改过的,即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

简单来说就是:我改过,别人那边看这块缓存行的状态为Invalid

Exclusive: 该缓存行只被缓存在该CPU的缓存中,它是未被修改过的,与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态shared。同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

简单来说就是:只有我在用

Shared: 该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致,当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废,变成无效状态Invalid

简单来说就是:我读时别人也在读

Invalid: 该缓存是无效的,可能有其它CPU修改了该缓存行。

但是要注意,跨越多个缓存行的数据依然必须使用总线锁。

伪共享

由于CPU读取缓存是以缓存行为基本单位,并且缓存行的多数实现为64 bytes。假设有这样一种情况,一块缓存行中存储了数据x和y,CPU1要读x必须同时读取x和y。若此时CPU2想读这块缓存行的数据y时就需要等待。这就是为共享问题。

针对这个问题,可以使用缓存行对齐,来提升代码执行效率,代码如下:

public class Test {
    private volatile long x = 0L;
    private static Test[] arr = new Test[2];

    static {
        arr[0] = new Test();
        arr[1] = new Test();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(System.currentTimeMillis() - start);
    }
}

上面这段代码平均执行时间为200+毫秒。如果在变量x上方加上代码private volatile long p1, p2, p3, p4, p5, p6, p7;再跑一下,平均执行时间减少为100+毫秒,性能确实有提升。

乱序问题

CPU为了提高指令执行效率,需要解决的性能瓶颈为对内存的访问。CPU的速度往往会比去主存中读数据要快两个数量级。所以如第一张图所示,CPU会引入L1 CacheL2 Cache甚至L3 Cache。此时,如果CPU执行时需要访问的数据不在Cache中,则需要到主存中读取。那么在读取到数据的这段时间内,CPU会继续执行其他没有依赖关系的指令,这就会导致乱序问题。以下代码可以证明乱序问题:

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while(true) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start();other.start();
            one.join();other.join();
            String result = String.format("第%s次,x = %s, y = %s", i, x, y);
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            }
        }
    }
}

正常理解下,x与y的值可能是(0, 1)或(1, 0),但是如果同时为0,则能证明乱序问题。

由于每一层Cache的读取速度又相差很多,例如L1 CacheL2 Cache的读取速度相差二三十倍,所以CPU又会使用另外一个缓冲区叫做合并写存储缓冲区。即CPU会把待写入的数据从L2 Cache写到合并写缓冲区,该缓冲区大小通畅也为64 bytes。当缓冲区去满了之后再写入L1 Cache。这个缓冲区允许cpu在写入或者读取该缓冲区数据的同时继续执行其他指令,这就缓解了CPU读数据时的性能影响。再看下面这段代码:

public class Test {
    private static final int ITERATIONS = Integer.MAX_VALUE;
    private static final int ITEMS = 1 << 24;
    private static final int MASK = ITEMS - 1;

    private static final byte[] arrayA = new byte[ITEMS];
    private static final byte[] arrayB = new byte[ITEMS];
    private static final byte[] arrayC = new byte[ITEMS];
    private static final byte[] arrayD = new byte[ITEMS];
    private static final byte[] arrayE = new byte[ITEMS];
    private static final byte[] arrayF = new byte[ITEMS];

    public static void main(final String[] args) {

        for (int i = 1; i <= 3; i++) {
            System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
            System.out.println(i + " SplitLoop  duration (ns) = " + runCaseTwo());
        }
    }

    public static long runCaseOne() {
        long start = System.nanoTime();
        int i = ITERATIONS;

        while (--i != 0) { // 同时改变六个位置 可能会有一些并发的控制占用计算资源
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }

    public static long runCaseTwo() {
        long start = System.nanoTime();
        int i = ITERATIONS;
        while (--i != 0) { // 改其中的三个位置
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
        }
        i = ITERATIONS;
        while (--i != 0) { // 改另外的三个位置
            int slot = i & MASK;
            byte b = (byte) i;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }
}

直观上会认为case one会比case two执行时间要快,但是实际情况相反。因为在case one中,合并写缓冲区被64 bytes的数据填满后,数据会写入L1 Cache。此时CPU等待另外64 bytes的数据由L2 Cache写入缓冲区,所以效率会低很多。

如何保证特定情况下不乱序

CPU层面如何规范

X86 Intel会使用如内存屏障: sfence: 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。

lfence: 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。

mfence: 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

除了内存屏障,如x86上还有原子指令”lock …” 该指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用内存屏障或原子指令来实现变量可见性和保持程序顺序。

JVM层面如何规范(JSR133)

LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

**LoadStore屏障:**对于这样的语句Load1; LoadStore; Store2, 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

**StoreLoad屏障:**对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

相关扩展:

对象大小(64位机)

使用java -XX:+PrintCommandLineFlags -version可以看到-XX:+UseCompressedClassPointers-XX:+UseCompressedOops这两个配置,下面会说明。

普通对象

  • 对象头即markword为8字节;
  • ClassPointer指针,表示对象属于哪个类。配置-XX:+UseCompressedClassPointers开启,该指针占4字节,若不开启则为8字节;
  • 实例数据和引用类型,配置-XX:+UseCompressedOops开启,该项占4字节,若不开启则为8字节;
  • Padding对齐:将对象占用内存书对齐为8的倍数;

数组对象

  1. 对象头:同普通对象
  2. ClassPointer指针:同普通对象
  3. 数组长度:4字节
  4. 数组数据
  5. Padding对齐:同普通对象

markword详解(32位)

markword内的数据与synchronized锁升级相关。同样可阅读juejin.cn/post/706707… 这篇文章来学习。

对象定位

  1. 句柄池

  2. 直接指针