Java中volatile关键字的作用

由一段代码引出的问题

我们先来看这样一段简单的代码:

public class VolatileThread implements Runnable{

    private boolean flag = true;

    public boolean isFlag() {
        return flag;
    }
    
    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        System.out.println("子线程开始执行...");
        while(flag){
        
        }
        System.out.println("子线程执行结束...");
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        VolatileThread volatileThread = new VolatileThread();
        Thread t1 = new Thread(volatileThread);
        // 启动t1线程,此时VolatileThread中的flag=true
        t1.start();
        // 让Main线程休眠一会
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();     
        }
        // 将flag修改为false
        volatileThread.setFlag(false);
        System.out.println("flag改为false");
        // 获取此时flag的值
        System.out.println("获取此时flag的值为:" + volatileThread.isFlag());
    }
}

在执行Main之前我们先来简单分析一下,首先创建了一条新的线程去执行VolatileThread中的run()方法,由于flag默认为true,所以会一直执行while(flag){ }循环,在Main线程休眠3秒后,将flag的值更改为false。此时理论上当t1再次获取flag时拿到的应该时false,然后会跳出while循环,打印"子线程执行结束…",然后程序结束。
但是事实真的像我们分析的这样吗,来执行一下Main,结果如下:
在这里插入图片描述
很奇怪的是,明明flag已经被修改成了false,并且在输出语句中也证明了这一点,为什么程序却一直在运行呢?也就说明它依然在while中循环没有跳出来。想要弄清楚这一点,我们有必要先从JMM(Java内存模型)说起。

理解JMM(Java内存模型)

首先要说明的是Java内存模型(即Java Memory Model,简称JMM)和JVM内存区域划分(程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区等)是不同的两个概念。Java内存区域本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
由于JVM运行java程序实际上是靠一条条线程来完成的,因此每条线程线程在启动时,JVM都会为其创建一个私有的工作空间,我们称其为本地工作内存,每条线程的本地工作内存都是相对独立的,其他线程无法访问。其实这很好理解,因为每条线程都有自己的职责,比如主线程负责执行我们写的代码,GC线程负责垃圾回收等等。试想如果每条线程之间都可以任意的访问其他线程的数据,是不是非常容易引起线程的安全性问题,所以说每条线程都存在这样一个本地工作内存。
但是反过来说,凡事不能太极端,如果每条线程都完全独立于其他线程,那么所有线程间就也无法一起协同工作了。因此又需要一个媒介,将不同的线程联系起来让它们之间保持通信。类似于相亲,两个互相不认识的男女,它们之间无法进行通信,但是要想取得联系,就必须通过媒婆来传话,媒婆就是这两个相对隔离的人之间的媒介。由此,就引出了线程间进行通信的媒介–主内存,主内存是共享数据区域,也就说每条线程都可以访问主内存中的数据。通过这种媒介的方式,虽然线程间不能直接访问对方工作内存中的数据,但是它们可以通过共同操作共享内存,从而间接的完成线程间的通信。

主内存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)。此外主内存中还包括共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问、修改可能会发现线程安全问题。

本地工作内存:主要存储当前方法所有的本地变量信息,如果线程需要操作主内存的数据,还会将主内存的数据拷贝一份到本地工作内存。每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

用下面这幅图来描述各条线程间的本地工作内存呢和主内存之间的关系:
在这里插入图片描述

理解线程间的可见性

线程是不允许直接操作(主要是指写操作)主内存中的数据的,线程若想操作主内存的数据,必须要先将主内存中的数据读取到自己的本地工作内存中,然后拷贝一个副本,对这个副本进行操作,然后再写回主内存中。

另外需要注意的是,线程读取共享数据的时候,也不是每次都从主内从中进行读取,因为从主内存中读取数据肯定要比从自己的工作内存中读取效率低。所以线程会基于操作系统的优化算法,前几次会尝试从主内存中进行读取,并拷贝副本到工作内存中,当从主内存中读取多次后发现总是和工作内存中的副本数据一样时,它之后每次便会优先从选择本地工作内存读取,不确定何时再到主内存中读取。基于上述分析,这种机制会造成一些问题:

  • 如果线程A在本地工作内存中对之前读进来的数据进行了更新,并且把线程A的副本更新成了最新值,但是还差最后一步将副本刷新到主内存没完成的时候,此时线程B主内存读取数据,那么此时线程B读取的依然还是旧的数据(即使线程A确实已经完成了对数据的更新操作),因为线程B是看不见线程A中的数据的。
  • 像上面所说的,如果线程B之前尝试从主内存中读取数据发现总是和副本的一致,那么接下来线程B将会一直读取自己本地工作内存中的副本。即使之后线程A将最新的数据刷新到了主内存,由于线程B一直在读取自己之前读进来的副本,那么主内存中的最新数据线程B依然是看不见的,因为并没人通知它主内存已经更新成了最新值。

上边所描述的这些,总结成三个字就是:可见性。线程的可见性问题,正是由于java内存模型的机制而引发的。

