Java中的volatile关键字最全总结

Java技术迷

共 7895字,需浏览 16分钟

 ·

2021-11-14 17:05

点击关注公众号,Java干货及时送达

作者 | 汪伟俊

 | 公众号:Java技术迷(JavaFans1024)


粉丝福利:小编会从今天留言的小伙伴中随机抽赠送8.88元现金红包。娱乐抽奖,大家随缘积极参与啦,给生活一点小幸运~感谢大家的支持

变量的不可见问题

来看一个简单的案例:

public class VolatileDemo {    public static void main(String[] args) {        MyThread myThread = new MyThread();        myThread.start();
while (true) { if(myThread.isTag()){ System.out.println("----------"); } } }}
@Dataclass MyThread extends Thread { private boolean tag; @Override public void run() { tag = true; System.out.println("子线程中tag为:" + tag); }}

这段程序应该非常好理解,当子线程被创建并调用时,子线程会执行run()方法将tag的属性值修改为true,修改完成后main方法中的while循环条件就成立了,所以程序的运行结果应该是:

子线程中tag为:true----------

但事实上,运行结果是这样的:


image.png

那么很明显,main方法并没有得到子线程修改后的tag值,这一现象就是多线程下变量的不可见问题。


那么main方法为何得不到子线程修改后的tag值呢?我们需要来了解一下JMM(Java内存模型)。在JMM中,所有的共享变量都会被保存到主存中,共享变量指的是实例变量和成员变量,局部变量不属于共享变量,他是线程私有的;当启动某个线程时,会开辟一个独立的内存空间提供线程使用,并从主存中拷贝一个共享变量的副本存入线程的内存里,线程只能对该变量的副本进行读写操作,不能直接操作主存中的共享变量,不同的线程之间也无法相互访问独立空间中的变量,而是需要通过主存进行数据的传递,如下图所示:

image.png

由此,我们可以分析刚才的程序:



image.png

首先主存中有一个共享变量tag,值为false,当子线程启动时,便会拷贝一份tag的副本存放于子线程的内存空间中,然而在子线程将tag值修改并写回主存之前,main线程也从主存中拷贝了一个tag的副本,所以此时main线程中tag的值仍然为false,这导致main线程中的if条件不成立,又因为while(true)底层的一些原因,使得它的执行效率非常地高,使得main线程无法再去主存中重新读取tag的值,即:取的一直都是main线程中的变量副本,这也就解释了为什么会出现变量不可见的问题了。


解决变量不可见的问题

既然出现了这一问题,那么该如何去解决它呢?

while (true) {    synchronized (myThread){        if(myThread.isTag()){            System.out.println("----------");        }    }}

只需使用同步代码块将使用到共享变量的代码包裹起来即可,此时代码的执行流程如下:

1.main线程获取到锁2.清空线程私有的内存空间3.从主存中拷贝一份共享变量的副本到私有内存4.对变量副本进行操作5.将修改后的变量副本的值重新放回主存6.main线程释放锁

由于main线程在使用tag时需要清空一次内存,并重新获取,这样就能够保证main线程在读取tag值的时候一定是最新的,而synchronized关键字的性能是比较差的,对于这种问题,使用 volatile 关键字将会显得更加优雅,我们只需要使用volatile关键字修饰共享变量即可:

private volatile boolean tag;

那么它的原理又是什么呢?首先子线程和main线程仍然会从主存中复制得到共享变量的副本,当子线程修改了共享变量但还未写入主存时,main线程获取到了共享变量的旧值,而由于共享变量被volatile修饰,所以当子线程将值写回主存时,会使其它线程的共享变量副本失效,失效后其它线程就会重新去主存获取一次值,这样也能够获取到最新的数据。

volatile能够保证不同线程对共享变量的操作可见性,当某个线程修改了共享变量的值时,其它线程便能够立即看到最新的值。

vloatile关键字还有一个特殊的性质,就是可以禁止指令的重排序,编译器为了提高程序的运行效率,它往往会对执行指令进行一个重排序,前提是不会影响到程序最终的运行结果,比如:

int a = 1;int b = 2;a = 3;

在这段程序中,按照从上到下的顺序,首先需要将a的值保存为1,再将b的值保存为2,最后重新将a的值保存为3,但为了提高效率,编译器可能会重新设置代码的执行顺序:

