Java并发编程之volatile关键字

本文深入探讨Java并发编程中的volatile关键字,解释其解决的可见性和有序性问题,通过实例解析volatile无法保证原子性,并介绍了其在两阶段终止模式和DCL单例模式中的应用场景。同时,文章提供了一道关于volatile的面试题,引发思考。
摘要由CSDN通过智能技术生成

从事Java编程的程序员若想有所成长,就肯定绕不过并发编程,今天就聊一聊在Java并发编程中volatile关键字。

以下是本文的目录大纲:

一. 并发编程中的三个概念

二. 为什么没有退出循环

三. 进一步认识volatile关键字

四. volatile关键字的使用场景

五. 一道面试题

一. 并发编程中的三个概念

在并发编程中,我们通常需要考虑三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

原子性

原子性是指一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

比如我们去12306上面买高铁票,进入买票的页面时,通常有选择乘车人--->选择座位--->付款--->出票这四个步骤,这些步骤在执行过程中就需要保存原子性。不能说我付了钱,但是当应该给我票时程序却暂停了,不在继续执行了。如果是这样,那恐怕就不会有人用12306了

public void buyingTickets(){
    // 选择乘车人
    selectPassengers();
    // 选择座位
    selectSeat();
    // 付款
    payment();
    // 出票
    getTickets();
}

 

可见性

可见性是指当多个线程访问同一个全局变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

还是以12306上买票为例,打开12306买票时,我们都会看到剩余票数,这个剩余票数在程序实现中就是一个多线程共享的全局变量,这里我把起名为remainingTickets。那这个remainingTickets的取值在多个线程中是可见的,当线程1修改了remainingTickets的值时,线程2看到的remainingTickets的值应该是线程1修改过后的值。比如当前剩余票数remainingTickets=10;有A和B同时通过12306来购买同一班高铁票(始发地和目的地相同)。假如A先购买完成,那B购买时,看到的剩余票数remainingTickets=9。否则如果一个人买到票后,另一个看到票数却未做相应的扣减,那节假日再也不怕买不到票了,高铁恐怕也要经常超载了。

有序性

有序性指的是指令重排序,在Java内存模型中,在不影响单线程情况下程序执行结果的前提下,允许编译器和处理器对指令进行重排序,从而提高执行效率。

这种重排序可以分为两种情况来判断,但是这两种情况归根结底都是最后执行的汇编指令的重排序。

第一种情况是我有多行代码,编译器对多行代码的执行指令进行了重排序,比如:

int i = 0;      
boolean flag = false;
i = 1;        //语句1 
flag = true;  //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真 正执行这段代码的时候并不一定保证语句1一定会在语句2前面执行,有可能会因为指令重排序导致语句2在语句1前面执行。这里无论是语句1先执行还是语句2先执行都不影响程序的执行结果。

第二种情况就是我就一行代码,但是这一行代码涉及多条执行指令。比如构造函数的执行。当我们new一个对象时,会涉及三个步骤:1. 分配对象的内存空间、2. 初始化对象、3. 设置instance指向刚分配的内存地址。这三个步骤不一定是根据1、2、3的顺序来的,在指令重排序的情况下,也有可能是按照1、3、2的顺序执行的。在DCL的单例模式中,就会涉及这个问题,这个我们后面再说。

通过上面三个概念的介绍,需要明白在并发编程中,我们需要考虑程序原子性、可见性以及有序性。只有这样,我们才能写出正确的代码。

二. 为什么没有退出循环

首先,我们来看一段代码:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            
        }
    });
    t.start();
    TimeUnit.SECONDS.sleep(1);
    run = false; 
}

这点代码涉及两个线程,一个是线程t,一个是主线程。在线程t中有个while循环,根据run的值来判断是否继续循环。而在主线程中,会将run置为false。那么理论上t线程就会退出while循环,然后终止。但是事实并非如此。当主线程将run置false时,t线程依然没有退出while循环。那么这是为什么呢?回想一下前文介绍的并发编程的三个概念,这里的问题就是出现在可见性上。主线程虽然将run置false,但是这个修改对t线程来说并不可见,也就是说t线程读到的run的值依然为true,所以无法退出while循环。那为什么会这样呢?这里就要先说一下Java的内存模型(JMM)。

Java内存模型(JMM)

 

Java的内存模型中,分为主内存和工作内存,主内存是所有线程共享的,而工作内存是线程独有的。所有的变量会放在主内存中,当某个线程需要频繁获取某个变量时,为了提高效率,JIT编译就会将该变量加载到线程的工作内存中,这样当线程在读取该变量时,就可以直接在自己的工作内存中读取,而不用再去主内存中获取了,当线程修改了该变量时,会将修改后的值写回主内存中。这样就会引发可见性的问题了,因为每个线程都是从自己的工作内存中获取需要的变量,而不是从主内存中获取,当某个线程修改了变量的值时,其他的线程就看不到修改后的值了。

