一篇文章读懂volatile

本文深入探讨Java内存模型,解析CPU高速缓存与主存交互机制,揭示多线程环境下的脏读问题及解决方案。阐述并发编程特性,如原子性、可见性和有序性,通过实例说明volatile关键字的作用及限制,对比Synchronized关键字,提供高性能并发编程实践。
摘要由CSDN通过智能技术生成

前提

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

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

那么问题来了,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变量都是从主内存中获取最新的。

引用自别人的博客:blog.csdn.net/it_dx/artic…


不保证原子性

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

引用自别人的博客:<blog.csdn.net/it_dx/artic…


应用

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. blog.csdn.net/it_dx/artic…
  2. blog.csdn.net/strivenoend…
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值