一篇文章读懂volatile

前提

计算机在执行程序代码的时候,实际上执行的是一条条指令,而这些指令,肯定会涉及到数据的读取和写入操作。

在我们的程序中,所定义的变量等临时数据,计算机会放在内存中,也称为主存。

那么问题来了,CPU执行指令的速度是很快的,但是从内存中读取数据和写入数据的过程,相比CPU执行指令的速度来说是比较慢的。如果每个程序都是直接从内存中读取数据,那么由于CPU执行指令的速度和数据的读取写入操作的速度不一致,那么肯定会大大降低了执行的效率,所以在CPU里面引入了高速缓存

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。


Java内存模型

内容

Java程序的所有变量都存储在主内存中。我们知道,Java的每个线程在运行的时候,都会有自己的工作内存,线程所用的变量和数据都是用工作内存中的,工作内存的数据都是从主存中获取的。由于每个线程都是独立的,所以不同线程不可以相互访问工作内存的变量,只有通过主存还传递变量,当线程对数据进行操作之后,会把工作内存的数据刷新到主存,但是这个刷新的时间是不确定的。

多线程带来的脏读问题

int i = 10
i = i + 1
  1. 单线程,i的值存放在内存中,当只有一个线程进行+1操作的时候,先从内存中读取I的值到线程自己的工作内存中,然后进行自增操作,然后写入到自己的工作内存,然后再刷新到主存中。
  2. 多线程,如果同时有两个线程执行+1的操作,我们预期的效果i的值是12。可是在多核CPU中,两个线程可能会同时从内存读取i的值读取到工作内存,此时工作内存之间都是独立存在的。所以当一个线程对i的值进行+1,写到自己的工作内存,然后刷新到主存,此时主存I的值为11,另外一个线程由于是同时读取i的值,也就是读的时候是10,那么操作完成之后也是把主存值变为11,毕竟两个线程对i的操作都是一样嘛。这就不符合我们当初的预期了

多线程引出的问题就是缓存一致性的问题了,被多个线程访问的变量i也被成为共享变量。

那么问题来了,如何才能让多线程执行才能符合我们预期呢?
先了解并发编程的 原子性,可见性,有序性 吧!!!


并发编程特性

原子性

内容

定义

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

实例

账户A给账户B进行银行转账1000元,包括两个操作 1. 账户A扣去1000元,2. 账户B加上1000元。

实际中,这两个操作必须符合原子性,就是说,操作1和操作2要么一起执行,要么全部不执行。

如果不符合原则性,那么会带来问题。当账户A扣去1000元的时候,操作由于某些原因突然中止,那么A账户已经扣去1000元了,可是操作2并没有执行,也就是账户B没有加上1000元,那么用户就白白损失了1000元了。

Java的原子性

定义

在Java内存模型中,只对变量的读取用常量赋值给变量的操作是具有原子性。
变量给变量之间的相互赋值这个过程不具有原子性。
其他的地方,如果要实现更大范围的原子性,可用关键字Synchronized和Lock实现。

实例

x = 10;        //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

由定义可知,只对变量的读取用常量赋值给变量的操作是具有原子性

语句1,满足常量赋值给变量的要求,整个操作符合原子性。

语句2,包含两个操作,1 读取X变量的值,2 将读取到的值赋值给y变量。这两个操作只是各自符合原子性,但是合起来就不符合原子性。

语句3 和语句4是一样的, 包含三个操作, 1 读取x变量的值 , 2 对变量x进行+1 ,3 将步骤2所得的值赋值给变量x。和步骤2一行,独自的操作符合原子性,合在一起就不符合了。

综上,除了语句1,其他语句如果在多线程的情况下执行,很有可能会出现和我们预想不到的结果。


有序性

内容

程序执行的顺序按照代码的先后顺序执行。

指令重排序

处理器为了提高程序运行效率,可能会对输入代码进行优化,也就是对执行指令进行重排序。它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

如何保证指令顺序不一样但是执行的最终结果和代码顺序执行时一样的?

处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

TIP:指令重排序不会影响单个线程的执行结果,但是多线程则不一定。


实例

