Volatile 详解

认识Volatile

首先,java中地Volatile可以解决可见性,指令重排序问题。

例如,以下代码,

package pop.thread.demo3;

/**
 * @author Pop
 * @date 2019/6/18 22:48
 */
public class VolatileDemo {

    public volatile  static boolean volatile_stop = false;
    public static boolean unvolatile_stop=false;
    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(()->{
            int i =0;
            while(!volatile_stop){
                i++;
            }
        });
        thread.start();
        Thread.sleep(1000);
        volatile_stop =true;
    }

}

如果我们使用volatile_stop来作为停止线程工作地标签,在一秒后,线程将会完成他地任务,并且退出线程,而unvolatile_stop的改变将不会被让程序停止下来,他将一直运行下去,这里我们可以得到初步地结论。

加了volatile得变量,他们改变对于其他线程是可感知地,也就是说被修饰地变量对于其他线程而言是具有可见的性,而未加地变量如果发生改变,其他线程是感觉不到地,也就是不可见的。

Volatile的意义

如果你cpu核心只有一颗,无论读操作还是写操作都是它去操作,所以它自己是知道此刻这个变量地状态是什么,所以不存在可见性地问题。

当cpu核心数多了后,硬件层面可以支持地线程多起来后,能操作这个变量的人也多了起来,那么当线程A去改变变量地值地时候,线程B又怎么知道此时这个变量是否是最新的值,又或者这个变量会不会发生了变化。

变量明明已经发生了变化,线程B还拿着还未更新的以前地变量去操作,势必会得到错误地预期结果。

从硬件层面谈起

总的来说,能够较大影响一台计算机性能的组件

  • CPU
  • 内存
  • IO设备

当然,我们知道一台计算机性能地高低,取决于三者地共同表现,例如电脑地磁盘的IO操作慢,那么你地内存再大,CPU能够支撑的线程数再多也没有意义。

这三者的处理速度越靠近CPU越快。CPU>内存>IO设备。

同时,为了提高他们各自的性能,也提出了许多地概念并给予了实现。

CPU高速缓存

我一直将CPU使用数据进行计算看做是一个人在吃饭。数据就是面包。

IO设备相当于用来放面包的盘子,内存是我们的手,已经有一部分面包被我们从盘子中拿起,而高速缓存则是我们的嘴巴,它已经在咀嚼送入嘴里地面包了。

我们地最终目的是为了吃面包,而要送入嘴巴过程需要经过盘子(IO设备)、手(内存)、嘴(高速缓存)

。同样我们也很明了,嘴是最容易处理面包步骤,因为我们只需要咀嚼就可以吃饱,而前两者则需要,拿起来送进嘴里去,这样的步骤。

回到硬件层面,高速缓存的引入也是为了这样的情况,cpu处理速度其实是很快的,但为他加载需要得数据确是漫长得过程,从磁盘到内存,然后在拿内存中的数据进行计算,这过程中发生了阻塞都会影响CPU的计算性能。

为了更好解决这个问题,高速缓存被提了出来,高速缓存可以理解成是存在CPU的一块内存,之前我们说过越靠近CPU,交互的速度也就越快,而高速缓存对于CPU来说就是触手可及的距离。

在这里插入图片描述

当然,我们从任务管理器也可以看到这些缓存的实际大小

在这里插入图片描述

高速缓存中也分了级别,所有线程公用的L3级别缓存,和每个核心都自己独有地L2和L1缓存。

这里,其实我们地CPU性能已经得到一定程度的提升,因为我们每次都会从主内存中将一些数据复制到我们的高速缓存中,这样就可以提高的处理地速度,可是这就存在一个问题,那就是缓存一致性

缓存一致性协议

其实我们只需要读写数据的时候保持一致就可以了。

最常见的缓存一致性协议就是MESI协议。表示缓存行的四种状态。

  • M(Modify)已修改,表示共享数据存在于CPU缓存中,并且是被修改的状态,也就是缓存的数据和主内存数据不一致。
  • E (Exclusive)表示缓存的独占状态,数据只缓存当前CPU缓存中,并且没有被修改。
  • S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致。
  • I(Invalid)表示缓存已经失效。

在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且还需要监听(snoop)其它Cache的读写操作,以方便改变缓存行的状态,和发送缓存行的状态。

