美文网首页
Java中volatile除了保证可见性还有什么用

Java中volatile除了保证可见性还有什么用

作者: 明翼 | 来源:发表于2020-07-11 21:32 被阅读0次

    最早接触到volatile的关键字的时候, 是用在多线程控制地方,一个主的线程通过quitFlag标志来控制子线程的启停,子线程通过循环来判断标记是否为true,为true则退出,这时候如果不用volatile 关键字修饰quitFlag在主线程更改后, 子线程可能无法立刻看到修改,导致无法及时退出的问题,甚至无法退出的问题。

    一 volatile保障了可见性

    上面情况,如果用volatile 来修饰quitFlag关键字,则可以及时退出。

    public class TestQuitFlag {
    
     // 这种可能无法即时退出   
     // private static  boolean quitFlag = false;
    // 这种情况可以正常退出
    private static  volatile boolean quitFlag = false;
    
        public static void main(String [] args) throws InterruptedException {
            new Thread(){
                @Override
                public void run() {
                   while (!quitFlag) {
                       System.out.println(Thread.currentThread()+" is running");
                       try {
                           Thread.sleep(2000);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                   }
                   System.out.println("Child thread is stop");
                }
            }.start();
            Thread.sleep(3000);
            quitFlag = true;
            System.out.println("Main is exit..");
        }
    }
    
    

    原因是Java对缓存进行了抽象,java的JMM内存模型,将线程访问的内存分为工作内存和主内存,工作内存只有本线程才可以操作,Java操作的数据先保存到本地内存中,更改后刷新到主内存中,其他线程读取变量的时候每次都从主内存中同步到它的本地内存中,如下图:


    主内存和工作内存

    二 volatile 与线程安全

    volatile 保障了可见性,不具有原子性,不能保障线程的安全。有些说法可以部分保障线程安全,我认为那种可见性不能算是线程安全。
    简单的测试下,累加这种典型的场景:

    import java.util.ArrayList;
    import java.util.List;
    
    public class TestCounter {
    
        private static volatile int  count = 0;
    
        public static void main(String [] args) throws InterruptedException {
            List<Thread>  threads = new ArrayList<>();
    
            for (int i = 0; i< 10 ; i++) {
                threads.add(new Thread(){
                    @Override
                    public void run() {
                       for (int j = 0; j < 1000; j++) {
                           count++;
                       }
                    }
                });
            }
            threads.forEach(thread->{thread.start();});
            threads.forEach(thread->{
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            System.out.println("Main is exit..");
            System.out.println("result:" + count);
        }
    }
    
    

    一共启动10个线程,每个线程计数1000次,如果是线程安全的结果应该是10000,打印结果如下:


    执行结果

    如果改动下,通过synchronized 来控制累加,代码如下:

    import java.util.ArrayList;
    import java.util.List;
    
    public class TestQuitFlag {
        private static volatile int  count =0 ;
        public static void main(String [] args) throws InterruptedException {
            List<Thread> threads = new ArrayList<>();
            for (int i = 0; i< 10 ; i++) {
                threads.add(new Thread(){
                    @Override
                    public void run() {
                       for (int j = 0; j < 1000; j++) {
                           synchronized (TestQuitFlag.class) {
                               count++;
                           }
                       }
                    }
                });
            }
            threads.forEach(thread->{thread.start();});
            threads.forEach(thread->{
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            System.out.println("Main is exit..");
            System.out.println("result:" + count);
        }
    }
    
    

    通过synchronized 包下代码块,执行的结果就是10000了,这里面要注意下所有线程的synchronized的传入参数要是同一个对象,如果不是,则达不到锁的目的。比如刚才代码中:

    synchronized(TestQuitFlag.class) 改成synchronized(this)是操作不同的对象,则达不到锁的目的。
    

    当然在java中有性能更高的累加方法,那就是采用Atomic*系列类,这些类因为采用CAS的方式进行加锁,所以性能更好些,这里就不再举例了。

    三 volatile 可以防止指令重排

    volatile 可以防止指令重排,JVM虚拟机在执行Java字节码的时候,为了提升性能,在不影响程序语义的情况下,会对指令进行重排。当然除了JVM,编译器或cpu都可能会进行指令重排。

    典型的代码场景是双重锁检查单例写法,具体展示如下:

    
    public class Singleton {
        private static volatile  Singleton singleton;
    
        private Singleton() {
    
        }
        public static Singleton getInstance() {
            if (singleton == null) {
                synchronized (Singleton.class) {
                    if (singleton == null ) {
                        singleton = new Singleton();
                    }
                }
            }
            return singleton;
        }
    }
    

    这里面必须给singleton添加volatile关键字,为什么要添加关键字,这里和volatile的防止指令重排问题有关。这里面主要和singleton = new Singleton();这句代码相关,
    这句代码实际执行的时候分为三步操作:

    1. 申请一块内存。
    2. 调用Singleton的构造函数初始化。
    3. 将singleton引用指向这块内存空间,这样singleton执行就不是null了。
      这三步处于锁控制的范围内,当时如果没有volatile 情况下,会发生指令重排,而引起错误。来举个例子:
      1) 线程1 执行到synchronized同步代码块中,判断singleton为null,这时候开始执行
      singleton = new Singleton();代码。
      2) 由于指令产生了重排,所以执行的代码顺序是1->3->2 , 执行完3,之后singleton不是null了,这时候线程时间片时间到,线程休眠。
      3) 其他线程再调用getInstance()判断singleton不为null,直接返回singleton使用,当时我们知道,其实这个变量现在是未初始化的。其他线程使用了这个未初始化的变量,从而造成问题。

    volatile 关键字给JVM指明修饰的字段可能在其他的线程中发生修改,所以

    如下图:


    无volatile情况

    加上volatile 关键字后,看JVM编译后的代码会多一句:

    lock addr $0x0,(%esp)
    

    这个指令相当于一个内存屏障,只有一个cpu,并不需要;如果有两个或两个以上cpu访问访问的时候,会将cache本地内存的数据同步到主内存中,通过这个操作让volatile变量在其他的内存中立刻可见,也保证了后续的指令不能重排到lock指令之前。

    顺便说下,DCL实现的单例模式,还常被问到的点,为什么两次判断singleton是否为null。
    顺便说下:

    1. 第一次判断singleton 是否为null,在不为null的时候可以不用进入到同步代码块,快速返回,提升了性能。
    2. 第二次判断singleton是否为null,一个线程在判断singleton为null,进入到同步代码块之前休眠了,这时候另外一个线程因为判断singleton为null,则先进入了同步代码块,执行完毕后;开始的线程仍然可以进入同步代码块,如果不判断singleton是否为null,则会再次创建个单例对象,违反了我们的单例的初衷。

    相关文章

      网友评论

          本文标题:Java中volatile除了保证可见性还有什么用

          本文链接:https://www.haomeiwen.com/subject/jfcicktx.html