int a = 1;a = 3;int b = 2;

此时只需保存a的值为3,再保存b的值为2,这样就省略了一个步骤,提高了性能,需要注意的是指令重排序不能影响到程序最终的运行结果,所以语句 a = 3 肯定不会在 int a = 1 之前被执行。

来看一个例子:

public class VolatileDemo {
private static int a, b, i, j = 0;
public static void main(String[] args) throws InterruptedException { while (true) { a = 0; b = 0; i = 0; j = 0; Thread t1 = new Thread(() -> { a = 1; i = b; }); Thread t2 = new Thread(() -> { b = 1; j = a; }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("i = " + i + ", j = " + j); } }}

该程序中共有2个子线程,分别会去修改四个变量的值,然后输出被子线程修改后的变量值,这里调用join()方法是为了让主线程等待子线程执行完毕后才去输出变量值。我们可以猜测一下程序的运行结果,若是线程t1先执行,线程t2后执行,则i的值为0,j的值为1;若是线程t2先执行,线程t1后执行,则i的值为1,j的值为0;若是线程t1在执行过程中,t2也得到了执行,则i的值为1,j的值也为1,然而在运行程序之后,却得到了第4种结果:

......i = 0, j = 1i = 1, j = 1i = 0, j = 1i = 0, j = 0

i和j的值竟然均为0?这是为什么呢?原来,这是指令重排序导致的,编译器为了优化程序,很可能会将指令执行顺序重新排序,比如这样:

Thread t1 = new Thread(() -> {    i = b;    a = 1;});Thread t2 = new Thread(() -> {    j = a;    b = 1;});

此时当线程t1在执行过程中,线程t2被执行,那么i和j的值就都为0了,这显然是违背我们正常思维的,为了防止这种情况的发生,可以使用 volatile 关键字修饰这些变量:

private volatile static int a, b, i, j = 0;

这样我们将无法再得到i和j均为0的情况了。

happens-before原则

由于有指令重排序的存在,这将导致我们在分析多线程程序的时候出现一些难以预料的问题,这些问题往往又很难被发现。基于此,从JDK1.5开始,官方提出了happens-bofore原则,指的是如果一个线程对某个变量的操作在另一个线程对该变量的操作之前,则第一个线程的操作必须对第二个线程可见。happens-before共有六项规则:

1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;它表示在一个线程中的每个操作,对于其后续的操作都应该是可见的2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;它表示某个线程在解锁之前的所有操作,都应该对另一个加锁的线程可见3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;它表示某个线程在写入一个volatile修饰的变量之前的所有操作都应该对读取这个volatile修饰的变量的线程可见4.传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;它表示线程A对线程B可见,线程B对线程C可见,则线程A就对线程C可见5.start()规则:如果线程A执行操作 ThreadB.start() ,那么线程A的 ThreadB.start() 操作happens-before于线程B的任意操作;它表示线程A在启动线程B时,此时所有对共享变量的操作都对线程B可见,但线程B启动之后,线程A再对共享变量进行操作,线程B无法保证可见6.join()规则:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作happens-before于线程A从 ThreadB.join() 操作成功返回;它表示线程B在调用 ThreadA.join() 并成功返回后,线程A所有对共享变量的操作都对线程B可见

由此我们可知,对于刚才的案例,如果对四个变量添加了volatile关键字,则线程在对其进行操作时,互相都是可见的,比如线程t1将a赋值为1的时候,线程t2读取了变量a,那么线程t1在为变量a赋值及其之前的所有操作都将对线程t2可见,所以当线程t1将变量a的值修改为1时,线程t2读取到的变量i值一定为0,变量j值一定为1。

基于happens-before原则,volatile重排序也受到了相应的限制:

当对volatile变量进行写操作时,无论前一个操作是什么,都不能重排序当对volatile变量进行读操作时,无论后一个操作是什么,都不能重排序当先对volatile变量进行写操作,后进行读操作时,不能重排序

volatile在单例模式中的应用

来温习一下单例模式的书写:

publicclassSingletonDemo{privatestaticfinalSingletonDemosingletonDemo=newSingletonDemo();privateSingletonDemo(){}publicSingletonDemogetInstance(){returnsingletonDemo;}}

这是饿汉式单例的一种写法,还有懒汉式单例的实现:

publicclassSingletonDemo{privatestaticSingletonDemosingletonDemo;privateSingletonDemo(){}publicSingletonDemogetInstance(){if(singletonDemo==null){synchronized(SingletonDemo.class){if(singletonDemo==null){singletonDemo=newSingletonDemo();}}}returnsingletonDemo;}}

这种方式通常被认为是高效的、线程安全的,然而这种方式仍然面临着一个问题,需要知道的是,对象的创建分为以下几个步骤:


1.JVM首先对new的内容进行解析,在常量池中查找一个类的符号引用2.若是没有找到符号引用,则认为该类是没有被加载的,所以JVM会对其进行类的加载、解析和初始化3.JVM为对象分配内存4.将分配的内存初始化为零值5.调用对象的方法

这些步骤可以简化为:分配内存,初始化实例,返回引用。现在假设线程A执行到了 singletonDemo = new SingletonDemo() ,但由于创建对象的过程并不是一个原子性的操作,且编译器可能会对创建对象的操作进行重排序,所以当JVM为对象分配了内存之后,很有可能会将返回引用的操作提前,此时该引用还没有进行初始化等操作,接着线程B抢占到了执行权,其判断singletonDemo不为空,就能够直接获取到singletonDemo的引用,但它仅仅是一个半成品,还没有进行接下来初始化的操作,此时线程B使用着这个半成品就会出现一些无法预料的问题。

正确的办法是使用volatile修饰单例变量:

private static volatile SingletonDemo singletonDemo;

这样就能避免指令的重排序,使对象的创建步骤正常有序地进行。

volatile的应用场景

由于volatile的特性,使得它适用于一些纯赋值的场景,对于一些非原子性的操作,比如:i++,volatile就不适合了,看一个例子:

public class VolatileDemo {    public static void main(String[] args) throws InterruptedException {        MyThread myThread = new MyThread();        Thread t1 = new Thread(myThread);        Thread t2 = new Thread(myThread);        t1.start();        t2.start();        t1.join();        t2.join();        System.out.println(myThread.flag);        System.out.println(myThread.atomicInteger);    }}
class MyThread implements Runnable {
public volatile boolean flag = false; public AtomicInteger atomicInteger = new AtomicInteger(0);
@Override public void run() { for (int i = 0; i < 5000; i++) { change(); atomicInteger.incrementAndGet(); } }
private void change() { flag = true; }}

对于这个程序,输出的flag结果永远只能是true,但是如果将 flag = true 修改为 flag = !flag ,这就不是一个原子性的操作了,此时程序就会出现true或false的两种输出结果。

volatile还可用于变量的触发器,前面我们了解过happens-before原则,当某个volatile修饰的变量被赋值后,其它线程在获取该变量时,该变量及其之前的操作对其它线程均是可见的,例如:

public class VolatileDemo {
private int a = 1; private int b = 2; private int c = 3; private volatile boolean flag = false;
private void write() { a = 3; b = 4; c = 5; flag = true; }
private void read() { while (flag) { System.out.println("a=" + a + ",b=" + b + ",c=" + c); } }
public static void main(String[] args) throws InterruptedException { VolatileDemo volatileDemo = new VolatileDemo(); while (true) { new Thread(() -> { volatileDemo.write(); }).start(); new Thread(() -> { volatileDemo.read(); }).start(); } }}

在read()方法中,当flag值为true时会输出变量a、b、c的值,而如果flag为true的话,那么为flag赋值之前的操作都将是可见的,所以变量a、b、c的值一定分别是3、4、5,这就相当于一个触发器,当触发器变量满足条件时,刷新之前的变量得到最新值,触发器典型的应用场景就是Web容器的初始化。

本文作者:汪伟俊 为Java技术迷专栏作者 投稿,未经允许请勿转载

1、致歉!抖音Semi Design承认参考阿里Ant Design

2、对比7种分布式事务方案,还是偏爱阿里开源的Seata,真香!

3、Redis存储结构体信息,选hash还是string?

4、扫盲 docker 常用命令

5、最全分布式Session解决方案

6、21 款 yyds 的 IDEA插件

7、真香!用 IDEA 神器看源码,效率真高!

点分享

点收藏

点点赞

点在看

浏览 31
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报