单线程
int i = 0;              

boolean flag = false;

i = 1;                //语句1
flag = true;          //语句2

语句1和语句2所代表的指令,相互之间并没有什么依赖关系,所以这两语句执行的顺序怎样都不会影响结果。也就是说

可能是 1--->2,也有可能是2--->1,但不影响最终结果

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

我们看到,语句3依赖于语句1,语句4依赖于语句3,所以这个顺序肯定是不可以变的,正是因为这样,所以才可以保证指令重排序但是执行的结果依然不变。

执行顺序可能是

1->2->3->4

2->1->3->4

多线程
boolean inited = false;
//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

 //线程2:
while(!inited ){
   sleep()
}
doSomethingwithconfig(context);

语句1和语句2并没有依赖关系,所以指令重排序之后,线程1可能会先执行语句2然后再执行语句1。可能会发生这种情况,线程1执行完语句2,由于某种原因发生了阻塞,线程2此时跳出死循环,然后执行到doSomethingwithconfig(context)

可是context并没有被加载出来,那么很有可能会出现故障。

综上:指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。


Java的有序性

Java内存模型本身就有一些有序性,也就是说不需要通过任何手段就能够得到保证的有序性。

称为 happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

1 2 4 都比较好理解,就不多多说,说下3

volatile变量规则

如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。(记住这句,后面分析的时候会用到)


理解volatile关键字

保证可见性

共享变量被volatile修饰之后,就说明有以下的特性

  1. 不同线程对同一个变量进行操作时,线程1读取变量,进行修改,写入自己的工作内存,然后会强行刷新到内存中。如果线程2还没有对变量读取过,那么当线程2读取工作内存中的变量的时候,发现工作内存中给的变量已经失效,那么会直接去主存中读取最新的值。这也就保证了可见性!!
  2. 禁止指令重排序。

实例

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}

//线程2
stop = true;

前提:我们期望线程2修改stop的值从而让线程1停止循环doSomething

不用volatile关键字的问题

线程2的语句有两个步骤,1、将true赋值给工作内存中的stop临时变量。 2、将工作内存中的临时变量更新到主存中,但是不是到什么时候更新。可是在执行步骤1之后,线程2可能去做其他事情了。从而导致并没有让主存的stop的值得到更新,那么线程1之前一直是读取工作内存中的值,那么如果某一时刻读取主存中的值的时候,那么stop的值还是没有改变,所以就会一直循环,不符合我们的预期。

加了volatile关键字

线程2还是有两个步骤,和上面的一样,区别在于步骤2,步骤2会马上将线程2的工作内存中的值设置到主存中,

  1. 如果线程1还没有读取stop值,那么读取工作内存的时候会发现读取无效,则会到内存中读取。
  2. 如果线程1在工作内存无效之前已经读取过一次,那么下一次循环的时候(也就是线程2将主存的值更新并且设置线程1的工作内存无效)就会从内存中读取最新的值了!

所以加了关键字,这段代码可以不会发生我们的预期之外,你看神奇吧?!

原理

在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

引用自别人的博客:https://blog.csdn.net/it_dx/article/details/70045286


不保证原子性

package suanfa;

public class VolatileTest {
    public static volatile int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<100;j++){
                        count++;//每个线程执行加100
                    }
                    System.out.print(count+" ");
                }
            }).start();
        }
    }
//    运行结果
//    200 400 500 300 200 600 700 800 900 1000
//    100 200 384 500 400 600 700 800 900 1000
//    200 200 300 400 578 578 678 778 878 978
}

分析

之前曾经说过,count++这个操作不符合原子性,就是说会有三个步骤 1. 读取count变量的值 、2.对值进行+1 、 3. 赋值给工作内存,然后刷新到内存中。

这里说下可见性的本质(个人理解):

  1. 线程准备读取自己工作内存的变量的时候,如果其他线程让主存发生了刷新,那么读取工作空间的变量会失效。
  2. 如果线程1在自己的工作内存没有失效之前已经读取了,线程2让内存的值发生了变化,线程1只用自己成功读取到的值!!

