Volatile内存屏障&指令重排

前言

在上一篇笔记中提到了volatile关键字的作用,我们知道并发编程的三大理论是可见性、有序性和原子性,而volatile只能保证可见性和有序性,有序性是什么呢?有序性就是让程序能够有序的执行,volatile可以禁止指令重排,但是指令重排在应用中其实很常见的,比如简单的例子就是当cpu在执行一条指令的时候,执行完发现这个值已经被修改了,是处于无效的状态,这是cpu缓存架构中的缓存一致性协议中MESI协议,那么这个时候寄存器需要从新加载变量,那么在加载的过程中,cpu是不会闲着的,所以这个时候cpu会去执行其他指令,所以说cpu的执行时乱序的,也就是指令重排了。

内存屏障

硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:1. lfence,是一种Load Barrier 读屏障2. sfence, 是一种Store Barrier 写屏障3. mfence, 是一种全能型的屏障,具备lfence和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等指令。
内存屏障有两个能力:1. 阻止屏障两边的指令重排序2. 刷新处理器缓存/冲刷处理器缓存
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。通过以上可以知道java中的关键字volatile其实是一种lock屏障,lock本身不是内存屏障,但是它能完成类似于内存屏障的功能。
上面的解释简单来说就是增加了内存屏障过后,就是在操作本身的变量之前,能够看到在主存中的最新值,可以刷新cpu的高速缓存。

CPU缓存架构之伪共享

伪共享是什么呢?我们在之前的笔记中讲到,cpu中的缓存一致性协议是锁的缓存行,但是在cpu缓存架构中,如果我们多线程频繁的对一些变量进行频繁操作,那么线程之间的数据就频繁失效,频繁的去主存中加载;前面说了缓存行的大小最大为64byte,那么如果多个线程都在同一个缓存行中频繁操作,那么就会出现缓存失效,然后又要重新加载,我们将这种不合理的资源竞争的关系称为伪共享,具体我们可以看下图:
在这里插入图片描述
如上图所示,当启用了缓存一致性协议的情况下,主存中有两个变量x和y,有两个线程都在操作x和y,而x和y都处于同一缓存行中,那么这个时候如果Threa1操作了x,那么总线嗅探机制嗅探到过后Thread2发现自己的x失效了(MESI),又重新加载x,而Threa2修改了y过后,Thread1又要重新加载y,这样反反复复的操作就是称为伪共享,那么如何避免这种伪共享呢?伪共享是会有性能开销的,那么我们如何来避免呢?我们知道缓存一致性协议锁的是缓存行,而缓存行默认是64byte,那么我们是不是可以通过占满一个缓存行来达到目的呢?避免重复的去主存加载最新的值,比如我们的一个类里面有两个long的变量,我们知道long占8byte,那么我们每次操作的时候将缓存行占满是不是就可以避免伪共享了,占满过后,每次操作就是自己共享一个缓存行,那么这样就可以避免伪共享的出现:

public class T0918 {
    
    


    public static void main(String[] args) throws InterruptedException {
    
    
        Ponint ponint = new Ponint();
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(()->{
    
    
            for(int i = 1; i<= 100000000 ; i++){
    
    
                ponint.x ++;

            }

        },"Thread-t1");

        Thread t2 = new Thread(()->{
    
    
            for(int i = 1; i<= 100000000 ; i++){
    
    
                ponint.y ++;

            }

        },"Thread-t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long end = System.currentTimeMillis();
        System.out.println("耗时="+(end - start));

    }


    static  class Ponint{
    
    


        public volatile  long x;
       
        public volatile  long y;

    }
}

执行完成过后如图:
在这里插入图片描述
差不多3s多是吧,那么我们来修改下程序,修改下类Point如下:

static  class Ponint{
    
    