对于MESI协议,从CPU读写角度来说,会遵循以下原则。

  • CPU读请求
    • 缓存处于M、E、S状态都可以被读取,意味着如果该缓存行处于已经被修改,已经被独占,已经被多个缓存缓存的时候,可以从高速缓存直接读取。而处于I的阶段,也就是失效的阶段,代表该缓存已经失效,CPU只能从主存中读取。
  • CPU写请求。
    • 缓存处于M、E状态的时候才可以被写。对于S状态的写操作,必须将其他缓存中的存在的该缓存行置为无效才可以写。(I)

所以取决于CPU是否要去高速缓存拿数据还是主内存内存去那数据的关键在于根据MESI协议,该内存行是否已经无效,如果无效,将从主内存拿最新的值,否则就在高速缓存中进行交互。

在这里插入图片描述

缓存管理器负责通知其它缓存在其他CPU中的缓存行是否处于MESI状态的

广播者。

再谈可见性

回到可见性话题上,从上图中我们看到,CPU1对自己高速缓存的修改,对于其它CPU2是不可见的,导致CPU2拿到的数据是脏数据,这是我在前文谈及多次的东西。

但是这里有一个问题,为什么在CPU层面上,已经存在总线锁和缓存锁,当我们执行

package pop.thread.demo3;

/**
 * @author Pop
 * @date 2019/6/18 22:48
 */
public class VolatileDemo {

    public volatile  static boolean volatile_stop = false;
    public static boolean unvolatile_stop;false;
    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(()->{
            int i =0;
            while(!volatile_stop){
                i++;
            }
        });
        thread.start();
        Thread.sleep(1000);
        stop=true;
    }

}

还是没办法让程序一秒后停止呢?

也就是为什么还需要volatile关键字,为什么还会存在可见性问题呢?

MESI优化所带来的可见性问题

之前我们说过,通过MESI协议可以保证硬件层面的可见性。

但是,由于MESI协议中将一个S(Shared)状态的变量变成M(Modify)状态的时候,需要将其他处理器中的缓存设置为I(Invalid)。

而每个缓存行拥有自己的缓存管理器,他将发送通知指令告诉其他CPU,这条缓存已经失效,你们需要从主存获取最新的值。

问题就在这里,当CPU通知其它CPU这条缓存行失效的并收到反馈的这个过程中,CPU是处于阻塞状态的,这也是一个性能的瓶颈。而为了优化MESI协议,工程师将 store bufferes引入到CPU中。

store bufferes其实就是位于cpu和高速缓存中的另一块内存区域,由于发送缓存无效的请求期间CPU阻塞,所以我们将发送请求的任务交与store bufferes,也就说,当修改缓存行内容的时候,把通知其它cpu该缓存失效的任务让store bufferes,去执行,cpu接着执行下面的任务。类似异步的任务。

在这里插入图片描述
这种优化存在两个问题

  • 数据什么时候提交不确定,因为这是一个异步请求,即便是交个了storebuffer来做,其它cpu回复我己经设置缓存行无效的时机我也不确定。
  • 引入了storeBuffer后,处理器会先从storebuffer从读取,如果有数据就从stroreBuffer中读取,否则就从缓存行读取。
int value = 2; //共享状态 S
void cpu0(){
    value = 10; 
    flag = true; //独占状态 E
}
void cpu1(){
    if(flag){ // 这个时候还未收到失效通知
        assert value == 10;
    }
}

对于CPU0而言,他缓存了value(S),和flag(E)。

当CPU0执行value=10的时候,value由S变成了M,CPU0将通知写入到store bufferes中,让他代为转发。接着执行flag=true

由于flag在CPU0是独占状态,可以直接修改,不需要写入到storeBuffer中。

这个时候之前value=10失效通知还在store bufferes中,他也在等待其他cpu等待通知。

这个时候cpu1发起读请求,因为flag是E,可以直接从高速缓存中读取,所以flag=true可以通过,当去运行assert value == 10;的时候,恩很有可能,CPU1没有受到CPU1storebuffer的通知,而还是用原来的值进行计算,那么这个时候value的值应该是2,所以判断不等于10。

这其实就是我们总说是CPU的乱序执行,也可以说是重排序。他们将会带来可见性问题。

很明显,这就回到了一开始的缓存一致性的问题上,我们仿佛进入了一开始的死循环。

从软件层面谈起

既然硬件层面无法解决,我们可不可以在软件层面做尝试呢?

工程师认为,貌似我解决了CPU层面上的缓存一致性问题,而在软件层面,你们又存在指令的乱序执行,这我很难办,不如我给你定义规范,你们按照自己的语言模型各自实现算了。

在CPU这边,提出了内存屏障的概念。