在某一时刻count的值是10,线程1和线程2同时去读取count的值,存放在自己的工作内存,由可见性的本质的第2点可知,假如线程2完成更新操作,让内存的值完成了更新变为了11,可是线程1因为早就读取了值,不会受到影响,所以自己就是操作10,最后更新到内存值还是11,两次++,但是值是11。

说明了volatile并不保证原子性!

解决办法

  1. 通过Synchronized和Lock加锁,实现原子性。
  2. CAS操作,可以去了解AtomInterger的源码就知道CAS是怎么操作的了

参考链接


保证有序性

前面说过volatile不会让指令重排序,所以volatile能在一定程度上保证有序性。

两点去了解有序性

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放其前面语句的后面执行,也不能把volatile变量后面的语句放到其前面执行。

通过代码实例来看

//x、y为非volatile变量
//flag为volatile变量

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

如果执行语句3,那么语句1和2肯定已经执行了,但是由于指令重排序(非volatile关键字),1和2谁先执行并不知道。

而且语句3执行之后的效果,对后面的语句4和5是可见的,4和5的顺序也是不一定的,虽然这里没什么可见的。

语句4和5,语句1和2各自也没什么数据依赖上的关系,但是由于flag是volatile,所以重排序的时候4和5不能在flag前执行,1和2也不能在flag后执行。

前面的例子

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

如果inited加了volatile关键字那么就可以保证不会出错了。

由于有序性,那么inited = true如果执行了,那么前面的context肯定已经初始化了,所以线程2执行就不会出现context没有初始化的情况了!

原理

Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

引用自别人的博客:<https://blog.csdn.net/it_dx/article/details/70045286


应用

Synchronizedvolatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件

  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中
class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();//语句1
            }
        }
        return instance;
    }
}

语句1包括以下3个操作,并不符合原子性(没有加Synchronized的情况下)

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null )

(以下说的是不加Synchronized的情况)

如果不给instance加volatile关键字,那么由于指令重排序的优化,步骤3可能先于步骤2执行,所以当当前线程执行步骤3的时候,其他线程也用了instance资源,这时由于instance不为空,那么直接返回instance,那么就出错了。

如果添加了Synchronized和volatile,也就是源码那样,就可以很好避免上面说的问题了!

总结

  1. 对Java的内存模型有了深刻的印象
  2. 加深了volatile和Synchronized和CAS的印象,以及其中的区别

参考

  1. https://blog.csdn.net/it_dx/article/details/70045286
  2. https://blog.csdn.net/strivenoend/article/details/80440884

原文地址:https://www.cnblogs.com/jieming/p/jiemingli.html

时间: 2024-11-11 15:24:18

一篇文章读懂volatile的相关文章

一篇文章读懂Java类加载器

Java类加载器算是一个老生常谈的问题,大多Java工程师也都对其中的知识点倒背如流,最近在看源码的时候发现有一些细节的地方理解还是比较模糊,正好写一篇文章梳理一下. 关于Java类加载器的知识,网上一搜一大片,我自己也看过很多文档,博客.资料虽然很多,但还是希望通过本文尽量写出一些自己的理解,自己的东西.如果只是重复别人写的内容那就失去写作的意义了. 类加载器结构 名称解释: 根类加载器,也叫引导类加载器.启动类加载器.由于它不属于Java类库,这里就不说它对应的类名了,很多人喜欢称Boots

【转】一篇文章读懂人力资源三支柱体系(COE?BP?SSC)

通过人力资源转型,提升效率和效能   作者:Sharon Li,翰威特大中华区咨询总监. 杰克韦尔奇曾说过“人力资源负责人在任何企业中都应该是第二号人物”,但在中国,99%的企业都做不到.原因很简单,人力资源部没创造这么大的价值——业务增长很快,但HR总在拖后腿.有些人说人力资源部是“秘书”,有人说人力资源是“警察”,在中国,真正认为人力资源部是“业务伙伴”的,真是凤毛麟角. 研究证明,人力资源部可以成为业务驱动力,关键是HR自身要转型. 1. 重新定位人力资源部门 人力资源部成为业务的驱动力,

趣味学习:一篇文章读懂三层交换机【新任帮主】

