JAVA多线程基础篇-关键字volatile

1.概述

volatile是java多线程中一个常见的关键字,面试中被问的频率也比较高。那么volatile的作用是什么?以及其实现原理是什么?本文将基于上述问题,结合一些具体案例来分析volatile的作用以及实现原理,来帮助大家更好地理解volatile。

2.案例分析

2.1 volatile关键字的作用

关键字volatile的主要作用是使变量在多个线程间可见。

由上述定义可知,volatile的作用是是变量能在多个线程之间可见。若按照上述描述,说明一般情形下,不同线程之间的变量应该是互不可见的。那么究竟是否是这样呢,我们先写段代码验证一下。

public class RunThread extends Thread {

    private boolean isRunning = false;

    public boolean isRunning() {
        return isRunning;
    }

    public void setRunning(boolean isRunning) {
        this.isRunning = isRunning;
    }

    public void run() {
        System.out.println(Thread.currentThread().getName() + "进入run了");
        try {
            TimeUnit.SECONDS.sleep(1);
            setRunning(true);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "线程已停止!");
    }
}

public class Run {
    public static void main(String[] args) throws InterruptedException {
        RunThread runThread = new RunThread();
        runThread.start();
        while (true) {
            if (runThread.isRunning()) {
                System.out.println("isRunning is:" + runThread.isRunning());
                break;
            }
        }
    }
}

线程运行后出现如下结果:
在这里插入图片描述

由上述运行结果可知,虽然RunThread在休眠1s后改变了isRunning属性的状态为true,但是主线程Run并没有感知到isRunning属性的改变,因此不会打印"isRunning is:true"。明明属性状态值isRunning已经被修改,为什么没有打印呢?这里就需要了解JMM内存模型了。

2.2 JMM内存模型

JMM(Java Memory Model)是用来屏蔽各种硬件和操作系统的内存访问差异,实现让Java程序在各种操作系统平台下都能达到一致的访问效果。JMM内存模型描述了JAVA程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的细节。
它规定了所有变量存储于主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。JMM内存模型如下图所示:
在这里插入图片描述
由上图可知,每一个线程都拥有自己的本地工作内存,内部保存了线程需要使用的变量的工作副本。线程对变量的的所有操作(读、写)都必须在工作内存中完成,而不能直接读写主存中的变量。而且不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转完成。本地工作内存是JMM的 一个抽象概念,并不真实存在。
由于JMM内存机制的因素,导致上述案例中的现象,RunThread线程与Run线程分别存储了变量isRunning的副本,RunThread修改的也是自己工作内存中的副本,主线程Run无法感知。
上述案例中的问题分析:
在这里插入图片描述

  • 1.子线程RunThread从主内存读取isRunning=false,写回本地工作内存;
  • 2.子线程RunThread将isRunning的值修改为true,此时isRunning的值还没有写回主内存;
  • 3.此时主线程Run读取到isRunning的值为false;
  • 4.当子线程RunThread将isRunning的值写回主内存后,主线程Run中while(true)调用的是系统底层代码,该部分代码快速执行,快到没时间从主内存中读取isRunning的值,因此isRunning一直保持值为false;
  • 5.一旦线程Run读取到最新的isRunning值为true,则会打印"isRunning is:true"。

那么,针对上述问题,该如何解决呢?可以使用volatile来解决。

2.3 volatile使用

为了解决2.1节案例中的问题,可以使用volatile来修饰isRunning属性,使isRunning在多个线程间可见。修改代码如下:

public class RunThread extends Thread {

    private volatile boolean isRunning = false;

    public boolean isRunning() {
        return isRunning;
    }

    public void setRunning(boolean isRunning) {
        this.isRunning = isRunning;
    }

    public void run() {
        System.out.println(Thread.currentThread().getName() + "进入run了");
        try {
            TimeUnit.SECONDS.sleep(1);
            setRunning(true);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "线程已停止!");
    }
}

public class Run {
    public static void main(String[] args) throws InterruptedException {
        RunThread runThread = new RunThread();
        runThread.start();
        while (true) {
            if (runThread.isRunning()) {
                System.out.println("isRunning is:" + runThread.isRunning());
                break;
            }
        }
    }
}

运行修改后的代码,可得如下结果:
在这里插入图片描述
为什么加了volatile就可以实现上述效果,或者volatile到底做了啥?接下来我们分析一下。

2.4 volatile特性

volatile实现上述功能的主要包含以下几个特性:

1.保证可见性
2.保证有序性