所谓的内存屏障,就是将store bufferes中的通知缓存行的失效等指令,直接写入到主存中去,明确规定,你们就是要在主存上读写,不要再各自的高速缓存区域玩了。以此来达到可见性问题。

memory barrier在X86的系统上有三种

  • StoreMemoryBarrier(写屏障),在处理任何的write操作之前,强制将存储到store bufferes中的数据同步到主内存,也就是说,在写屏障之前的所有指令对于屏障之后的所有读写指令都是可见的。
  • LoadMemoryBarrier(读屏障),在读屏障之后的所有读操作,必须在读屏障之后执行。配合写屏障,让写屏障所有的内存更新对于读屏障是完全可见的。
  • FullMemoryBarrier(全屏障),算是懒人版本,他可以保证内存读写操作提交之后,再执行屏障后的读写操作,保证屏障后的读写操作使用的是最新数据。
再回到之前的案例
int value = 2; //共享状态 S
void cpu0(){
    value = 10; //写屏障,强制将stroebufferes的内容更新到主存
    storeMemoryBarrir();
    flag = true; //独占状态 E
}
void cpu1(){
    if(flag){ 
        //使用读屏障,从内存中直接获取
        loadMemoryBarrier();
        assert value == 10;
    }
}

所以,内存屏障是为了解决CPU对内存的乱序执行而提出的,用来保证共享数据在多线程并行执行下的可见性。

JMM

JMM全程是Java Memory Model。那么什么是JMM呢

其实上面说了那么多,基本就是提出一个新的解决方法,而产生一个新的问题,然后解决提出解决这种问题的技术,然后又出现了另外的技术。

而前面通过分析我们可以看出,store bufferes虽然可以解决等待其他CPU等待的回复的阻塞问题,但是也导致了软件层面的指令重排序和有序性问题。

那么JAVA作为一款一次编写,到处运行的语言,自然需要设置自己规则来让自己的语言避免重排序,有序性问题。

JMM是一种语言级别的抽象内存模型,也就是对硬件模型的抽象,JMM将保证,由JMM规范后的指令,输入到CPU的执行的时候,是不会重排序,是有序性的。

它定义了共享内存中多线程程序读写操作的行为规范,在虚拟机中把共享变量存储到内存以及从内存读取的时候共享变量的时候该如何保证有序性。

通过这些规则来规范对内存的读写操作而保证指令的正确性,它解决CPU的多级缓存,处理器优化,指令重排序导致的内存访问问题,保证了并发场景下的可见性。

但是!!!!

对于JMM而言,他只是对硬件层面的抽象内存模型,方便java程序在上面更改的适配,从而解决运行的兼容问题。

JMM抽象模型分为主内存和工作内存;主内存是所有线程共享的,一般是是实例对象、静态字段,数据等对象存储在堆内存中的变量。

工作内存是每个线程独有的,线程对变量的操作直接在工作内存中完成,不能直接操作主内存,线程之间共享变量值的传递都是基于主内存在完成。

在这里插入图片描述

可是JMM并没有限制执行引擎使用CPU的寄存器或者告诉缓存来提升执行顺序,也没有限制指令进行重排序。

也就是说。JMM只是将底层的问题抽象到了JVM层面,也就是软件层面,在基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。

既然硬件层面的缓存一致性问题,和指令重排序问题,那么在JVM这个层面也同样存在。

而Java内存模型底层实现可以简单的问题:通过内存屏障(memory barrier)禁止重排序,即编译器将会更具硬件底层体系架构,将这些内存替换成具体的CPU指令。

对于编译器而言,内存屏障将会限制它的重排序优化。

对于处理器而言,内存屏障将会导致缓存的刷新操作,而保证读写操作对其它线程可见。

例如,对于volatile,编译器将在volatile字段的读写操作前后插入一些内存屏障。

JMM是如何解决可见性有序性问题

JMM提供了禁用缓存,以及禁止重排序的方法,来解决可见性和有序性问题。

volatilesychronizedfinal

从源代码到最终执行的指令,可能会经过三种重排序。

源代码->1.编译器级别优化重排序->2.指令级并行重排序->3.内存系统指令重排序->最终执行指令序列。

1和2属于处理器重排序,这些重排序可能会导致可见性问题。

编译器的重排序:JMM明确禁止在某个位置进行重排序操作。

处理器重排序:JMM要求编译器生成指令的之后,会插入内存屏障来禁止处理器重排序。

不能重排序的地方

不过,也并不是所有的程序都会出现重排序问题。

