zoukankan      html  css  js  c++  java
  • # 深入理解volatile

    深入理解volatile

    Volatile的官方定义

    Java语言规范第三版中对volatile的定义如下:java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
    volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

    可见性

    处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

    缓存一致性解决方案

    数据总线加锁

    LOCK前缀指令会引起缓存回写到内存,LOCK前缀指令导致执行指令期间,声明处理器的LOCK#信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存)

    缓存一致性协议

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

    内存模型

    在这里插入图片描述

    代码示列

    public class VolatileTest {
        //可见性
        private /**volatile**/ static int INIT_VALUE = 0;
    
        private final static int MAX_VALUE = 5;
    
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                int localVale = INIT_VALUE;
                while (localVale < MAX_VALUE) {
                    /***  对INIT_VALUE没有volatile关键字修  ***/
                    //为什么这里一直没有去从主内存中拿数据进行刷新呢?
                    //这是因为java认为这里没有writer的操作,所以不需要去主内存中获取新的数据。这个具体最新的值被刷新指
                    //具体可以VolatileTest2进行比较
                    //在这里加一个sysytem的输出是有可能会去刷新主存的,或者每次运行的时候休眠一小段时间,
                    // 这个程序是有可能会结束的。如果没有System的输出,或者休眠,在while判断会一直不去主内存
                    //刷新新数据,也就导致程序一直没法结束。
                    //System.out.println("=");
                    /*try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }*/
                    if (localVale != INIT_VALUE) {
                        System.out.println("The value updated to [ " + INIT_VALUE + " ]");
                        localVale = INIT_VALUE;
                    }
                }
            },"READER").start();
            TimeUnit.SECONDS.sleep(1);
            new Thread(() -> {
                int localValue = INIT_VALUE;
                while (INIT_VALUE < MAX_VALUE) {
                    System.out.println("update the value to [ " + (++localValue) + " ]");
                    INIT_VALUE = localValue;
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"UPDATER").start();
        }
    }
    

    有序性

    happens-before规则

    Java的内存模型具备一些天生的有序规则,不需要任何同步手段就能够保证的有序性,这个规则被称为Happens-before原则,如果两个操作的执行顺序无法从happens-before原则推导出来,那么它们就无法保证有序性,也就是说虚拟机或处理器可以随意对它们进行重排序处理。

    1. 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生于编写在前面的操作之后(如果这个都不能保证,我们程序员还怎么编程呢????对吧,所以这个肯定是需要保证的)
      这个规则的意思就是程序按照编写的顺序来执行,但是虚拟机还是可能会对程序代码的指令进行重排序,只要确保在一个线程内最终的结果和代码顺序执行的结果一致即可。
    2. 锁定规则:一个unlock操作要先行发生于同一个锁的lock操作
      这句话的意思是,无论是单线程还是多线程的环境下,如果同一个锁是锁定状态,那必须先对其执行释放操作之后才能继续执行lock操作。
    3. Volatile变量规则:对一个变量的写操作要早于对这个变量之后的读操作
      这句话的意思是,如果一个变量使用volatile关键字修饰,一个线程对它进行读操作,一个线程对他进行写操作,那么写入操作肯定要先行于读操作。
    4. 传递规则:如果操作A先于操作B,而操作B又先于操作C,则可以得出操作A肯定先于操作C。
    5. 线程启动规则:Thread对象的start()方法先行发生于对该线程的任何动作,只有start之后线程才能真正运行,否则Thread也只是一个对象而已。
    6. 线程中断规则:对线程执行interrupt()方法肯定要优先于捕获到中断信号。如果线程收到了中断信号,那么在此之前势必要有interrupt()。
    7. 线程终结规则:线程中所有的操作都要先行发生于线程的终止检测,通俗的讲,线程的任务执行、逻辑单元执行肯定要发生于线程死亡之前。
    8. 对象终结规则:一个对象的初始化完成先行于finalize()方法之前。

    指令重排序

    由程序次序规则,在单线程的情况下,对于指令重排序不会出现什么问题,但是对于多线程的情况下,就很有可能会由于指令重排序出现问题。
    volatile关键字直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后五以来的指令则可以随便怎么排序

    int x = 10
    int y = 20
    /**
      在语句volatile int z = 20之前,先执行x的定义还是先执行y的定义,我们并不关心,只要能够百分百
      保证在执行到z=20的时候,x=0,y=1已经定义好,同理对于x的自增以及y的自增操作都必须在z=20以后才能发生,这个规则可以认为是由程序次序规则+volatile规则推导
    **/
    valatile int z = 20
    x++;
    y++;
    
    private volatile boole init = false;
    private Context context ;
    public Context context() {
    	if(!init){
    		context = loadContext();
    		/**
    			如果init不使用volatile关键字修饰的话,由于编译器会对指令做一定的优化,也就是指令重排序。
    			所以在由多线程执行的情况下,如某个线程A它可能执行init = true,后执行context = loadContext(),
    			因为这两条指令并没有任何的依赖关系,所以执行顺序可能不定。当线程B执行到判断的时候,发现init=true成立,
    			那么线程B就不会再去加载context啦,此时如果它使用context,有可能context在线程A中还没有加载成功,此时线程B去
    			使用context就有可能报空指针异常。
    			而volatile关键字能阻止指令重排序,也就是说在init=true之前一定保证context=loadContext()执行完毕。
    		**/
    		init = true; //阻止指令重排序
    	}
    }
    

    其实被volatile修饰的变量存在一个“lock”的前缀。
    lock前缀实际上相当于是一个内存屏障,该内存屏障会为指令的执行提供如下几个保障
    1.确保指令重排序不会将其后面的代码排到内存屏障之前
    2.确保指令重排序不会将其前面的代码拍到内存屏障之后。
    3.确保在执行内存屏障修饰的指令时前面的代码全部执行完成(1,2,3阻止了指令重排序)
    4.强制将线程工作内存中的修改刷新至主内存中
    5.如果是写操作,则会导致其他线程的工作内存(CPU Cache)中的缓存数据失效。(4,5保证了内存可见性)

    原子性

    volatile无法保证原子性
    原子性:一个操作或多个操作,要么都成功,要么都失败,中间不能由于任何的因素中断
    对基本数据类型的变量读取和赋值是保证了原子性的,要么都成功,要么都失败,这些操作不可被中断
    a = 10; 原子性
    b = a; 不满足1.read a; 2.assign to b;
    c++; 不满足1.read c; 2.add 3.assign to c
    c = c + 1; 不满足1.read c; 2.add 3.assign to c

    public class VolatileTest2 {
    
        //虽然保证了可见性,但是没有保证原子性
        private volatile static int INIT_VALUE = 0;
    
        private final static int MAX_VALUE = 50;
    
        public static void main(String[] args) {
            new Thread(() -> {
                while (INIT_VALUE < MAX_VALUE) {
                	//很有可能会输出重复的数字
                    System.out.println("ADD-1-> " + (++INIT_VALUE));
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"ADD-1").start();
    
            new Thread(() -> {
                while (INIT_VALUE < MAX_VALUE) {
                    System.out.println("ADD-2-> " + (++INIT_VALUE));
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"ADD-2").start();
    
        }
    }
    
    

    volatile与synchronized

    (1) 使用上的区别

    • volatile只能用于修饰实例变量或类变量,不能用于修饰方法以及方法参数和局部变量,常量等。
    • synchronized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块。
    • volatile修饰的变量可以为null,synchronized关键字同步语句块的moniter对象不能为null。
      (2)对原子性的保证
    • volatile无法保证原子性
    • 由于synchronized是一种排他机制,因此synchronized关键字修饰的同步代码是无法被中断,因此能够保证其原子性。
      (3)可见性的保证
      两者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同
      synchronized借助于jvm指令的monitor enter和moniter exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都会被刷新到主存中;
      volatile使用机器指令(lock;)的方式迫使其他线程工作内存的数据失效,不得不到主存中重新加载数据
      (4)对有序性的保证
      两者都保证有序性,volatile关键字禁止jvm编译器以及处理器对其进行重排序,所以他能够保证有序性
      synchronized以程序的串行化执行来保证有序性

    参考博客

    并发之volatile底层原理:https://www.cnblogs.com/awkflf11/p/9218414.html

  • 相关阅读:
    CF1324F Maximum White Subtree
    CF1204C Anna, Svyatoslav and Maps
    CF1187E Tree Painting
    CF1304E 1-Trees and Queries
    深入探究jvm之类装载器
    深入探究jvm之GC的算法及种类
    深入探究jvm之GC的参数调优
    spring源码解析之AOP原理
    spring注解扫描组件注册
    cas-client单点登录客户端拦截请求和忽略/排除不需要拦截的请求URL的问题
  • 原文地址:https://www.cnblogs.com/liuligang/p/10587473.html
Copyright © 2011-2022 走看看