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

2020-07-11  本文已影响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,则会再次创建个单例对象,违反了我们的单例的初衷。
上一篇下一篇

猜你喜欢

热点阅读