编译器和CPU的重排序会遵循依赖性原则。编译器和处理器不会改变数据依赖关系。

a=1;b=a;//1
a=1;a=2;//2
a=b;b=1;//3

如果单线程对1,2,3种情况进行重排序,会导致结果不一样,所以编译器不会这类指令进行优化。这也被称为 as-if-serial

int a = 2;//1
int b = 3;//2
int c = a+b;//3

1和3,2和3存在依赖,所有3的位置一定是在1,2之后,不然编译无法通过。但是1和2却可以重新排序,因为他们的执行顺序不一致也不会影响结果。

JMM层面的内存屏障。

在这里插入图片描述

HappenBefore

他表示前一个操作结果对于后续的操作是可见的,这不是一个技术,而是一个概念,添加了内存屏障后,将保证屏障前的数据的读写对屏障后的读写可见。

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须存在happens-before关系,这来两个操作可是同一个线程,也可以是不同的线程。

JMM中哪些方法建立了happend-before规则

程序顺序原则

class VolatileExample{
    
    int a = 0;
    volatile boolean flag = false;
    public void writer(){
        a = 1;      //1
        flag = true; //2
    }
    public void reader(){
        if(flag){     //3
            int i =a; //4
            //...
        }
    }
    
}

1 happens-before 2;3 happens-before4;因为这个顺序不管如何变,结果都是一样的。

volatile 变量规则

对于volatile的修饰的变量写操作,一定happen-before后续的对于volatile变量的读操作

所以上面的代码,2 happens-before 3

传递性规则

还是以上面的代码为例子,如果 1 happens-before 2;3 happens-before 4;那么根据传递性,1 happens-before 4

start 规则

public StartDemo{
    
    int x = 0;
    Thread t1 = new Thread(()->{
        
        //t1.start()之前的操作一定优先于t1内的操作
        //所以这里的x是10,而不是0
        print(x);// 10
    });
    x = 10;
    t1.start();
}

在线程启动前的数据将会保证一定会在后续线程得到一定是最新的。

join 规则

Thread t1 = new Thread(()->{
   // 此处对共享变量x修改
    x = 100;
});
t1.start();//内部已经对x进行修改
t1.join();//子线程对共享变量的修改,对join之后可见。
print(x);// 100

监视器锁的规则

对一个锁的解锁 happens-before 于随后这个锁的加锁。

synchronized(this){//此处自动加锁 2
    //x 是共享变量,初始值为10
    if(this.x<12){
        this.x = 12;
    }
    
}// 此处自动解锁  1

线程A进入这个模块加锁,并且离开后解锁,对于下一个线程B而言,线程A的解锁一定 happens-before与线程B的加锁。

总结

我们从硬件层面和软件层面分析了Volatile的由来。虽然Volatile的使用非常简单,但是短短几个字背后其实也有很复杂的实现来支持。

总而言之,Volatile是JAVA层面提供的一种内存屏障的实现,他将会保证被Volatile修饰的变量的读操作一定有写操作,一定优先于后续的读操作,而保证后续读到的变量值一定是最新的,使用JMM的内存屏障,而保证可见性问题。

  • 硬件层面
    • 为了提高cpu的性能,我们加入高速缓存。
    • 由于高速缓存与主内存的交互存在缓存一致性问题,我们加入了总线锁和缓存锁,缓存锁中是比总线锁锁粒度更小,性能更好的锁技术,里面使用了MESI协议。
    • 又因为,MESI协议告知其它处理器缓存失效的等待回复中,会导致处理器阻塞,有性能问题,所以我们加入了store buuferes。来异步支持通知。
    • 最后,异步由于无法保证其它处理区正常接受失效的通知,又再次导致可见性问题和编译器的指令重排序。问题仿佛又回到了起点,所以cpu设置了内存屏障,让编译器自己实现各自的内存屏障实现。
  • 软件层面
    • JMM的提出,隔离了硬件层面的模型,JAVA 专用的内存模型。JMM的实现其实是为了让编译器按照自己的规则,禁止对某些指令进行重排序,对CPU而言,JMM会添加java自己的实现的内存屏障来到指令中,让cpu执行,从而避免可见性问题。所以JMM可以看做是一个中间人,他规范了JVM的执行规则,也告诉了硬件改如何执行这些指令。
    • Volatile 是JMM 中 happens-before规则中的一种,被他修饰过的变量,变成指令后,将会前后增加对应的内存屏障,添加内存屏障的工作由JMM来实现。

每天积累一点点

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值