• 面试(三)---volatile


    一、前言 

         最近去成都玩了一圈,整体感觉还不错,辞职以后工作找的也很顺利,随着年龄增加自己也考虑定居和个人长期发展的问题,反正乱七八糟的事,总之需要好好屡屡思路,不能那么着急下定论,当然我对下份工作也是有所期望的,不扯了开始我们今天主题吧。

    二、Java的内存模型

        Java内存模型规定所有的变量都存在主内存当中,每条线程都有自己的工作内存,线程的工作内存保存了被该线程使用的变量的主内存副本拷贝,线程对变量的所有操作都在内存中进行,而不能直接读写主内存中的变量。不同的线程之间无法直接访问对工作内存中的变量,线程之间变量值的传递均需要通过主内存来完成。----来自深入理解Java虚拟机

       

       这里注意下,Java的内存模型(JMM)和Java的内存区域的差别,不要混淆这两者之间的概念,JMM主要是围绕在程序中各个变量在共享数据区域和私有数据区域的访问方式。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。

      接下来我们来看下主内存和工作内存之间的交互问题:

      

     1.lock操作,锁定主内存变量,标识为当前线程独占状态;

     2.read操作,将锁定的主内存变量读取到工作内存当中;

     3.load操作,将read操作的主线程变量放入到工作变量的副本当中;

     4.use操作,将工作内存的变量传递给执行引擎,当虚拟机调用到该变量的时候执行该变量;

     5.assign操作,当虚拟机对工作内存的变量的值进行赋值操作的时候,将值赋值给工作内存的变量;

     6.store操作,将工作内存的变量传递给主内存变量;

     7.write操作,将store操作的工作变量写入工作变量;

     8.unlock操作,将锁定的主内存中的变量锁释放,等待其他线程锁定;

     明白这些我们再来探究下JMM如何处理原子性、可见性和有序性的问题:

     1.原子性

     Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

     2.可见性

     由于JMM结构原因,当多线程访问的时候不能及时将变量的值更新到主内存当中,这个时候就能出现数据不一致的问题,Java提供了volatile关键字来保证可见性,另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。

     3.有序性

     在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

     JMM中有序性可以通过volatile实现,另外通过synchronized和Lock也能够保证有序性,这个和保证可见性的原理一样;另外Java语言有一个“先行发生(happens-before)”的原则,如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

     下面就来具体介绍下happens-before原则(先行发生原则):

     1.程序次序规则:一个线程内书写在前的代码先执行,写在后面的代码后执行;

     2.锁定规则:一个unLock操作先行发生于lock操作;

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

     4.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

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

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

     7.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

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

    三、volatile

     上面聊了很多,接下来我们看下volatile,

     volatile作用:

     1.保证变量的可见性;

    public class VolatileVisibility {
        public static volatile int i =0;
    
        public static void increase(){
            i++;
        }
    }
    View Code

     针对于可见性我们分析下上面的代码,i被volatile修饰的情况下,i的任何改变都会反应到其他线程当中,但是当多线程同时调用increase()的方法时,会出现线程安全的问题,i++不是原子操作,该操作先读后写,分2步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败;因此对于increase方法必须使用synchronized修饰,以便保证线程安全。

     接下来我们再看一个例子:

    public class VolatileDemo {
    
        volatile boolean close;
    
        public void close(){
            close=true;
        }
    
        public void doWork(){
            while (!close){
                System.out.println("work...");
            }
        }
    }
    View Code

     被volatile修饰的close字段,字段可以立即可见,保证当多个线程同时访问实例的时候,一个线程对close进行更新另外一个线程可立即获取到该字段变化以后的值;当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。

     对比下得出一个结论:volatile并不能保证原子性,只能保证可见性;

     2.防止指令重排序;

     明白这个要我们需要知道一个概念:内存屏障(Memory Barrier)是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

      接下来我们看下我们最常见的单例模式:

    public class SingletonDemo {
        private volatile static SingletonDemo instance;
        private SingletonDemo(){
            System.out.println("Singleton has loaded");
        }
        public static SingletonDemo getInstance(){
            if(instance==null){
                synchronized (SingletonDemo.class){
                    if(instance==null){
                        instance=new SingletonDemo();
                    }
                }
            }
            return instance;
        }
    }
    View Code

      这里我们思考下没有volatile的时候出现的问题:当然在单线程的情况下是不会出现问题,多线程下面会出现问题,首先这里我们要声明下volatile在这个地方只是防止指令重排,可见性是由锁实现的,接下来我们在分析为什么多线程会出现问题?

      正常情况下初始化一个对象的过程如下:

      1.分配内存空间;

      2.初始化对象;

      3.将内存空间的地址赋值给对象的引用;

      当发生指令重排的时候可能会发生如下状况:

      1.分配内存空间;

      2.将内存空间的地址赋值给对象的引用;

      3.初始化对象;

      这个时候我们就来考虑下多线程情况下可能发生的问题喽,如果A线程正好初始化到了第(2)步的时候, 正好有其它线程B来获取这个对象, 那线程B能不能看到这个由A初始化但是还没初始化完毕的对象呢?答案是可能会看到这个未完全初始化的对象, 因为这里初始化的是一个共享变量,这个时候就会照成2种情况:

      1.如果读到的是null,反而没问题了,接下来会等待锁,然后再次判断时不为null,最后返回单例。 
      2.如果读到的不是null,那么坏了,按逻辑它就直接return instance了,这个instance还没执行构造参数,去做事情的话,很可能就崩溃了。

      注意:这个问题不容易出现理解下就好,不要和我一样调试到怀疑人生。。。。。

    四、总结

     JMM就是一组规则,这组规则为了处理在并发编程可能出现的线程安全问题,并提供了内置的(happen-before原则)以及其外部可使用的同步手段(synchronized/volatile等),保证程序在执行多线程时候的原子性,可见性以及有序性。

     volatile不能保证原子性;

     欢迎加群:438836709

     欢迎关注公众号:

     下一篇:spring IOC源码解读

     

  • 相关阅读:
    栈和队列
    绪论
    抽象数据类型和python类
    《黑马程序员》流程控制(顺序结构,选择结构,循环结构)(C语言)
    《黑马程序员》C语言中的基本运算(C语言)
    《黑马程序员》C语言中的基本数据类型 (C语言)
    《黑马程序员》 关键字、标示符、注释(C语言)
    获取图片
    文件路径
    文件上传
  • 原文地址:https://www.cnblogs.com/wtzbk/p/9008967.html
Copyright © 2020-2023  润新知