oynix

于无声处听惊雷,于无色处见繁花

Java volatile关键字

volatile是Java的一个关键字,用来修饰变量,被volatile修饰的变量,在每次更新之后会立刻刷新到主内存中,令缓存行无效,其他线程从主内存读取的会是最新值,保证变量的可见性,常用于并发编程中。

这么一说有些抽象,要想说明白这其中原理,还要从Java内存模型说起。

1. CPU、内存和高速缓存

我们都知道,在计算机中指令由CPU执行,这个过程就包括读取数据、操作数据和写出数据。程序在运行时,数据是存储在物理内存中的,也就是平时说的内存条。CPU在执行指令时,将数据从内存读入,操作之后,再将数据写出到内存,而CPU读写速度之快,远远超过内存的读写速度,这势必会大大拉低CPU的速度,所以高速缓存应运而生。

程序在运行时,先将需要的数据复制一份到高速缓存中,等到CPU执行指令时从高速缓存中读入数据,操作完之后写出到高速缓存中,程序执行完成后,再将高速缓存中的数据刷新到内存中,这样一来更好的利用了CPU的性能,提高了整体的运行速度。

这种设计,在单线程编程中是可以的,但是到了多线程并发编程时,就会有意想不到的结果。比如,两个线程同时操作内存中的一个值为0的变量,同时将其读入到各自的高速缓存中,CPU先将线程一的变量加1,结果为0+1=1,然后刷新到内存中;然后再将线程二的变量加1,由于在这个线程读入变量时并没有加1,所以读进来的值也是0,加1后为1,然后再写入到内存中,两个线程各自执行了一次加1,但最后结果却是1,问题就来了。

2. 并发编程中的三个概念

原子性:原子不可分割,顾名思义,一个或多个操作要么全部执行,要么就全部不执行。在Java中,对原始类型变量的读取和赋值操作是不可中断的原子操作,这里的赋值是指将一个确定的值赋给变量,如a=1,而不是将另一个变量的值赋给另一个变量,如a=b,这里面包含两步操作,需要先读取b的值,然后再将值赋给a。比如使用new关键字实例化对象时,需要先请求分配地址空间,然后再给地址空间了每一个变量赋默认值

可见性:如同上面那个例子,两个线程同时修改一个变量时,之所以会出现错误的结果,是因为当线程一修改完变量的值时,线程二并不知道,以至于在一个旧的值上继续操作。多个线程同时操作一个变量,一个线程修改了它的值后,其他线程能够读到最新的值,这就是可见性,被volatile修饰的变量,就具有这种可见性。

有序性:为了进一步提高执行效率,JVM虚拟机会对指令的顺讯重现排列,所以代码可能并不会按照定好先后顺序来执行代码。但这其中也有一些规定,如果语句2不依赖于语句1的结果,那么本位于后面的语句2可能会先于语句1执行,如果语句2依赖于语句1,则不会前置执行。举一个指令重排在并发编程中的问题:线程一种执行两个语句,语句1是初始化资源,语句2是将是否初始化标志位置为true,一般来说,当执行到语句2,也就是标志位为true时,就可以认为语句1已经执行过,资源已经初始化完成;线程2则是一直等待标志位,直到为true时,开始进行后续的操作。线程一中,由于只修改标志位不依赖初始化操作,所以可能会先于初始化执行,这时线程二判断已经初始化,就会开始后面的操作,而实际上并没有完成初始化,程序就不会按照设计的执行。

因此,要想多线程并发时能够正确执行,就必须保证这三个特性。

3. Java内存模型JMM(Java Memory Model)

Java虚拟机JVM规范中定义了一种内存模型,用以来屏蔽不同硬件平台和操作系统间的差异,从而实现其跨平台的特性。简而言之,Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接对主内存进行操作,同时,每个线程不可访问其他线程的工作内存。

主内存可以看作是上面提到的物理内存,而每个线程的工作内存可以看作是高速缓存,每个线程在执行程序时,需要先将数据从主内存复制一份到自己的工作内存中,等待CPU处理完之后,再将工作内存中的数据刷回到主存中,到此执行完成。

4. volatile在并发编程中

第一点,原子性。volatile无法保证原子性。

1
public volatile int inc = 0;

线程一和线程二同时对变量inc执行自增操作100次,能保证最后的结果是200吗?不能。可能觉得不对,刚刚不是才说过,被volatile修饰的变量在修改之后会立刻刷新回主存,同时让其他线程的工作内存中的缓存无效,那为什么执行200次结果却不是200呢?这便是volatile无法保证原子性。

程序开始执行时,线程一从主存中把inc的值复制到一份到其工作内存中,此时线程一被挂起。为什么有可能会被挂起呢,因为在多线程并发编程中,分到CPU时间片的线程才能执行操作,没有分到时间片时就要挂起等待,而CPU时间片的分配是由系统决定的。这时,线程二开始执行,根据JMM同样需要将主存中inc的值复制一份到自己的工作内存中,由于此时线程一还没有修改inc的值,所以读到的还是0,然后继续执行自增操作,最后再将inc的最新值1刷回到主存中。此时线程一释放,继续执行,因为已经将inc加载进来,便不会再去加载,而是执行增加操作,得到1,最后将1刷新回主存中。

因为自增操作包含读取、操作、再赋值多个操作,被或不被volatile修饰的区别在于,被修饰的变量在操作后会立即刷新到主存中,没有被修饰的变量在修饰后刷回到主存的时机不确定。每个线程只能操作自己的工作线程而不能操作其他线程的,故而主存是它们的交互通道,volatile只能保证主存中的值一定是最新的,却不能保证其他线程的值是最新,如果在中间的某个步骤被阻塞挂起,就无法保证最终结果的准确性。

要解决这个问题,有多重方式,比如synchronized、Lock等,另自增操作同一时刻只有一个线程在操作便可。

第二点,可见性。这一点在上面也已基本上说明,这里就不再重复。

第三点,有序性。volatile能够禁止指令重排,它会形成一个内存屏障,也称内存栅栏。在volatile变量读写操作前的指令,一定会先执行,在其后面的指令一定会后执行。屏障前的指令可能会重排,但是不会越过屏障,同样,屏障后面的指令也可能会重排,但是也不会越过屏障。

5. volatile的原理和实现机制

观察加入vlatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀即位内存屏障,有3个功能:

  • 它确保屏障前后的指令不会跨越屏障
  • 它强制对缓存的修改操作立即写入主存(缓存即为线程的工作内存)
  • 如果是写操作,会导致其他CPU中对应的缓存行无效(缓存行无效后便会从主存读取,保证可见性)
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2022/03/7fb798f2eef3/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道