    public volatile  long x;
    long p1,p2,p3,p4,p5,p6,p7;
    public volatile  long y;

}

在这里插入图片描述
是不是少了2s中,为甚呢?就是上面所说的我们通过添加7个long变量来防止cpu缓存架构出现伪共享,你想哈,如果存在伪共享,那么是不是两个线程每次执行的时候都要出现重新从主存加载,那么这个时候就会性能损耗,当然耗时也比较多;但是在实际的开发过程中,我们这样写的话有点不太好看,别人读的你的代码就发现有点膈应,在JDK8中其实提供了一种实现,JDK8提供了一种注解来标明可以占满缓存行,但是需要增加启动参数-XX:-RestrictContended
在这里插入图片描述
是不是也一样的能够达到效果啊,所以这个例子就是伪共享的发现与解决。

指令重排

指令重排在上面已经大概介绍了下都知道在java中通过volatile关键字可以避免指令重排,那么指令重排用白话文该怎么解释呢?我这边用一个简单的自己也是平时很常见的来说明下,比如大家在开发过程中或者读框架底层源码都知道单例模式的懒汉模式中有个双重检查(doublechaeck)

public class SingleFactory {
    
    

    private SingleFactory(){
    
    }

    public static volatile SingleFactory factory;

    public  static SingleFactory getInstance(){
    
    
        if(factory == null){
    
    
            synchronized (SingleFactory.class){
    
    
                if(factory == null){
    
    
                    factory = new SingleFactory();
                }
            }
        }
        return factory;

    }
}

上面的双重检查为什么要这样做?加入同时过来几个线程都要得到SingleFactory 的实例,第一重检查可能所有线程都会进,那么synchronized 中的第二重检查只有第一个线程可以进,其他线程发现不为空就会直接返回对象了。但是有没有注意到对象实例factory是增加了vaolatile关键字的,为什么要这样做呢?而在上面的单例模式中,增加这个关键字的目的其实就是为了防止指令重排,大家都知道程序在执行factory = new SingleFactory()的时候分为三步,那三步呢?1.在堆区开辟一块cell空间来存储SingleFactory对象,并获取了内存地址;2.堆factory对象进行初始化;3.将factory对象指向堆区开辟空间的内存地址。比如说在上面的流程中,假如factory对象初始化的逻辑复制,初始化的内容非常多,如果指令重排了,在多线程环境下可能出现3在2还在初始化完就执行了,这个时候指令重排了,那么拿到factory这个对象的线程可能会操作factory的数据,那么这个时候可能还没初始化完,所以就会出问题,所以加了volatile关键字就是为了防止指令重排而带来线程数据安全问题。来聊聊指令重排,声明解释了单例工厂的禁止指令重排,在指令重排的概念里面有两个,as-if-serial和happens-before

as-if-serial

这个是什么意思呢?专业的解释就是不管指令如何重排,在单线程的环境下,不会影响最终的执行结果,编译器、runtime和处理器都必须准守as-if-serial语义,为了准守as-if-serial语义,编译器和runtime、处理器在数据间没有依赖关系的时候对指令进行重排序以达到性能的最优;但是如果数据间存在依赖关系,指令重排序过后会影响最终的执行结果,这样就不能进行指令重排序。什么意思呢?我们来看一段代码:

int a = 3;//A
int b =4;//B
int c = a + b * 4;//C

代码A 、B、C的顺序如上,as-if-serial的意思就是A与C有依赖关系,B与C也有依赖关系,所以C不能再A B之前执行,也就是再怎么重排序,也是必须A和B执行完成过后才能执行C;在比如:

A:
int a=3;
int b=4;
a = a + 3;
int c = a *b;

上面的代码中有4行代码,在编译过后运行的过程中编译器是可以进重排序以达到性能最优,比如我们看第一行代码和第三行代码是可以一并执行的,所有这段是代码是可以进行重排序的,重排序过后不会影响执行结果。那我们再来看如果代码这样写

B:
int a=3;
a = a + 3;
int b=4;
int c = a *b;

假如,我说假如,编译器是顺序执行,不会指令重排,那么到底是A的执行效率高还是B的执行效率高呢?可能有些人认为执行效率一样啊,其实执行效率是B要高,为什么呢?上一篇笔记中我们记录到了CPU的缓存架构就说过,说这个之前,我们先来看一张图:
在这里插入图片描述
CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率。CPU缓存延迟,单位是CPU时钟周期,可以理解为CPU执行一个指令的时间
上图的意思就是CPU执行一个时钟周期需要从不同的内存中加载数据的时钟周期数,从主存中加载需要167个时钟周期,从高速缓存中加载就很快,时钟周期也很短,所以也就解释了为什么CPU需要L1L2L3高速缓存了,而不直接从主存加载;了解了这个过后,来看下上篇笔记中的一个图
在这里插入图片描述
比如我们执行:int a=3;int b=4;a = a + 3;int c = a *b;首先如果a=3完成过后,执行b=4,然后在执行a=a+3,那么这个时候是不是还要去主存中加载a过来,才能进行运算,那么这个时候又要加载一次,根据上面的cpu时钟周期图可以知道,从主存加载过来是有性能开销的,所以虽然这段代码不影响执行结果,但是如果不重排序的话,性能肯定没有B段代码的性能高的;当然了,在正常的执行过程中这段代码是要进行指令重排序的,这样做的性能将会是最优的。

happens-before

先行发生原则,从JDK1.5开始,happens-before用来表述在多线程之间的内存可见性,在JMM模型中,如果一个线程的操作必须对另一个线程可见,那么这两个线程必须存在happens-before关系;happens-before原则非常重要,它是判断数据是否竞争,线程数据是否安全的重要依据,依靠这个原则,我们解决两个线程是否存在资源冲突的所有问题,简单来说就是如果线程执行过程中修改的数据必须对其他线程可见,那么线程之间执行就必须存在happens-before关系;happens-before原则如下:1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果必须对第二个操作可见,而且必须是第二个操作在第一个操作之前;2.如果两个操作之间存在happens-before关系,并不意味着两个操作非要按照happens-before来执行,编译器优化过后的指令重排序只要不影响执行结果,那么也是可以的。这边定义了happens-before的的原则:

1.程序次序规则:在一个线程中,按照代码的顺序,编写在前面的先执行,
编写在后面的后执行;

2.锁规则:在多线程中,某个线程在某个时刻unlock操作必须在lock操作之后执行;

3.volatile规则:对一个变量的写操作先行发生与后面线程对这个变量的读操作;

4.传递规则:如果操作A先行发生与B,而B又先行与发生C,那么A是先行与发生C;

5.线程启动规则:线程的Thread的start方法先行与线程内的任何操作;

6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断
线程的代码检测到中断事件的发生;

7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,
我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段
检测到线程已经终止执行;

8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

我们来详细看看上面每条规则(摘自《深入理解Java虚拟机第12章》):
程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:

private int i = 0;
 
public void write(int j ){
    
    
    i = j;
}
 
public int read(){
    
    
    return i;
}

我们约定线程A执行write(),线程B执行read(),且线程A优先于线程B执行,那么线程B获得结果是什么?;我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 + 推导的6条可以忽略,因为他们和这段代码毫无关系):由于两个方法是由不同的线程调用,所以肯定不满足程序次序规则;两个方法都没有使用锁,所以不满足锁定规则;变量i不是用volatile修饰的,所以volatile变量规则不满足;传递规则肯定不满足;所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但是就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?满足规则2、3任一即可。
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。