2.4.1 保证可见性

volatile保证不同线程对共享变量操作的可见性,也就是说某个线程修改了共享变量,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其它线程读取该共享变量时,会从主内存重新获取变量最新值,而不是从当前线程的工作内存中获取。

2.4.2 保证有序性(禁止指令重排序)

保证有序性与禁止指令重排序在这里是一个意思。

什么是指令重排序?
为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。具体案例如下2.5节所示。

重排序需要遵守一定的规则:

1.重排序操作不会对存在数据依赖关系的操作进行重排序;
2.重排序是为了优化性能,但是不管如何重排序,单线程下程序的执行结果不能被改变(as-if-serial原则)。

什么是数据依赖性?

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

指令重排序的几种方式如下:

  • 1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • 2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  • 3.内存系统的重排序。由于处理器使用缓存和读、写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

上述方式可以简化成下图:
在这里插入图片描述

2.5 重排序概念讲解案例

由于指令排序是机器级的优化操作,执行时应该是相应汇编代码的执行。本文为了直观展示其原理,采用JAVA伪代码来分析其原理。

boolean flag = false;
byte[] fileBytes = null;
fileBytes = readFile(fileUrl);
flag = true;
while(flag) {
	load(fileBytes);
}

单线程下上述代码执行不会有什么问题,但如果是多线程执行,可能会发生指令重排序,由于属性flag与其它两个变量并无实质性的关联,因此上述代码重排序后可能会变成如下形式:

boolean flag = true;
byte[] fileBytes = null;
fileBytes = readFile(fileUrl);
while(flag) {
	load(fileBytes);
}

此时fileByete可能还没来得及初始化,就会直接去调用load方法,程序就会出现异常。
为了防止指令重排序,可以使用volatile来修饰属性flag:

volatile boolean flag = true;
byte[] fileBytes = null;
fileBytes = readFile(fileUrl);
while(flag) {
	load(fileBytes);
}

需要注意的是在JDK5之前,即使使用volatile修饰了信号变量,也可能出现问题,因为早期的Java内存模型虽然不允许volatile变量之间重排序,但允许volatile变量于普通变量重排序。也就是说,在JDK5之前,即使我们使用volatile修饰initialized变量,依旧可能出现flag = true,fileBytes变量没有初始化的情况。

3.volatile保证有序性原理

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

3.1 内存屏障的含义

在这里插入图片描述
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。JMM内存屏障主要分为以下四类:
在这里插入图片描述

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序;
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序;
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序;

3.2 JMM针对volatile的重排序

java编译器会在生成指令序列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
在这里插入图片描述
举例描述上述表格中的意义,以普通读/写为例,若第一个操作为普通变量的读或写时,且第二个操作为volatile写,则编译器不能重排序这两个操作。若第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个规则确保了volatile写之前的操作不会被编译器重排序到volatile写之后。当第一个操作为volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。当第一个操作是volatile写,第二个操作时volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,因此JMM采取保守策略,基于保守策略的JMM内存插入策略如下:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:
在这里插入图片描述
下面是在保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

在这里插入图片描述

4.注意事项

4.1 volatile不保证原子性

原子性指的是在一次操作或者多次操作中,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。volatile不保证原子性。具体案例如下:

public class VolatileAuto implements Runnable {

    public volatile static int count = 0;

    private static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }


    @Override
    public void run() {
        addCount();
    }
}


public class VolatileAutoMain {

    public static void main(String[] args) {
        VolatileAuto[] volatileAutos = new VolatileAuto[100];
        for (int i = 0; i < 100; i++) {
            volatileAutos[i] = new VolatileAuto();
        }
        for (VolatileAuto volatileAuto : volatileAutos) {
            new Thread(volatileAuto).start();
        }
    }
}

执行结果如下:
在这里插入图片描述
运行上述代码可知,count的输出值每次都不一样,但是不会出现期望结果10000,而且属性count还用volatile修饰了,按照之前的逻辑,加了volatile关键字,count值每次修改都会被刷新至主存,且其它线程每次也都是从主存中获取最新的count值,那为什么还会出现这种情况呢?
虽然volatile会使不同的线程每次从主内存中读取,而不是从线程本地工作内存中读取,这样是保证了数据的可见性。但是需要注意的是:如果修改实例变量中的数据,例如count++,也就是count=count+1,这个操作并不是一个原子操作,它包含下面三步:

  • (1)从内存中取出count的值;
  • (2)计算count的值;
  • (3)将count的值写回内存中