为什么我们说三层交换机的三层转发性能要比路由器的效率要高的多?有的时候在很多书上面会提及到现在路由器的软件的做的也非常强大,几乎也能够达到限速转发的能力: 软件能够和硬件比吗,不太可能:交换机之所以转发速度快是因为交换机使用的专门的ASIC硬件转发卡,而路由器是software-based 的转发: 我们习惯说,在二层网络环境中相同vlan之间可以通信,不同vlan之间不可以通信,如果想通信必须借助三层设备,所以说三层交换机必须要做的事情是路由转发,但是具体的工作原理是什么样的呢 ,接着看吧!

一篇文章读懂什么是串口通信及其工作原理

介绍 串行通信是在数据处理设备和外围设备之间传输信息的最广泛使用的方法.一般而言,沟通意味着通过书面文件,口头语言,音频和视频课程在个人之间交换信息. 每台设备都可能是您的个人计算机或移动设备在串行协议上运行.该协议是安全可靠的通信形式,具有由源主机(发送方)和目的地主机(接收方)寻址的一组规则.为了获得更好的洞察力,我已经解释了串行通信的概念. 在嵌入式系统中,串行通信是以串行数字二进制形式使用不同方法交换数据的方式.用于数据交换的一些众所周知的接口是RS-232,RS-485,I2C,SPI

(好文推荐)一篇文章看懂JavaScript作用域链

闭包和作用域链是JavaScript中比较重要的概念,首先,看看几段简单的代码. 代码1: 1 var name = "stephenchan"; 2 var age = 23; 3 function myFunc() { 4 alert(name); 5 var name = "endlesscode"; 6 alert(name); 7 alert(age); 8 alert(weight); 9 } 10 myFunc(); 11 myFunc(); 上述代码

一篇文章看懂Android学习最佳路线

为什么中高级Android程序员不多呢?这是一个问题,我不好回答,但是我想写一篇文章来描述下Android的学习路线,期望可以帮助更多的Android程序员提升自己. 作者:来源:Android开发中文站|2015-11-12 10:40 收藏 分享 前言 看到一篇文章中提到"最近几年国内的初级Android程序员已经很多了,但是中高级的Android技术人才仍然稀缺",这的确不假,从我在百度所进行的一些面试来看,找一个适合的高级Android工程师的确不容易,一般需要进行大量的面试才

一篇文章看懂spark 1.3+各版本特性

Spark 1.6.x的新特性Spark-1.6是Spark-2.0之前的最后一个版本.主要是三个大方面的改进:性能提升,新的 Dataset API 和数据科学功能的扩展.这是社区开发非常重要的一个里程碑.1. 性能提升根据 Apache Spark 官方 2015 年 Spark Survey,有 91% 的用户想要提升 Spark 的性能.Parquet 性能自动化内存管理流状态管理速度提升 10X 2. Dataset APISpark 团队引入了 DataFrames,新型Datase

angularjs 一篇文章看懂自定义指令directive

 壹 ? 引 在angularjs开发中,指令的使用是无处无在的,我们习惯使用指令来拓展HTML:那么如何理解指令呢,你可以把它理解成在DOM元素上运行的函数,它可以帮助我们拓展DOM元素的功能.比如最常用ng-click可以让一个元素能监听click事件,这里你可能就有疑问了,同样都是监听为什么不直接使用click事件呢,angular提供的事件指令与传统指令有什么区别?我们来看一个例子: <body ng-controller="myCtrl as vm"> <d

一篇文章搞懂python2、3编码

说在前边: 编码问题一直困扰着每一个程序员的编程之路,如果不将它彻底搞清楚,那么你的的这条路一定会走的格外艰辛,尤其是针对使用python的程序员来说,这一问题更加显著, 因为python有两个版本,这两个版本编码格式却完全不同,但我们却经常需要兼顾这两个版本,所以出现各种问题的几率就大了很多. 所以在这里我试图用一篇文章来彻底梳理整个python语言的编码问题,尽量降低以后在这方面举到问题的可能性. ps 此文一定程度上参考和引用了alex的博客:“https://www.cnblogs.co