JVM第六天-JMM之硬件级别内存模型

我来啦。。。。今天继续来肝JVM!!!!

硬件层的并发优化基础知识

在这里插入图片描述
现代CPU的处理速度很快,大约比内存快100个数量级,比硬盘就快更多了,可能100W个数量级。
CPU很快,但如果它读取的地方需要耗时很慢,那么这将是个瓶颈,因此诞生出了这么一个多级缓存的结构。
如果你不是很要求速度,甚至可以将资源存储在远程进行存储。稍微好一点的,是放在本地磁盘,但还是很慢。
这时考虑到内存了,内存是磁盘寻址的十万倍。但对于CPU而言,还是比较慢,因此又诞生了上面的L3以后的缓存结构。
L3是所有CPU共享的缓存,自此向上,L2,L1都是每个CPU独享的缓存,并且也是随着越上面,速度越快,直到最上面最快的肯定是离cpu最近的寄存器了。
但随着向上,离着CPU越近,也意味着更快,但能存储的内容更少,成本也更高。比如寄存器是最核心的寄存单元,它只能存储几个数。。。
这个多级缓存是说,CPU如果要计算一个数,会首先最高级的缓存从上向下一层一层的寻找,是否有这个数,如果没有,会向下找,找到之后再从下向上一级一级缓存起来。下次再找的时候,就可以直接从CPU最近缓存中读到了。

各缓存时间分布图
在这里插入图片描述

数据不一致

在这里插入图片描述
根据刚才说的,L2之后的缓存都由每个CPU独享。那这样的话,可能出现一个问题,两个CPU分别都把X值load到自己的L2上,CPU1把X值变成了2,而CPU2把X值变成了3,而这个变动对各自来说都是不可知的,此时CPU1和CPU2各自的X值是不一样的,就出现了数据不一致的问题。

解决方案

1、总线锁

在这里插入图片描述

在老式CPU中,使用的是总线锁来解决一致性问题,我们知道CPU与存储器之间的连接需要总线的支持,相当于一座大桥来连接两边的道路,总线锁是什么意思呢?
就是当一个CPU访问一个内存中的X的时候,把这个总线直接锁住。这样会使得另外一个CPU无法通过总线去访问所有地址,而不仅仅是X,
总线锁属于无差别全范围锁定,效率可想非常的低。

2、缓存锁-一致性协议

在这里插入图片描述
在新版CPU中,使用的是各种各样的一致性协议。这些都是一致性协议

在这里插入图片描述
而我们现在的电脑大多都是Intel的处理器,它实现的就是MESI协议,因此一般谈起一致性协议,都是说MESI协议

MESI一致性协议参考文章:
https://www.cnblogs.com/z00377750/p/9180644.html

简要概述就是,CPU会为每个独享缓存行作一个标记(使用2位进行存储标识,这2位并非存储在缓存中,而是在另外的地方):
如果当前缓存行内容与主存相比,进行过更改,就标记为Modified。如果还有其他CPU读取了该缓存内容,那么此CPU变动缓存后(也即打上Modified后),会导致其他读取了的CPU将此行缓存置为Invalid
如果当前缓存行内容只有当前CPU读取了,就标记为Exclusive
如果当前缓存行内容不只被当前CPU读取,还被其他CPU读取了,就标记为Shared
如果当前缓存行内容被其他CPU变动过了,就标记为Invalid
当CPU要对当前缓存行内容即将发生计算时,发现是Invalid状态时,会重新去主存中去读取该值。

缓存一致性也就是缓存锁,确实要比总线锁快不少,但有时依然无法覆盖完全:有些无法被缓存的数据(数据特别大),或者跨越多个缓存行的数据,将无法使用,此时只能使用总线锁。
因此,现代CPU的数据一致性实现 = 缓存锁(MESI …) + 总线锁

缓存行

上面有提到缓存行,缓存行是啥呢?
cache line,读取缓存到CPU是以cache line为基本单位的,目前一般来说都是64个bytes(字节)

基于缓存行的伪共享问题

在这里插入图片描述
我们上面说到,读取缓存到CPU是以cache line为基本单位的,而一致性协议也是以缓存行为单位进行标记状态的。
看图上,也就意味着,如果CPU1和CPU2把X,Y作为一个缓存行的数据读取进缓存后,会发生什么:
CPU1的L2中的X发生了变化,要将整个缓存行标记为Modified,而也会造成CPU2的L2中位于X,Y缓存行整个行的无效,导致需要重新去主存加载。
而实际上,我们只改动了X,Y却也要跟着受牵连,这就是位于同一缓存行的两个不同数据,被两个不同CPU锁定,产生互相影响的伪共享问题。