JIT指令重排序

不知道大家有没有感觉到在平时的开发过程中以及系统运行过程中,系统刚开始运行的时候性能不如后面的好,简单来说就是你重复运行一段代码比如for while等代码块的时候,性能越来越好。如果没有这种感觉证明你对JVM了解的太少,我们的JVM解释器有两种,模板解释器和字节码解释器,字节码解释器是需要将java字节码解释成C++代码,然后在生成硬编码(汇编),而模板解释器是直接将java字节码通过模板解释器解释成硬编码;而我们的JTI即时编译在什么情况下触发呢?我这边有个疑问,JTI即时编译是否就是通过模板解释器直接编译成了汇编代码,这里就涉及到了热点代码以及热点代码缓冲区,热点代码针对的是可以是方法,可以是代码块,java查看热点代码缓冲区的大小可以根据下面的命令:

java -XX:+PrintFlagsFinal -version|grep CompileThreshold
     intx CompileThreshold                          = 10000           {
    
    pd product}
    uintx IncreaseFirstTierCompileThresholdAt       = 50              {
    
    product}
     intx Tier2CompileThreshold                     = 0               {
    
    product}
     intx Tier3CompileThreshold                     = 2000            {
    
    product}
     intx Tier4CompileThreshold                     = 15000           {
    
    product}
