带你彻底理解JAVA关键字之volatile

提示:关于Java关键字volatile文章只是本人在看书学习的过程中的一些记录与思考,可能有理解不到位的地方,如果有不对的地方,欢迎评论区讨论👏

多线程安全

什么线程安全问题?
多线程安全问题,许多一线程序员谈之色变的问题。这往往是因为首先我们自己打心底里就对多线程安全问题很抗拒,觉得线程安全问题很复杂难以理解。克服恐惧最好的办法就是去面对恐惧。下面我们一起来探讨探讨(其实也不是想象中那么困难哦~~)
为了更好的理解线程安全问题,首先我们需要了解一下
JAVA的内存模型**,此处要与JVM内存模型区分开来,很多同**学总是会把这两个内存模型混淆。(不要着急,磨刀不误砍柴工)
简单来说,Java内存模型规定了所有的变量都存储在主内存中,而每一条线程(比如我们的一次接口调用)都拥有自己的工作内存,线程的工作内存就是用来保存我们每次用到的一些变量(我们平时在类中定义的一下变量),而这些变量都是从主内存中拷贝过来的副本。在线程的后续的业务逻辑处理的过程中,所有涉及到对变量的修改,都是在每个线程自己的工作内存中完成的,然后会同步到主内存,线程与线程之间是无法直接访问对方的工作内存中的变量,只能通过与主内存交互,完成变量值的传递滴。可以通过下面一幅图来加深我们的理解,****

在这里插入图片描述
通过上面简单的学习,相信大家已经对Java内存工作原理有了一些初步的认识,在此基础上,我来考虑一个问题,上述内存模型,多线程情况下会出现什么问题呢?显而易见,这就是我们前面说的线程安全问题——数据不一致。举个列子,当前主内存中有一个库存数量 amount =2,此时A线程请求扣减库存,则A线程的依次操作是:
1、去主内存中读取amount=2,并拷贝一份副本到自己的工作内存中
2、在自己的工作内存中,做自己的业务处理,并扣减扣库存,amount =amount-1
3、将自己对修改后的变量 amount =1,刷回到主内存当中。
经过上面三步操作,线程A圆满的完成了自己的任务。如果此时B线程,刚好在A线程完成上述三步后,请求扣减库存,则重复上述三个步骤,并完成自己的工作,此时,主内存中库存数量amount=0 皆大欢喜。但是事情往往不是想我们预想的那样完美的执行,试想一下,假如,线程B在线程A完成前两步操作,还没来的及将修改后的变量同步回主内存的情况下,请求扣减库存,这个时候发生的情形是这样的,
1、线程B去主内存读取库存 amount=2
2、在自己的工作内存中,做自己的业务处理,并扣减扣库存,amount =amount-1
3、将自己对修改后的变量 amount =1,刷回到主内存当中。
至此,不管是线程A先将自己工作内存中的修改后的库存刷回主存,还是线程B先把自己工作内存中修改的库存变量刷回主内存中,这时候,主存中的库存都是不正确,因为两次扣库存,数量没有变为0,在web层面看来,两次下单扣库存,库存的数量却没有扣减正确。这就是多线程安全问题。
至此,我们知道了,为什么多线程环境下会出现线程安全问题了(线程安全问题其实也很好理解),接下来,我们今天的主角volatile就要闪亮登场了。

volatile

volatile 英文翻译过来就是挥发性的;不稳定的;爆炸性的;反复无常的,为什么用这个英文来作为关键字呢?后面我会说一下我的理解,我们先来看一下,《深入JVM虚拟机》这本书对它的解释:
Java关键字volatile,可以说是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义为volatile修饰之后,这个变量就具备了以下两个属性,

(1)保正变量对所有线程的立即可见性。
(2)禁止jvm指令重排
下面我们来对这两点进行解释与分析

1、线程之间可见性

线程之间如何通信?
根据上面的Java内存模型,线程之间工作内存中的变量是相互独立,无法相互自可见的,必须通过与主内存交互,才能完成线程之间的通信的,而volatile关键字,就可以保证,当变量在线程a中被修改后,其他线程工作内存中的该变量的值立即可以得知改变(这里并没有改变线程之间通信的方式:工作内存——主内存——工作内存),这是怎么实现的呢?
简单的来说,volatile关键字修饰变量的时候,当变量发生改变的时候,这个时候它会保证变量的修改被安全的同步到主内存当中,同时,使得其他线程的工作内存中对该变量的缓存失效,这样的话,其他线程必须重新去主内存中获取变量的最新值,即表现为对其他线程立即可见。
但是据此很多人就草率地得出结论,基于volatile修饰的变量,在并发下的运算一定是线程安全的。这个结论是不正确的,因为在Java里面的运算是非原子的,下面我通过一个简单例子,来说明一下这种情况:

