volatile

volatile英文解释为:易变的,不稳定的。这也就是volatile在Java关键字中的语义。

当你用volatile去申明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采取一些特殊的手段,来保证这个变量的可见性特点。

下面就来看看虚拟机的特殊手段,解释之前先明白一些概念:

CPU缓存

  cpu缓存的出现主要是为了解决cpu运算速度与内存的读写速度不匹配的矛盾,因为cpu的运算速度要比内存的读写速度快的多,举个例子:

    ❤ 一次主内存的访问通常在几十到几百个时钟周期

    ❤ 一次L1高速缓存的读写只需要1~2个时钟周期

    ❤ 一次L2高速缓存的读写也只需要数十个时钟周期

这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据的到来或者把数据写入内存。

基于以上的原因,现在CPU大多数情况下读写都不会直接访问内存(cpu都没有连接到内存的管脚),取而代之的是cpu缓存,cpu缓存是位于cpu和主内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存快的多。而缓存中的数据是内存中的一部分数据,但是这一部分数据是短时间内cpu即将访问的,当cpu调用大量数据时,就可以先从缓存中读取,从而加快读取速度。

按照读取顺序与cpu结合的紧密程度,cpu缓存可以分为:

  ❤ 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与cpu结合最为紧密的CPU缓存。

  ❤ 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半。

  ❤ 三级缓存:简称L3 Cache,部分高端机才有。

每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增

当cpu要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中去找,如果还是没有再从三级缓存中去找或者内存中去找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据的80%都可以在一级缓存中找到,只剩下20%的总数量数据才需要从二级缓存、三级缓存或者内存中读取。

由于现代的服务器或者电脑等,基本都是多核CPU,那么CPU的缓存也就有多份,也就是说有几个CPU,那么这个变量的缓存就有可能有几个,为了保证数据在各个缓存中的同步性,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,其他CPU就会将它们的这个缓存视为无效。

缓存一致性协议

说缓存一致性之前,先说一下缓存行的概念:

  ❤ 缓存是分段的,一个段对应一块存储空间,我们称之为缓存行,它就是cpu缓存中可分配的最小存储单元,大小为32字节、64字节、128字节不等,这与cpu架构有关,通常来说为64字节。当cpu看到一条读取内存的指令时,它会吧内存地址传给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级缓存)中加载进来。注意,这里说的是一次加载整个缓存段。

  ❤ 缓存协议一致性有很多种,但是日常大多数计算机设备都属于“嗅探(snooping)”协议,它的基本思想是:

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享的资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个cpu缓存可以读写内存)。

cpu缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其它缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它的处理器都会得到通知,他们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存中已失效。

MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存有4个状态,用2bit表示:

这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。所以这里新的知识点只有E状态,代表独占式访问,这个状态解决了“在我们开始修改某块内存之前,我们需要告诉其他的处理器”这个问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想去写某个缓存行时,如果它没有独占权,它就必须发送一条“我要独占权”的请求给总线,这会通知其他处理器把它们拥有的同一缓存段的缓存失效(如果有)。只有在获得独占权后,处理器才能开始修改数据----并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。

反之,如果其他处理器想读取这个缓存行(马上就能知道,因为在一直嗅探总线),独占或已修改的缓存行必须先回到“共享”状态,如果是已修改的缓存行,那么还要先把内容回写到内存中。

volatile 就是通过此原理保证了多个线程间的可见性。即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。

明白了volatile可见性原理,那么来看看如何使用和使用场景吧!直接来代码:

 1 public class VolatileVisibility extends Thread {
 2 
 3     private boolean isRunning = true;
 4 
 5     public boolean isRunning(){
 6         return isRunning;
 7     }
 8 
 9     public void setRunning(boolean isRunning){
10         this.isRunning = isRunning;
11     }
12 
13     public void run(){
14         System.out.println("进入run了");
15         while (isRunning == true){}
16         System.out.println("线程被停止了");
17     }
18 
19     public static void main(String[] args){
20         try{
21             VolatileVisibility mt = new VolatileVisibility();
22             mt.start();
23             Thread.sleep(1000);
24             mt.setRunning(false);
25             System.out.println("已赋值为false");
26         }
27         catch (InterruptedException e)
28         {
29             e.printStackTrace();
30         }
31     }
32 }

输出结果:

1 进入run了
2 已赋值为false

  然后你会发现,已经将isRunning 修改为false了,线程还是没有停止。这就要从JMM说起了,Java中有一块主内存,不同的线程有自己的工作内存,同一个变量在主内存中有一份,如果线程用到了这个变量的话,自己的工作内存也有一份一模一样的拷贝。出现上述原因就是主内存和工作内存中数据不同步造成的。因为执行run()方法的时候拿到了一个isRunning的拷贝,而改变isRunning是main线程做的,也就是说,main改变的是主内存中的isRunning,而run()线程的工作内存中的isRunning没有更新,这样就肯定会一直循环,因为对于run()线程来说isRunning一直是true。

解决方法:

  给isRunning变量加上关键字volatile。加上后再运行赋值为false后,线程就停止了。

volatile除了保证可见性之外,还保证了有序性,就是不会对其进行指令重排优化

  被关键字volatile修饰的变量,赋值后在底层执行时,会加上一个“loadadd1$0x0,(%esp)“指令,这个指令相当于一个内存屏障(指令重排时不能把后面的指令重排到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;

  指令重排:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。至于为什么要指令重排,可查阅我的另外一篇文章JMM(Java Memory Model)、原子性、可见性,有序性,这里就不赘述了。

volatile不保证原子性

  举例说明:

 1 public class VolatileDemo {
 2     static volatile int i = 0;
 3 
 4     public static class PlusTask implements Runnable{
 5         @Override
 6         public void run() {
 7             for (int k = 0;k < 10000;k++){
 8                 i++;
 9             }
10         }
11     }
12 
13     public static void main(String[] args) throws InterruptedException {
14         Thread[] threads = new Thread[10];
15         for (int i = 0;i < 10;i++){
16             threads[i] = new Thread(new PlusTask());
17             threads[i].start();
18         }
19 
20         for (int i = 0;i < 10;i++){
21             threads[i].join();
22         }
23         System.out.println(i);
24     }
25 }

执行上述代码,如果volatile保证了原子性的话,输出应该是100000(10个线程各累加10000次),但实际上,输出基本都小于100000。所以 volatile 并不能代替锁,同时它也无法保证一些复合操作的原子性。

由以上可得:

  volatile变量在各个线程的工作内存中不存在一致性问题,在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都会刷新,使用时看不到不一致的的情况,因此可以认为不存在一致性问题。

volatile性能:

  volatile的读性能与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多的内存屏障指令来保证处理器不发生乱序执行。

使用场景:

  大多数场景下,volatile的总开销要比锁低,我们在锁和volatile之中选择的唯一依据仅仅是volatile 的语义是否满足使用场景的需求,如果满足,则用 volatile。

参考:https://www.cnblogs.com/xrq730/p/7048693.html

   https://www.cnblogs.com/xrq730/p/4853578.html

posted on 2018-09-19 21:45  AoTuDeMan  阅读(1192)  评论(0编辑  收藏  举报

导航