乱序问题-内存屏障认识

 乱序问题

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

对于现代cpu而言,性能瓶颈则是对于内存的访问。cpu的速度往往都比主存的高至少两个数量级。因此cpu都引入了L1_cache与L2_cache,更加高端的cpu还加入了L3_cache.很显然,这个技术引起了下一个问题:

     如果一个cpu在执行的时候需要访问的内存都不在cache中,cpu必须要通过内存总线到主存中取,那么在数据返回到cpu这段时间内(这段时间大致为cpu执行成百上千条指令的时间,至少两个数据量级)干什么呢? 答案是cpu会继续执行其他的符合条件的指令。比如cpu有一个指令序列 指令1  指令2  指令3 …, 在指令1时需要访问主存,在数据返回前cpu会继续后续的和指令1在逻辑关系上没有依赖的”独立指令”,cpu一般是依赖指令间的内存引用关系来判断的指令间的”独立关系”,具体细节可参见各cpu的文档。这也是导致cpu乱序执行指令的根源之一。

写操作也可以进行合并

当cpu执行存储指令时,它会首先试图将数据写到离cpu最近的L1_cache, 如果此时cpu出现L1未命中,则会访问下一级缓存。速度上L1_cache基本能和cpu持平,其他的均明显低于cpu,L2_cache的速度大约比cpu慢20-30倍,而且还存在L2_cache不命中的情况,又需要更多的周期去主存读取。其实在L1_cache未命中以后,cpu就会使用一个另外的缓冲区,叫做合并写存储缓冲区。这一技术称为合并写入技术。

在请求L2_cache缓存行的所有权尚未完成时,cpu会把待写入的数据写入到合并写存储缓冲区,该缓冲区大小和一个cache line大小,一般都是64字节。这个缓冲区允许cpu在写入或者读取该缓冲区数据的同时继续执行其他指令,这就缓解了cpu写数据时cache miss时的性能影响。

当后续的写操作需要修改相同的缓存行时,这些缓冲区变得非常有趣。在将后续的写操作提交到L2缓存之前,可以进行缓冲区写合并。 这些64字节的缓冲区维护了一个64位的字段,每更新一个字节就会设置对应的位,来表示将缓冲区交换到外部缓存时哪些数据是有效的。当然,如果程序读取已被写入到该缓冲区的某些数据,那么在读取缓存数据之前会先去读取本缓冲区的。

经过上述步骤后,缓冲区的数据还是会在某个延时的时刻更新到外部的缓存(L2_cache).如果我们能在缓冲区传输到缓存之前将其尽可能填满,这样的效果就会提高各级传输总线的效率,以提高程序性能。

如果我们能在缓冲区被传输到外部缓存之前将其填满,那么将大大提高各级传输总线的效率。如何才能做到这一点呢?好的程序将大部分时间花在循环处理任务上。

这些缓冲区的数量是有限的,且随CPU模型而异。例如在Intel CPU中,同一时刻只能拿到4个。这意味着,在一个循环中,你不应该同时写超过4个不同的内存位置,否则你将不能享受到合并写(write combining)的好处。

乱序证明,如下:

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 + ")";

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

    }

}

上面程序,确实会出现x=0,y=0的情况,证明了指令会乱序。

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

为什么会有内存屏障

每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。

用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。

硬件层面内存屏障

硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障

1. lfence,是一种Load Barrier 读屏障。在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据

2. sfence, 是一种Store Barrier 写屏障。在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力

4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

Lock前缀实现了类似的能力.

1. 它先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。

2. 在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。Lock后的写操作会让其他CPU相关的cache line失效,从而从新从内存加载最新的数据。这个是通过缓存一致性协议做的。

  • 内存屏障有两个作用:
  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

Java内存屏障

java内存屏障分为四种(为硬件层内存屏障的组合情况):LoadLoad,StoreStore,LoadStore,StoreLoad,借此完成一系列的屏障和数据同步:

LoadLoad:load1;LoadLoad;load2;在load2及之后要读取的数据被访问之前,保证load1要读取的数据已经被读取完毕;

StoreStore:store1;StoreStore;store2;在store2及之后的写操作执行之前,保证store1的写入操作对所有处理器可见;

LoadStore:load1;LoadStore;store2;在store2及之后的写操作执行之前,保证load1要读取的数据已经被读取完毕;

StoreLoad:store1;StoreLoad;load2;在load2及之后要读取的数据被访问之前,保证store1的写入对所有处理器可见。(开销最大,在大多数处理器的实现中,该屏障为万能屏障,包含其他三种屏障的功能)

Java内存屏障应用

volatile的实现细节

如下程序:

public class TestVolatile {

    int i;

    volatile int j;

}

字节码层面

 只是添加了一个 ACC_VOLATILE

JVM层面

Volatile的内存屏障采用“悲观”的态度来使用内存屏障

在volatile修饰的变量写操作之前插入StoreStore屏障,在写操作之后插入StoreLoad屏障,以确保该变量写入的值对其他线程可见

在volatile修饰的变量读操作之前插入LoadLoad屏障,在读操作之后插入LoadStore屏障

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信

synchronized实现细节

如下:

public class TestSync {

    synchronized void m() {

    }

    void n() {

        synchronized (this) {

        }

    }

    public static void main(String[] args) {

    }

}

字节码层面

方法上加 ACC_SYNCHRONIZED

方法块上

是通过monitorenter(入锁)和monitorexit(出锁)还有一个monitorexit是在运行出异常的时候,执行的。

JVM层面

C C++ 调用了操作系统提供的同步机制

这块后续再研究,探讨....

猜你喜欢

转载自blog.csdn.net/huzhiliayanghao/article/details/106874122