//变量自增并发下的问题
public class VolatileTest{
public static volatile int i=0;
public static void increase(){
i++;
}
 public static void main(String[] args){
        for(int i = 0; i < 10; i++){
            //每个线程对t进行1000次加1的操作
            new Thread(new Runnable(){
                @Override
                public void run(){
                    for(int j = 0; j < 1000; j++){
                        increase();
                    }
                }
            }).start();
        }

        //等待所有累加线程都结束
        while(Thread.activeCount() > 1){
            Thread.yield();
        }

        //打印t的值
        System.out.println(i);
    }
}
	这段代码最终执行结果会是我们想要的10000么?答案是否定的,而且每次运行的结果都不一样,都是一个小于10000的数字,(大家可以自行运行一下看看)这是为什么呢?不是说好的线程立即可见么?

网上很多人的解释是,线程1更新了i=0+1的值,还没有同步到主内存中,线程2,也读取了i=0的值,然后线程1同步回主内存,线程2,工作内存中还是i=0,然后执行i=0+1;然后同步到主内存中,这样就导致两次自增操作,结果只增加了1,这种解释其实是错误的,有很大的误导性,没有真正的理解volatile的原理,及Java底层运算原理。

	问题的关键在于,自增运算**i++**,我们可以通过Javap反编译一下这句话,会发现一行i++代码在Class文件中却产生了四条指令如下:
public static void increase();
Code:
Stack =2,
0: getstatic      //Field i
3: iconst_1
4: iadd
5: putstatic

我们知道Java的方法的执行是在栈中进行的,每个方法的执行就是一个个栈桢的进栈与出栈,当线程1修改i=0+1=1的时候并保证同步到主内存后,此时线程2的工作内存里i值已经是i=1了,(之前的缓存已经失效,重新读取主内存中的值),即当线程执行 getstatic将变量取到栈顶的时候,已经保证了取到的是最新的i值,但是,在接下来执行iconst_1,iadd这些指令时,其他线程可能又已经把i的值增到了,并刷回主存,这个时候线程2再执行putstatic指令,就可能把较小的值更新到主内存当中了,这就是问题的关键。

2、指令重排

**首先,指令重排通俗地解释,就是我们写的一行行代码,在jvm生成相应的字节码去运行的时候,jvm为了提高效率,会在不改变运行结果的情况下对指令的执行顺序做出优化重排,如下**
 public int add (){
  int i=3;
  int j=5;
  int s = i+j;
  boolean flag =true;
  return s;
 }

在上面代码当中,jvm在执行的时候,会对方法生成的字节码进行指令优化重排,boolean flag =true;可能会在int i=3;之前执行也能在int s = i+j;之前被执行,当然,不管如何指令重排,jvm都会保证,该方法最终的执行结果返回是正确的,这样的指令重排优化才有意义。
上述情况,在单线程情况下不会出现问题,不管如何,单线程最终运行方法得到的结果都是正确的,这就是Java内存模型中描述的所谓的“线程内表现串行的语义”,但是在多线程情况下,我们考虑下面情况,

//假设以下是线程A中的代码
Map configOptions = new HashMap();
configOptions =readConfigFile(fileName);
initialized = true

//假设以下是线程B中的代码

if(initialized){
dosomeThingWithConfig()
}

上述伪代码中,如果线程A在执行的时候,jvm进行了指令重排,initialized = true,排在了configOptions =readConfigFile(fileName);之前被执行,此时线程B,从主内存中读取initialized = true,开始执行自己的逻辑,这时候初始化配置还没有完成,则B使用到配置信息时就会报错。这就是Java内存模型中描述的所谓的“线程外表现并行的语义”
而当我们修改一下

Map configOptions = new HashMap();
configOptions =readConfigFile(fileName);
volatile initialized = true

就不会出现上面的问题了,因为volatile 关键字,可以起到禁止jvm指令重排优化操作,这样只有执行完configOptions =readConfigFile(fileName);才会执行volatile initialized = true。其原理就是,volatile关键字修饰的变量,生成字节码后会在变量前后生成内存屏障,指令重排时,不能把内存屏障后面的代码排到前面执行。

那么什么情况下,使用volatie关键只可以保证线程安全呢,只有满足下面的情况,则可以保证线程的并发安全:
1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2、变量不需要与其他状态变量共同参与不变约束。

总结: (开篇还提到volatile的英文翻译:不稳定的;反复无常的,其实这就是告诉jvm我是不稳定的,对我的操作要及时地同步给其他使用者,并且不能把我随便排序,个人理解哈)码字不易,有理解不到位的地方,欢迎大家,评论区讨论指教,共同进步!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值