伪共享问题的解决方案-缓存行对齐

针对这种伪共享问题,说白了就是因为让两个不同的东西出现在了一个缓存行才导致,那么我们如果可以保证让一个值在单独一行,是不是能够解决呢?
这里我们使用例子来看一下:

1、首先是没有用缓存行对齐的例子

public class T01_CacheLinePadding {
    private static class T {
        public volatile long x = 0L;
    }

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

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

    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.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start)/100_0000);
    }
}

这个的程序意思就是一个数组里有两个内部成员变量是long类型值的对象,然后用2个线程分别去修改两个数(因为缓存行是64字节,所有这俩long数肯定是在一缓存行上)
执行看下时间:343

2、接下来是使用缓存行的例子:

public class T02_CacheLinePadding {
    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7; //使用7个字节填充,为了保证数据在一行
    }

    private static class T extends Padding {
        public volatile long x = 0L; //继承Padding ,为了保证x单独在一缓存行上
    }

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

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

    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.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start)/100_0000);
    }
}

执行时间:142

可见,使用了缓存行对齐,明显的提示了执行的效率。

使用场景
在这里插入图片描述
著名的号称单机最快的消息队列Disruptor内部的环形队列的指针就运用了缓存行对齐的技巧,在cursor指针上下各填充了7个字节,保证肯定会单独在一缓存行。

乱序问题

读操作可以乱序

CPU为了提高指令执行效率,会在一条指令执行过程中(比如去内存读数据(慢100倍)),去同时执行另一条指令,前提是,两条指令没有依赖关系

写操作也可以进行合并

在这里插入图片描述
当CPU执行存储指令(store)时,它会尝试将数据写到离CPU最近的L1缓存。
如果此时出现缓存未命中,CPU会访问下一级缓存。此时,无论是英特尔还是许多其它厂商的CPU都会使用一种称为“合并写(write combining)”的技术。
在请求L2缓存行的所有权尚未完成时,待存储的数据被写到处理器自身的众多跟缓存行一样大小的存储缓冲区之一,也即是合并写存储缓冲区(write combining store buffers)
当后续的写操作需要修改相同的缓存行时,这些缓冲区变得非常有趣。在将后续的写操作提交到L2缓存之前,可以进行缓冲区写合并。 这些64字节的缓冲区维护了一个64位的字段,每更新一个字节就会设置对应的位,来表示将缓冲区交换到外部缓存时哪些数据是有效的。
也许你要问,如果程序要读取已被写入缓冲区的某些数据,会怎么样?我们的硬件工程师已经考虑到了这点,在读取缓存之前会先去读取缓冲区的。
这一切对我们的程序意味着什么?
如果我们能在缓冲区被传输到外部缓存之前将其填满,那么将大大提高各级传输总线的效率。如何才能做到这一点呢?好的程序将大部分时间花在循环处理任务上。
这些缓冲区的数量是有限的,且随CPU模型而异。例如在Intel CPU中,同一时刻只能拿到4个。这意味着,在一个循环中,你不应该同时写超过4个不同的内存位置,否则你将不能享受到合并写(write combining)的好处。

代码举例:

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;
    }
}

runCaseOne和runCaseTwo的运行时间,可能你会觉得是runCaseOne会比较快,因为runCaseTwo使用了2次循环,而runCaseOne只用了一次。

运行结果:

1 SingleLoop duration (ns) = 10176982954
1 SplitLoop  duration (ns) = 6254802884
2 SingleLoop duration (ns) = 9871555670
2 SplitLoop  duration (ns) = 6177449176
3 SingleLoop duration (ns) = 7421554292
3 SplitLoop  duration (ns) = 6094619471

上面的例子说明:如果在一个循环中修改6个数组位置(内存地址),程序的运行时间明显长于将任务拆分的方式,即,先写前3个位置,再修改后3个位置。
通过拆分循环,我们做了更多的工作,但程序花费的时间更少!欢迎利用神奇的“合并写(write combining)”。通过使用CPU架构的知识,正确的填充这些缓冲区,我们可以利用底层硬件加速我们的程序。