java version "1.8.0_11"
Java(TM) SE Runtime Environment (build 1.8.0_11-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.11-b03, mixed mode)

CompileThreshold 就是热点代码的缓冲区大小,如果一个代码块for或者while,jvm不可能每次都去编译执行,当执行达到了热点代码的阈值过后,就回将热点代码缓存起来,下次执行就直接使用汇编代码去执行,所以为什么在一些热冷备的系统中,突然切换到冷机过后,如果这个时候并发上来了,那么有可能就会出现系统直接挂掉,因为这个时候热点代码缓冲区还没有,就全部采用重新编译执行,我们来看我们编写的代码的编译过程:
在这里插入图片描述
基本上是按照这个流程编译执行的,但是我们来思考我们的编译器在那个过程可能会出现指令重排序,首先1-2的过程肯定不会出现指令重排序的情况,因为如果1-2发生了指令重排序,那么我们的反编译就没有办法做了,反编译的时候那个知道你的代码顺序;其实JIT就是在2-3产生的,热点代码也是在这里产生的,JTI产生的汇编指令代码的时候,当触发了热点代码,就会放入热点代码缓冲区,下次执行就直接读取指令进行执行,那么指令重排序也就发生在这里,我们在前面说过,指令重排序在单线程环境下,不能影响最终的执行结果,也就是要遵守as-if-serial,而在多线程环境下,也要遵守happens-before原则,也就是要保证线程的数据安全。
总结下前面:

1. 指令重排序是编译器优化的结果;
2. 在单线程环境下指令重排序不能影响最终的执行结果,也就是遵循as-if-serial;在多线程环境下,遵守happens-before原则,保证线程的数据安全;
3. JIT即时编译器在编译成汇编指令时可以进行指令重排序
4. CPU执行时乱序的,为什么乱序,比如CPU在执行某一条指令的时候,发现该数据失效了,需要重新加载,那么这个时候是不会等着的,会去先执行其他指令,也就是乱序,乱序的特性一定会提升性能的,所以乱序也可以在一定的程度上称为指令重排序。

指令重排序的手段

在前面已经很详细的说了指令重排序和内存屏障,其实内存屏障就是让所有的线程可见;而指令重排序是能提升我们的程序的执行性能的,所有在合适的条件下指令重排序是允许并且是很有必要的,但是有些情况就要禁止指令重排序的,比如我们上面说的懒汉单例模式,不知道大家在看JDK底层源码的实现的时候有没有看到相关指令重排序的代码,我这里分享下我自己看到的:

public LinkedBlockingQueue(Collection c) {
    
    
    this(Integer.MAX_VALUE);
    final ReentrantLock putLock = this.putLock;
    putLock.lock(); // Never contended, but necessary for visibility
    try {
    
    
        int n = 0;
        for (E e : c) {
    
    
            if (e == null)
                throw new NullPointerException();
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node(e));
            ++n;
        }
        count.set(n);
    } finally {
    
    
        putLock.unlock();
    }
}