若在上述步骤(3)中,线程1计算完count的值,还未来得及将count的值写回内存,线程2来获取count的值,此时线程2拿到未被线程1修改的count,同样执行count=count+1操作,执行完成后同样需要将count写回主内存,这时就会将线程写入主内存中的值覆盖。虽然是两个线程执行分别执行了count=count+1,但是由于开始拿到的count值是同一个,实际上count的值只增加了一次,因此导致最后的count值不符合预期值10000。具体流程如下图所示:
在这里插入图片描述
上述问题的根本原因:自增操作是非原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。解决方法是加锁,保证资源同步访问,修改代码如下:

public class VolatileAuto implements Runnable {

    public volatile static int count = 0;

    private static synchronized void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }


    @Override
    public void run() {
        addCount();
    }
}

执行结果如下:
在这里插入图片描述

4.2 long和double变量规则

在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆 分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把 一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,即允许虚拟机实现可以不保证64位数据类型的load、store、read、和write者4个操作的原子性,这点就是所谓的long和double的非原子性协定。任意的读操作在JSR- 133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。这点就是所谓的long和double的非原子性协定。
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对他们进行读取和修改操作,那么某些线程可能会读取到一个即非原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况非常罕见(在目前的商用Java虚拟机中不会出现),因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。在实际开发中,目前各种平台下的商用虚拟机几乎都选择吧64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile。

4.3 volatile使用场景

volatile适用于以下场景:

1.某个属性被多个线程共享,其中一个线程修改了此属性,其他线程可以立即获得修改后的值,比如线程循环标识boolean flag;
2.volatile还可以用于单例模式,可以解决单例双重检查对象初始化代码执行乱序问题。

下面展示volatile应用于单例模式代码:

public class Singleton {

    private volatile static Singleton singleton = null;

    public Singleton() {
        System.out.println(Thread.currentThread().getName() + "生成singleton");
    }

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

上述单例模式中使用了双重检验,这是为啥?如果不用volatile关键字修饰属性又会怎样?
单线程情况下,属性singleton不加volatile关键字也不会出现任何问题,但是多线程情况下,根据我们之前说的,会出现指令重排序的情况,就有可能出现空指针问题。首先需要了解的是对象创建包含下面三个过程:

  • (1)分配内存空间
  • (2)调用构造函数,初始化对象
  • (3)返回地址给引用

由于步骤(2)和步骤(3)不存在数据依赖关系,而且无论是重排前还是重排后的执行结果在单线程中并没有发生改变,因此这种重排优化是允许的。若此时先执行步骤(3),步骤(2)还未执行完,另一个线程来执行if (singleton == null)会返回false,此时对象未完全生成,是个半成品,当访问对象方法或属性时,就会抛出空指针异常。使用volatile避免指令重排序,同时保证写回主存中的对象只有一个,实现真正意义上的单例。

4.4 volatile与synchronized的区别

1.关键字volatile是线程同步的轻量级实现,所以volatile的性能略胜于synchronized,并且volatile只能修饰变量,而synchronized可以修饰方法、代码块等;
2.多线程访问volatile不会发生阻塞,而synchronized会出现阻塞;
3.volatile能保证数据的可见性,但不能保证原子性;而synchronized可以保证原子性,也可以间接保证可见性,因为它会将私有内存和共有内存中的数据做同步(下一篇文章分析关键字synchronized);
4.关键字volatile解决的是变量在多个线程之间的可见性;而synchronized关键字解决的是多个线程之间访问资源的同步性。

5.小结

1.volatile只能作用域属性,我们用volatile修饰属性,编译器就不会对这个属性做指令重排序;
2.volatile能保证数据的可见性,任何一个线程对其修饰的属性进行修改,其它线程也立马可见,volatile属性不会被线程缓存,始终从主内存获取数据;
3.volatile可以使得long和double的赋值是原子的;
4.volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性;
5.volatile属性的读写操作都是无锁的,它比synchronized高效,但却不能替换synchronized;
6.volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作,happens-before程序规则大家可自行查阅资料,下面参考文献中就有。

6.参考文献

1.《JAVA多线程编程核心技术》-高洪岩著
2.https://zhuanlan.zhihu.com/p/151289085
3.https://www.bilibili.com/video/BV1BJ411j7qb
4.https://www.jianshu.com/p/ade59e3b3266
5.https://mp.weixin.qq.com/s/Oa3tcfAFO9IgsbE22C5TEg
6.《JAVA并发编程的艺术》-方腾飞著

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值