参考文章:
现代cpu的合并写技术对程序的影响
https://www.cnblogs.com/liushaodong/p/4777308.html
合并写(write combining)
https://yq.aliyun.com/articles/88109/

指令重排序的证明程序

美团有人写过这么一个程序来证明两个不相关的指令确实可能跟会发生重排序的可能,我们来看下这个程序:

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

            Thread other = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();other.start();
            one.join();other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            //如果指令都是按照顺序执行的,是绝对不会出现x,y都为0的情况
            //除非发生了指令重排序:
            //one线程内部成了: x=b;a=1;   other线程里是 y=a;b=1;
            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);
    }
}

当然,想要得到这种结果是比较难的,但肯定是会有几率出现的。
在这里插入图片描述

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

硬件级别保证
1、内存屏障

在这里插入图片描述

在CPU级别,不同的CPU有不同的保障措施。
而我们常用的都是X86结构的CPU,它的有序性是由CPU内存屏障保证的。
sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
在操作之间插入内存屏障来保证操作的有序性。

2、lock指令

原子指令,如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。 类似于这样:lock [add…] 意思就是说,在我这个add操作完成之前,这块内存都会被我锁住,别人都不能动。

Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序

JVM级别保证(JSR133)

JVM说白了也是一个允许在操作系统的软件,因此它虽说定义了一套规范,但终究执行的最终根源还是使用的上述的硬件级别指令进行完成的。
它对读取(load)和写入(store)进行了组合,从而定义了JVM层面的一些内存屏障:
在这里插入图片描述

探究volatile的实现细节

1、字节码层面

public class TestVolatile {
    int i;
    volatile int j;
}

就这段代码,我们使用idea的jclasslib看加了volatile修饰的j发生了什么:
在这里插入图片描述
可以看到,在字节码层面,访问修饰符中是可以看到volatile的体现的,ACC_VOLATIILE。

2、JVM层面

在这里插入图片描述

在jvm层面,针对加了volatile修饰的变量根据它的字节码标识的ACC_VOLATIILE,会对变量的读写操作进行添加内存屏障:
读操作:
会在这个变量的读操作的前面加上StoreStoreBarrier,保证前面的写操作必须要先于这个变量的写操作。 后面加了StoreLoadBarrier保证这个变量的写操作必须先于它后面的读操作。
写操作:
会在这个变量的写操作的前面加上LoadLoadBarrier,保证前面的读操作必须要先于这个变量的读操作。 后面加了LoadStoreBarrier保证这个变量的读操作必须先于它后面的写操作

3、操作系统和硬件层面

在这里插入图片描述

可以使用hsdis (HotSpot Dis Assembler)去观察汇编码。
参考文章:https://blog.csdn.net/qq_26222859/article/details/52235930

不同的操作系统的实现可能是不同的,windows就是使用 lock 指令进行的实现

探究synchronized实现细节

1、字节码层面

public class TestSync {
    synchronized void m() {

    }

    void n() {
        synchronized (this) {

        }
    }

    public static void main(String[] args) {

    }
}

对于synchronized方法来说(m方法),只对方法的修饰符中添加了synchronized的标识:
在这里插入图片描述

对于同步代码块来说(n方法),添加了monitorenter 和 monitorexit的指令:

 0 aload_0
 1 dup
 2 astore_1
 3 monitorenter //同步开始
 4 aload_1
 5 monitorexit  //同步结束
 6 goto 14 (+8)
 9 astore_2
10 aload_1
11 monitorexit //同步结束
12 aload_2
13 athrow
14 return

我们发现monitorexit怎么有2个? 这是因为其中一个是为了代码块里出现异常的时候进行执行的退出指令,而另外一个是正常同步代码块执行完后的退出指令。

2、JVM层面

JVM是由C C++编写的,在它的源代码中,是直接调用了操作系统提供的同步机制。

3、操作系统和硬件层面

针对于X86来说,使用了lock指令,通过 lock [cmpxchg] 指令来实现的,大致流程就是:
通过lock修饰的cmpxchg,在执行过程中,cmpxchg操作的对应内存会被它锁住,然后通过cmpxchg尝试将内存某个数从0变成1,只有能成功改变的,才可以进行后续操作,如果无法通过cmpxchg改变,通过jne跳回等待。

参考文章:https://blog.csdn.net/21aspnet/article/details/88571740

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值