上面的代码是LinkedBlockingQueue的构造方法,上面的就用到了禁止指令重排序的功能我们看第四行代码的解释,Never contended, but necessary for visibility(从未竞争过,但对可见性是必要的) Doug Lea对这句话的解释很明显了,这个加的锁毫无用处,简简单单的就是开启了内存屏障,进而禁止指令重排序,因为 this(Integer.MAX_VALUE);这行代码可能只需过长导致没有初始化完成,进而程序出现问题;我们前面已经说过volatile关键字其实就是生成了汇编指令lock,告诉cpu我这个变量启用了缓存一致性协议,也就是说对于X86的机器来说,内存屏障启用是根据lock指令来实现的,lock指令告诉CPU启用缓存一致性协议。
我们来看下一段代码指令重排序的例子:

public class T0918 {
    
    
    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;

            /**
             *  x,y:
             */
            Thread thread1 = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    shortWait(20000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    b = 1;
                    y = a;
                }
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            System.out.println("第" + i + "次(" + x + "," + y + ")");

            if (x==0&&y==0){
    
    
                break;
            }

        }

    }

    public static void shortWait(long interval){
    
    
        long start = System.nanoTime();
        long end;
        do{
    
    
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

上面代码的执行结果可能出现哪几种情况呢?x=?y=?上面的程序会启动两个线程,对x和y进行赋值,会出现哪几种情况呢?1,1, 0,1 会不会出现0,0呢?因为上面没有禁止指令重排序和内存屏障,那么0,0的可能也会出现的,因为是乱序的,所以执行结果如下:
在这里插入图片描述
那么我们手动编写一个开启内存屏障的代码看下

public class UnSafeFactory {
    
    


    public static Unsafe getUnsafe() {
    
    
        try {
    
    
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            return (Unsafe) field.get(null);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        return null;
    }
}
public class T0918 {
    
    
    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;

            /**
             *  x,y:
             */
            Thread thread1 = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    shortWait(20000);
                    a = 1;
                    UnSafeFactory.getUnsafe().storeFence();
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    b = 1;
                    UnSafeFactory.getUnsafe().storeFence();
                    y = a;
                }
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            System.out.println("第" + i + "次(" + x + "," + y + ")");

            if (x==0&&y==0){
    
    
                break;
            }

        }

    }

    public static void shortWait(long interval){
    
    
        long start = System.nanoTime();
        long end;
        do{
    
    
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

UnSafeFactory.getUnsafe().storeFence();开启了内存屏障所以执行了很久还是:
在这里插入图片描述
一直在执行,没有停下来但是我们还是不建议大家在代码中使用Unsafe类,比较危险,因为这个类直接操作了内存,在很多大厂的代码编程规约中,是不允许直接使用UnSafe这个类的,当然了除了使用这个类,加volatile关键字一样的可以开启内存屏障,禁止指令重排序

private static volatile int x = 0, y = 0;

private static  volatile  int a = 0, b = 0;

代码已经执行了10分钟了,还是在运行如:
在这里插入图片描述
所以使用volatitle一样可以开启内存屏障和禁止指令重排序。

volatile重排序规则

在这里插入图片描述
结论:

  1. 第二个操作是volatile写,不管第一个操作是什么都不会重排序
  2. 第一个操作是volatile读,不管第二个操作是什么都不会重排序
  3. 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

JMM内存屏障插入策略:
5. 在每个volatile写操作的前面插入一个StoreStore屏障
2. 在每个volatile写操作的后面插入一个StoreLoad屏障
3. 在每个volatile读操作的后面插入一个LoadLoad屏障
4. 在每个volatile读操作的后面插入一个LoadStore屏障
5.
注意:X86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障

在这里插入图片描述
在这里插入图片描述
上面的代码是openjdk8的内存屏障的实现,看到上面的lock指令了吗?没错,jdk底层就是通过lock指令实现的内存屏障总结下:

  1. volatile可以禁止指令重排序
  2. volatile可以通过汇编lock指令开启mfence(内存屏障)
  3. 多线程中volatile保证了内存的可见性和有序性(禁止指令重排序)

猜你喜欢

转载自blog.csdn.net/scjava/article/details/108673723