volatile关键字

了解了Java的内存模型,那上面的程序无法退出循环的原因就找到了。虽然主线程将run的值修改为了false,但是因为t线程是在自己的工作内存读取的run的值,读到的一直为true,所以就一直无法退出循环了。

 

那如何解决这个可见性问题呢?那就要引出今天的主角了:volatile关键字。volatile关键字可以解决并发编程中的可见性和有序性问题。上述代码只需要在变量run前面加个volatile关键字,t线程就可以正常退出了。

static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
​
        }
    });
    t.start();
    TimeUnit.SECONDS.sleep(1);
    run = false; // 线程t不会如预想的停下来
}

三. 进一步认识volatile关键字

volatile不能保证原子性

先看一下下面的代码

private static volatile int inc = 0;
​
private static void increase() {
    inc++;
}
​
public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            for (int j = 0; j < 100; j++) {
                increase();
            }
        }).start();
    }
    
    // 休眠10s,上面线程肯定都已经执行完毕了
    TimeUnit.SECONDS.sleep(10);
    System.out.println(inc);
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是1000。但是事实上运行它会发现每次运行结果都不一致,很多时候是小于1000的。

造成这个结果的原因就是volatile没办法保证对变量的操作的原子性。

首先需要明白自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  假如某个时刻变量inc的值为10,

  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  那么两个线程分别进行了一次自增操作后,inc只增加了1。

要想保证上面结果的正确性,可以通过synchronized关键字来完成,只需修改increase方法即可

private static synchronized void increase() {
   inc++;
}

四. volatile关键字的使用场景

volatile关键字主要应用在需要确保某个变量在多线程中的可见性和有序性情况中,这里有两个典型的应用:两阶段终止模式和DCL(双重检测)的单例模式中。前者利用了volatile的可见性,后者利用了volatile有序性。

两阶段终止模式

@Slf4j
class TwoPhaseTerminationVolatile {
    private Thread monitor;
    private volatile boolean stop = false;
    private boolean starting = false;
​
    public void start() {
        // 此处需要synchronized关键字,防止多线程并发问题
        synchronized (this) {
            if (starting){
                return;
            }
​
            starting = true;
        }
​
        monitor = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                if (stop) {
                    log.debug("处理问题");
                    break;
                }
​
                try {
                    Thread.sleep(10); // 此处被中断
                    log.debug("执行监控"); // 此处被中断
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
​
        });
​
        monitor.start();
    }
​
    public void stop() {
        stop = true;
    }
}

这里通过volatile修饰的变量stop实现monitor线程的退出。

DCL单例模式

public class Singleton {
    //通过volatile关键字来防止指令重排序
    private volatile static Singleton instance;
​
    private Singleton(){}
​
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

前面介绍过一句简单new对象 instance = new Singleton(),会被编译器编译成如下JVM指令:

memory =allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance =memory; //3:设置instance指向刚分配的内存地址

但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:

memory =allocate(); //1:分配对象的内存空间

instance =memory; //3:设置instance指向刚分配的内存地址

ctorInstance(memory); //2:初始化对象

当线程A执行完1,3,时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象

而我们在instance对象前面增加一个修饰符volatile关键字,就可以避免这种指令重排序的问题。

五. 一道面试题

这是我在LeetCode上面看到的一道关于volatile关键字的面试题,细节我就不细说了,这里直接献上链接

https://leetcode-cn.com/circle/discuss/8X13Ub/?um_chnnl=huawei?um_from_appkey=5fcda41c42348b56d6f8e8d5

面试官分享的代码,为什么下面的代码中线程1会正常停止,完全想不明白,代码如下:

boolean run = true;
volatile int s = 1;
public static void main(String[] args) throws InterruptedException {
​
    Vol v = new Vol();
    //thread 1
    new Thread(() ->{
        while (v.run) {
           
            int a = v.s;    //如果不注释这行,线程1无法中止
        }
    }).start();
    //thread 2
    new Thread(() ->{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
​
        v.run = false;
        System.out.println("set run false");
    }).start();
}

代码如上,while(run){} 判断线程是否继续循环,run是一个非volatile变量,因此一般情况线程1无法读取到线程2对run的修改,所以无法停止;但是如果在while中加入int a = v.s, v.s是一个volatile变量,线程1就可以停止了。代码里只对v.s进行读取难道也会读取主内存里run的值吗?

更新: 感谢各位的解答和提供信息,目前比较合理的回答:

https://www.zhihu.com/question/348513270

https://stackoverflow.com/questions/67233073/does-reading-a-volatile-variable-affects-the-value-of-other-no-volatile-variable

                                                                                           微信公众号


                                                                                         头条号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值