Volatile关键字理解

2020-06-10  本文已影响0人  yaco

学习volatile关键字之前,先看一下并发编程中的一些概念

1 Java内存模型

解决线程通信和同步的两种方案

Java并发编程需要解决的最大问题就是线程之间如何进行通信和同步,常见的解决方案分为:

在Java中选择的是共享内存并发模型,两种模型之间的区别如下:

如何通信 如何同步
消息传递并发模型 线程之间必须通过显式的发送消息来实现通信 发送消息进行通信起始就是隐式的同步方法,发送消息总是在介绍消息之前
消息共享并发模型 线程之间通过共享程序的公共状态,通过读写内存中的公共状态实现隐式的通信 必须显式的指定某段代码在执行的时候,一定要互斥的执行

Java的内存模型

首先看一下Java运行时数据区:

Java运行时数据区域

可以看到在JMM(Java内存模型)中,堆和方法区都是共享的,那么这块的内存不可见是怎么产生的尼?

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为CPU访问缓存区比访问内存要快得多。

JMM抽象示意图

从图中可以看出来:

所以,线程A和线程B之间如果要实现通讯,必须将更改过后的本地内存B刷新到主内存中去,然后线程A不可以直接读取本地内存中的数据,而是要去主内存中读取。

内存可见性

因为JMM存在这样的机制,所以形成了内存不可见的问题,如果不进行响应的操作,就可以发生脏数据的情况产生。也由此引发了内存可见性的概念。

内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值

2 缓存一致性的两种解决办法

​ 早期的cpu中是通过在总线加锁来解决缓存不一致的,因为计算机cpu通信是通过总线来执行的,如果在总线上面加锁的话就阻塞来其他cpu对该变量的访问,如上面的代码在程序执行时总线发出lock指令,那么只有在这段代码执行完毕后其他cpu才能读取变量执行相应的指令,这样就解决了缓存一致性问题

​ 由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

​ 所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。


缓存一致性协议

3 重排序

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。他的意义就在于尽可能的提高CPU的处理性能。

一个简单的例子:

a = b + c;
d = e - f ;

先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

4 并发编程的三个重要特性

要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。举个列子

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

  那么大家分析下以上语句是否是原子性操作呢?咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

  语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

  同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

  所以上面4个语句只有语句1的操作具备原子性。
  也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

  从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性

  对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

5 volatile的内存语义

在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:

可见volatile关键字保证了并发编程中的可见性和有序性,但是并没有保证原子性。

valiatle实现可见性和有序性

实例说明一下:

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

这个线程1一定会发生线程中断吗?答案是否定的。

在前面已经了解到每一个线程在运行过程中都有自己的工作内存,线程1在运行时,会将主内存中的stop变量值拷贝一份在自己的工作内存中。

那么当线程2更改了stop变量之后,还没来得及写回到主内存中去,线程2又去干别的事了,那么线程1此时不知道线程2对stop变量进行了更改,因此还会一直循环下去。

但是,如果使用了volatile关键字就不一样了:

第一,valatile关键字会强制将修改过后的值立即写入主存;

第二,使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

那么这样的话,线程1读取到的肯定就是最新的值了。

valitale不保证操作的原子性

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

此操作的结果始终小于10000,正是因为a++的操作是非原子性的,假设当线程1读取到了a的值,执行了加1的操作,但是这个时候线程2又去读主内存中的a,还没等到线程1刷新上去,线程2就已经读走了,所以产生了脏读的现象。

解决办法呢也是多种多样的,加锁就可以解决原子性问题比如采用synchronized、lock或采用AtomicInteger都可以解决原子性问题。


参考:

深入理解volatile关键字的作用

深入浅出Java多线程

上一篇下一篇

猜你喜欢

热点阅读