十分钟深入理解volatile

个人觉得JUC里面的东西比JVM还要玄,JUC中将引领我们用一种多线程的角度思考整体的程序,可以说是一种挑战吧。

volatile,中文语义:不稳定的

大家都应该了解这个常识吧!(不知道的一定要记好)

Java中,若一个变量会被多个线程所使用,我们需要给这个变量加上volatile关键字。

しかし!(但是)作为未来大厂高级电脑的附属品,我们必然不能只了解这个规则,更应该洞悉why,才能在未来的使用中,稳定起飞

index

一、首先,volatile到底是个啥?

Java语言规范第3版中对volatile的定义如下:

Java编程语言允许线程访问共享变量,为了 确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

Java语言 提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存 模型确保所有线程看到这个变量的值是一致的

简单来讲:

volatile做到了两点:(下面这两句出自大佬的总结,学完以后感觉确实是这两句)

扫描二维码关注公众号,回复: 12474763 查看本文章
  1. 保证目标在Java内存空间的可见性(这句话先记起来,后面会细锁)
  2. 禁止指令重排

看下面的demo,若我们去掉volatile关键字,则consumer永远都不能执行完成,若我们加上了volatile,则执行成功了。

各位不要在循环等待中使用print方法,print方法是一个加锁的方法,该操作会刷新缓存,
https://blog.csdn.net/weixin_44494373/article/details/111767803

public class VolatileTest {
    
    

    //线程池,用来执行任务
    ExecutorService executorService ;
    int a;
    volatile boolean flag=false;

    public VolatileTest() {
    
    
        executorService = new ThreadPoolExecutor(3, 3,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>());
    }

    public static void main(String[] args) {
    
    
        VolatileTest volatileTest=new VolatileTest();
        volatileTest.runConsumer();
        volatileTest.runProd();
    }

    void runProd(){
    
    
        Runnable task=()->{
    
    
            //睡一秒,保证后面再执行生产,反正先生产导致消费者启动获取时就有结果。
            try {
    
    
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
            for (int i = 0; i < 80; i++) {
    
    
                a++;
                a+=a;
            }
            flag=true;
            //让这个线程一直执行下去,防止线程结束后写回主存
            while (true){
    
    
                a++;
            }
        };
        executorService.submit(task);
    }
    void runConsumer(){
    
    
        Runnable task=()-> {
    
    
            int num=0;
            //flag检测不到更新,num++,用来查看确实执行了等待
            while (!flag) {
    
    
                num++;
            }
            System.out.println(a);
            System.out.println(num);

        };
        executorService.submit(task);
    }
}

二、volatile的实现原理

为了阐述volatile的内存语义,我们从两层理解:实现原理与内存语义,这分别对应了上面的两点

首先,为了能理解下面的原理,我需要确保各位读在这里时,已经对Java的内存模型有了一定的认识JMM

Java内存模型基础 https://blog.csdn.net/weixin_44494373/article/details/113244175

1.volatile的实现原理

相信你已经大致了解了JMM内存抽象

们在X86处理器下通过工具获取JIT编译器生成的 汇编指令来查看对volatile进行写操作时,CPU会做什么事情。

Java代码如下。

instance = new Singleton(); // instance是volatile变量

转变成汇编代码,如下

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp); 

lock指令是啥
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架 构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情。

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

于此同时,使用了Lock变量的缓存,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一 致性协议

每个处理器通过嗅探总线上传播的数据检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

volatile的实现原则

  1. Lock前缀指令会引起处理器缓存回写到内存。 Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存(锁总线)。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。对于Intel486和 Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果 访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。 IA-32处理器和Intel 64处 理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致 性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能**嗅探其他处理器访问系统内存和它们的内部缓存。**处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

参考书:《Java并发编程的艺术》

上述两段长长的理论,整理下来,即为:

  1. volatile会加上Lock前缀,Lock前缀可以让这个变量在更新时,及时的更新主存中的数据。
  2. 处理器用嗅探技术监控总线,若有自己缓存Lock前缀变量更新出现在总线(数据传输的通道),就会把缓存标记无效。

2.volatile的内存语义(读写屏障

若希望了解这里的知识,需要对指令重排序有了解(在刚刚的内存模型中),在介绍完理论后,会有一个实际例子

volatile使用了读写屏障,所以不仅保证了可见性,还达到了 禁止指令重排(原子性) 的目的。

这里再提一次volatile写的内存语义

  1. 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。换句话说,线程所修改的变量,是主内存中的变量,而不是缓存。
  2. 原子性:对任意单个volatile变量的读写具有原子性,但i++这种复合操作不具有,只对于单次的写有效,i++是一个复合操作

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。(从JDK5开始)

volatile从JDK5的加强后,在volatile写操作与锁的效果是一样的,AQS框架就是依靠volatile与CAS完成上锁的目的。

我们知道:锁前的语句的执行 必须在锁后语句执行前执行。(锁前后不难颠倒)

这就达到了禁止指令重排序的目的。

3.volatile内存语义实现

image-20210127160950338

这个表是在编译器重排序阶段,编译器对volatile的规则。

image-20210127161455062

可以看出来

  1. volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

我们可以这样理解,volatile有写前,读后屏障。

为了这两个屏障,JMM的策略如下

  • 写前
  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 读后
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

应用-单例模式

有用给孩子个三连吧,阿里嘎都

单例模式为什么需要volatile关键字?https://blog.csdn.net/weixin_44494373/article/details/112061551

gif

猜你喜欢

转载自blog.csdn.net/weixin_44494373/article/details/113251950