JVM_03 CPU的乱序执行

上次我们提到了JVM为了安全推出的 双亲委派机制

那么双亲委派机制可能被打破吗?

可以,那么如何打破呢? ClassLoader.loadClass 方法定义了类加载的双亲委派机制,重写该方法即可跳出双亲委派。

历史上出现过几次双亲委派被破坏的案例
  1. JDK 1.2 之前,想自己实现类的加载必须重写 loanClass 方法。
  2. Thread.ContextClassLoader 中的线程上下文加载器,不是按照双亲委派机制运行的。
  3. 热启动、热部署等场景,每一个 WebApplication 都有自己的 ClassLoader,可以实现不同的 WebApplication 使用同一类库的不同版本。
双亲委派机制的一些局限性

​ 当我们更加基础的框架需要用到应用层面的类的时候,只有当这个类是在我们当前框架使用的类加载器可以加载的情况下我们才能用到这些类。换句话说,我们不能使用当前类加载器的子加载器加载的类。这个限制就是双亲委派机制导致的,因为类加载请求的委派是单向的。

​ 为了解决这个问题,引入了线程上下文类加载器。通过java.lang.Thread类的setContextClassLoader()设置当前线程的上下文类加载器(如果没有设置,默认会从父线程中继承,如果程序没有设置过,则默认是System类加载器)。有了线程上下文类加载器,应用程序就可以通过java.lang.Thread.setContextClassLoader()将应用程序使用的类加载器传递给使用更顶层类加载器的代码。

volatile 关键字

作用:

  1. 保证线程可见性
  2. 禁止指令重排序

​ 其中线程可见性意为当某个线程中对内存做的读写操作,对其他线程是可见的。

保证线程可见性依赖于硬件层面的缓存一致性协议来实现

在这里插入图片描述

图 1 CPU缓存结构

在这里插入图片描述

图 2 CPU缓存耗时比例

​ CPU 在访问内存前,先要进行缓存的访问。对于上面的CPU核来讲,其对缓存中数据的修改,需要保证运行在其他CPU核上的线程可见。这个数据一致性在硬件层面是由 缓存一致性协议 来保证的。

​ 其中一种缓存一致性协议为 MESI (Intel CPU 使用):

  • Modified 修改的
  • Exclusive 排他的
  • Shared 共享的
  • Invalid 不合法的

​ MESI 标识上述四种状态。简单来说,当某一 CPU 修改了在缓存中的某一数据时,会将这个 缓存行 (CPU取数据时并不是一个一个地取的,而是按照缓存行一行一行地取。这是因为一种物理空间上的程序局部性原理,即CPU访问了某一块内存,其附近的数据也很有可能很快会用到。缓存行一般是 64 byte,这个数值的设置和地址总线的宽度有关) 的状态标记为 Modofied 然后将该缓存的新值以及状态同步到下一级缓存。其他 CPU 上这个缓存行的状态会变为 Invalid

参考资料:【并发编程】MESI–CPU缓存一致性协议

​ 现代CPU的数据一致性实现 = 缓存锁(MESI …) + 总线锁

​ 位于同一缓存行的两个不同数据,被两个不同CPU锁定,会产生互相影响的伪共享问题。即这两个CPU其实并不是修改的同一个数据,但是他们位于一个缓存行,其中一个 CPU 修改了这条数据后仍需按照缓存一致性协议来通知其他的CPU,增加了额外的同步开销。

​ 对于为共享问题,使用缓存行的对齐能够提高效率。一些成熟的框架中会引入类似如下代码来避免伪共享。

public class CacheLinePadding_Deprecated {
    private static long COUNT = 1_0000_0000L;
    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7;
    }

    /**
     *  T02比T01快很多, p1 - p7 占用 56 字节,x 占用 8字节,
     *  导致一个数组中的两个 T 对象中的 x 不可能在同一个缓存行里
     *  如此一来,一个CPU对缓存的修改不用按照缓存一致性协议通知另一个CPU
     */
    private static class T extends Padding{
        public volatile long x = 0L;
    }

    private static T[] arr = new T[2];

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

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

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

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
    }
}
禁止指令重排序

​ 首先要了解一下什么是指令重排序:

​ CPU 存在一个乱序执行的概念,也就是说,CPU 在执行指令时,并不会严格按照指令的顺序来依次执行。

  1. 比如 CPU 在执行一条读数据指令时,需要 100 个机器周期 (cycle)。而其下一条运算指令只需要 1 cycle,并且不依赖前一条指令的返回,CPU 完全没必要傻等着读数据。

  2. 多个写操作也可以进行合并:

    WCBuffer

    CPU 对缓存的写首先写入 WCBuffer 中,这个 buffer 速度非常快(甚至比L1还快),但是只有 4 个位置。当写满四次之后一次更新到 L2;

参考资料:现代cpu的合并写技术对程序的影响

CPU 乱序执行的证明

参考资料:Memory Reordering Caught in the Act

public class Disorder {

    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;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                // 由于线程 one 先启动,下面这句话让它等一等线程 two . 读者可以根据自己电脑的实际性能适当调整等待时间.
                shortWait(100000);
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });

            one.start();
            other.start();
            one.join();
            other.join();
            String result = "第" + i + "次" + "(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }

    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}
写操作的合并

​ 这个程序中一次写六个 byte 的速度要远低于 一次写三个 byte(加上byte b,正好四个字节);

public final class WriteCombining {

    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;
    }
}
volatile 关键字如何做到禁止指令重排序

硬件内存屏障 - X86

  • sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
  • lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
  • mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

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

JVM级别如何规范(JSR133)

LoadLoad屏障:

Load1; 
LoadLoadBarrier; 
Load2, 

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

StoreStore屏障:

Store1; 
StoreStoreBarrier; 
Store2;

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

LoadStore屏障:

Load1; 
LoadStore; 
Store2;

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

StoreLoad屏障:

Store1; 
StoreLoad; 
Load2;

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

volatile 的实现细节
如果是写操作,它会导致其他CPU中对应的缓存行无效
一个处理器的缓存回写到内存会导致其他处理器的缓存失效。
处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

  1. 字节码层面
    ACC_VOLATILE

  2. JVM层面
    volatile 内存区的读写 都加屏障

    StoreStoreBarrier

    volatile 写操作

    StoreLoadBarrier

    volatile 读操作

    LoadStoreBarrier

    LoadLoadBarrier

  3. OS和硬件层面
    hsdis - HotSpot Dis Assembler
    windows lock 指令实现 | MESI实现

    参考资料:[volatile与lock前缀指令](
    在这里插入图片描述

图 3 volatile 实现细节

在这里插入图片描述

图 4 synchronized 实现细节
总线风暴

​ 由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。

​ 所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值