了解了这些,我们现在回过头了再看最开始代码中的问题,就非常容易理解是如何产生的了。我们知道成员变量(存在于堆中)是全局共享的变量,因此在VolatileThread中,flag存在于共享数据区域即主内存。接下来我们来分析VolatileThreadMain,当执行到t1.start()时,线程t1会去执行run方法,进入while循环判断flag时,会先将flag读取进thread自己的本地工作内存并保存一个副本。然后就是不断的判断flag然后执行while循环。
我们在VolatileThreadMain中让Main线程休眠了3s,这3s看似不长,但是对于线程thread来说,它要做的循环次数要数以万计,这么多次循环判断flag中,flag都没有发生改变,这也就导致了我上面所说的,后边它会优先从自己的副本中读取flag(大家可以自行尝试一下,如果不加休眠,程序是很快就会停下的,就是因为前几次其实线程thread还是会去主内存中读取数据)。即使后边主线程将主内存中的共享数据flag修改成了false,线程thread也不会从主内存读取了,这也就是造成程序一直停止不了的原因。

使用volatile来解决可见性的问题

对于上述可见性问题,java给出了解决办法,使用volatile关键字,volatile的功能有两个:保证可见性和禁止指令重排序。

1.保证可见性
保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。它是基于内存屏障(想详细了解的话参见文末链接)实现了以下两点:

  • 线程读取共享数据时,每次都必须从主内存中读取,不允许从本地工作内存的副本中读取。
  • 线程更新共享数据时,只要更新完成必须强制刷新到主内存中。

在我们的代码中,出现问题的根本原因是线程thread每次没有从主内存中读取最新的flag值,而是从本地工作内存中的副本中读取,才导致程序一直处于循环状态中停不下来。所以当我们用volatile修饰共享变量flag后,就能保证主线程修改flag后,线程thread会立即得知结果。大家可以自己尝试一下,这里不再演示。

2.禁止指令重排序
volatile除了可以保证可见性外,还可以禁止指令的重排序。
什么是指令重排?我们通过volatile的典型应用–单例模式(懒汉式)的代码来简单描述一下指令重排:
在这里插入图片描述
图中线程A先进入到图示位置执行instance实例的创建,执行后cpu将线程A挂起然后让线程B执行,此时线程B要执行的是判断instance实例是否为null。按照正常思路来说,既然线程A已经完成了new Singleton()创建实例,那么此时instance肯定不为null,于是直接return instance实例。但事实真的如此吗?
在我上一篇java 线程安全问题以及使用synchronized解决线程安全问题文章中提到了,当CPU调度一条线程执行任务时,至少要执行一条计算机指令,但是一行java代码并不一定就是一条计算指令,它被编译器编译后,可能会得到多条JVM指令。就比如说Java中最常见的创建实例 instance = new Singleton(),它会被编译成如下的指令:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址 

但是这些指令的执行顺序并非是一成不变的,有可能会经过JVM和CPU的优化,从而使得最终CPU执行这3条指令的顺序可能如下:

memory =allocate();    //1:分配对象的内存空间 
instance =memory;     //3:设置instance指向刚分配的内存地址 
ctorInstance(memory);  //2:初始化对象 

刚才我们说到线程A执行创建实例时CPU将其挂起,但是并没说CPU是执行完这3条执行后将线程A挂起,这也就意味着线程A可能在执行完1、3后被挂起。这时问题就来了,由于指令的重排序导致实例变量instace在未真正的初始化对象之前就已经指向了一块已经分配好空间的内存,那么此时如果线程B去判断if(instance == null),结果肯定是false,然后就会把没有实例化的instance进行返回,当线程B拿到这个未实例化的instance去调用它里边的方法时,就会发生NullPointException。

而volatile的作用之一就是禁止这样的指令重排序优化,从而保证了指令的执行顺序。这也是为什么在懒汉式单例模式中必须使用volatile修饰实例变量的原因,正确代码如下:

public class Singleton {

    // 私有化构造函数
    private Singleton() {
    }

	// 使用volatile修饰实例变量,禁止指令重排序
    private volatile static Singleton instance = null;

    public static Singleton getInstance() {
        if (instance == null) { // 双重检测
            synchronized (Singleton.class) { // 同步锁
                if (instance == null) { // 双重检测机制
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile和synchronized的区别

修饰位置的区别:

  • volatile:只能修饰共享变量(即成员变量),不可修饰方法和方法中的局部变量。
  • synchronized:可以修饰在代码块、方法、静态方法

我们知道在多线程中有三个特性,分别是:原子性、可见性、和有序性

  • 被volatile修饰的变量能保证在执行时的可见性和有序性,但是无法保证操作的原子性。比如在我上一篇java 线程安全问题以及使用synchronized解决线程安全问题文章中举的多线程卖票的例子,即使我们给成员变量(即当前剩余的票数)加上了volatile但是不使用synchronized进行同步的话,依然会产生线程的安全问题。
  • 使用synchronized进行同步后,可以直接保证同步块内的原子性可见性(当线程申请锁时会将工作内存的变量值置为失效然后从主内存读取一份;当线程释放锁时会将工作内存的值写入到主内存中),也可以间接保证有序性从单线程的角度看,指令重排并不会影响最终的结果,而synchronized进行同步时正是让一个线程执行其他线程阻塞,这就类似于单线程执行,即synchronized不会直接保证有序性,它仅是通过原子操作来间接保证有序性。常见的例子就是设计模式–单例模式中第二版和第三版,第二版就是将可能引发有序性问题的代码全部使用synchronized进行了同步,因此避免了有序性带来的问题,而第三版反之,所以需要对共享变量进行volatile修饰来保证有序性。

本文参考自:

1.《Java多线程编程核心技术》
2. 全面理解Java内存模型(JMM)及volatile关键字

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值