浅谈volatile关键字的特性

115 阅读5分钟

在学习原子类的时候看到了volatile关键字,决定了解到它在此处可以保证数据的可见性,于是决定探究一下volatile关键字是如何保证数据可见性的,以及volatile的特性。

volatile关键字有一下几个特性: 1.保证可见性 2.禁止指令重排序 3.不能保证原子性

1.保证可见性

在原子类AtomicInteger中的value值就是被volatile修饰的,因为在JVM内存模型中,线程创建时JVM会为其开辟一块工作内存,工作内存是每个线程私有的内存,而在JVM内存模型中所有数据都是存在主存的,当线程需要对主存数据进行操作时,需要把主存中的数据拷贝到工作内存中,然后进行操作,最后写回主存,而原子类AtomicInteger中的CAS过程必须保证数据与主存保持一致。如下图所示:

线程内存.png 所以在多个线程对统一数据操作时会存在可见性的问题,如果线程A和线程B都拿到count值为0,并且线程A修改count为1写回主存中,此时线程B中的值还是原来的值0,再对0加一写回主存的话就还是1,其实应该是2。 在加了volatile关键字后,当每个线程对主存数据的修改对其他线程是可见的,其他会立即失效掉工作内存中的值并且重新从主存中获取。

为了验证volatile关键字的可见性,如下:

开启两个线程,线程1为死循环,当flag值被修改为true时会跳出循环,而线程2则会去修改flag的值为true

无volatile时:

public class Main {
    private static  boolean flag =false;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程1开始执行");
                while(true){
                    if(flag){
                        System.out.println("跳出循环");
                        break;
                    }
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程2开始执行");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                flag=true;
                System.out.println("flag已经被修改");
            }
        }).start();
    }
}

无volatile.png

线程1和线程2从主存中获取到flag值为false,当线程2修改flag值为ture时,在不加volatile关键字的时候,对于线程1而言,无法感知到线程2对flag值的修改,所以无法跳出循环。

有volatile时:

public class Main {
    private static volatile boolean flag =false;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程1开始执行");
                while(true){
                    if(flag){
                        System.out.println("跳出循环");
                        break;
                    }
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程2开始执行");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                flag=true;
                System.out.println("flag已经被修改");
            }
        }).start();
    }
}

有volatile.png

线程1可以成功的跳出循环

总结:volatile能够保证线程的可见性。

那么,volatile是如何保证可见性的,这里浅说一下,因为volatile关键字会开启总线的mesi缓存一致性协议。

mesi缓存一致性协议:多个CPU从主内存读取同一个数据到各自得高速缓存,当其中某个cpu修改了缓存里得数据,
该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据得变化从而将自己缓存里得数据失效

2.禁止指令重排序

首先,了解什么是指令重排序,JVM规定只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。

指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。

当然,在单线程的情况下,指令重排序不会对结果造成任何影响,但是在多线程的环境下,就会出现错误。如下代码。

public class MyTest {
    int a = 0;
    boolean flag = false;
    public void do1(){
        a = 1;
        flag = true;
    }
    public void do2(){
        if(flag) {
            a = a + 5;
            System.out.println("a=" + a);
        }
    }
}

如果按照正常逻辑执行do1和do2,那么得到的a=6。但是在多线程中由于没有数据依赖的关系,可能指令重排序后的顺序是这样的。

flag = true; 
a = a + 5; 
System.out.println("a=" + a); 
a = 1;

输出的结果会是a=5。所以就需要使用volatile来禁止指令重排序了。

原理: 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化,如下图所示:

volatile内存屏障.drawio.png

应用: 学习单例模式的时候,我们发现,使用懒汉模式实现单例模式的时候,单例对象就是用volatile关键字修饰的,如下:

class LazySingleton{
    private static volatile LazySingleton instance;//volatile关键字防止指令重排序
    private LazySingleton(){};//构造方法私有化,防止new对象
    public static LazySingleton getInstance(){
        if(null==instance){
            synchronized (LazySingleton.class){
                if(null==instance){
                    instance=new LazySingleton();
                }
            }
        }
        return instance;
    }
}

在这里使用volatile关键字的作用是保证有序性,禁止指令重排序,原因是 instance=new LazySingleton() 在JVM中其实是分几步执行的:

  1. 分配内存空间
  2. 初始化对象
  3. 使变量指向对象(instance!=null)

经过指令重排序后可能变成:

  1. 分配内存空间
  2. 使变量指向对象(instance!=null)
  3. 初始化对象

在多线程的情况下,如果线程A和B同时通过单例方法获取对象:

线程A进行指令重排序后执行到变量指向对象,但是还未进行初始化,这个时候线程B抢夺了CPU资源开始执行,此时它认为instance不为空已经创建完成,直接拿来使用,此时就会发生错误。

3.不能保证原子性

在 Java 中,原子性是指一个操作是不可中断的,volatile 变量的写操作和读操作之间是可以被中断的,这意味着在读取或者修改volatile变量的过程中,其他线程可能会对这个变量进行修改。因此,使用 volatile 变量并不能保证对变量的操作是原子性的。 如果想要保证原子性可以使用AtomicXXX类。