转载

Java多线程之volatile

volatile中文意为挥发物,不稳定的。在Java中也是一个关键字,用于修饰变量。

  1. 在JMM(Java Memory Model,Java内存模型)中,有main memory,每个线程也有自己的memory (例如寄存器)。为了性能,一个线程会在自己的memory中保持要访问的变量的副本。

  2. 这样就会出现同一个变量在某个瞬间,在一个线程的memory中的值可能与另外一个线程memory中的值,或者main memory中的值不一致的情况。

  3. 一个变量声明为volatile,就意味着这个变量是随时会被其他线程修改,线程在每次使用变量的时候,都会读取变量修改后的最新值。

Java内存模型图:

Java多线程之volatile

volatile的使用时注意:

  1. volatile 无论是修饰 实例变量 还是 静态变量 ,都需要放在 数据类型 关键字之前,即放在 Stringint 等之前。
  2. volatilefinal 不能同时修饰一个变量。volatile 是保证变量被写时其结果其他线程可见,而final已经让该变量不能被再次写了。
Java多线程之volatile

2. volatile和原子性、可见性和有序性之间的关系

关于原子性、可见性和有序性的介绍,之前的一篇文章有了介绍,传送门

2.1 volatile可以保证原子性吗?

不能。

  • 我们知道,原子性是指一个操作不可再被分隔成多步。一个操作或者多个操作 要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
  • 对于volatile来说,如果一个操作本身不是原子性的,那么使用volatile作用于这个操作上的一个变量,其也是无法保证原子性的。

例如我们常碰到的i++的问题。

i = 1; //原子性操作,不用使用volatile也不会出现线程安全问题。
复制代码
volatile int i = 0;
i++; //非原子性操作
复制代码

如果我们开启200个线程并发执行 i++ 这行代码,每个线程中只执行一遍。如果volatile可以保证原子性的话,那么i的最终结果应该是200;而实际上我们发现这个值是会小于200的,原因是什么呢?

// i++ 其可以被拆解为
1、线程读取i
2、temp = i + 1
3、i = temp
复制代码
  1. 例如当 i=5 的时候A,B两个线程同时读入了 i 的值
  2. 然后A线程执行了 temp = i + 1 的操作, 要注意,此时的 i 的值还没有变化,然后B线程也执行了 temp = i + 1 的操作,注意,此时A,B两个线程保存的 i 的值都是5,temp 的值都是6
  3. 然后A线程执行了 i = temp (6)的操作,此时i的值会立即刷新到主存并通知其他线程保存的 i 值失效, 此时B线程需要重新读取 i 的值那么此时B线程保存的 i 就是6
  4. 同时B线程保存的 temp 还仍然是6, 然后B线程执行 i=temp (6),所以导致了计算结果比预期少了1。

参考: www.cnblogs.com/simpleDi/p/…

那么如何保证i++这种操作的线程安全呢?

  1. 使用 synchronized 关键字或者 Lock 。至于为什么,可以看下synchronized与原子性
synchronized(object){
    i++;
}
复制代码
  1. 使用支持原子性操作的类,如 java.util.concurrent.atomic.AtomicInteger ,它使用的是CAS(compare and swap,比较并替换)算法,效率优于第 1 种。

2.2 volatile与可见性

volatile关键字的变量写操作时,强制缓存和主存同步,其他线程读时候发现缓存失效,就去读主存,由此保证了变量的可见性。

2.3 volatile与有序性

volatile可以禁止指令重排序,所以说其是可以保证有序性的。

什么是指令重排序(Instruction Reorder)?

在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序的结果不会影响到 单线程 的执行,但 不能保证多线程并发执行 时不受影响。

例如以下代码在未发生指令重排序时,其执行顺序为1->2->3->4。但在真正执行时,将可能变为1->2->4->3或者2->1->3->4或者其他。但其会保证1处于3之前,2处于4之前。所有最终结果都是 a=10; b=20

int a = 0;//语句1
int b = 1;//语句2
a = 10; //语句3
b = 20; //语句4
复制代码

但如果是多线程情况下,另一个线程中有以下程序。当上述的执行顺序被重排序为1->2->4->3,当线程1执行到第3步 b=20 时,切换到线程2执行,其会输出 a此时已经是10了 ,而此时a的值其实还是为0。

if(b == 20){
  System.out.print("a此时已经是10了");
}

复制代码

3. volatile的实现原理

3.1 内存屏障与指令重排序

  • 要知道volatile是如何禁止指令重排序的,首先需要了解一个概念 内存屏障

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,其是一种CPU指令,所以像Java、c++、c语言都有此概念。

  • 它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在memory barrier 之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序。

A memory barrier, also known as a membar, memory fence or fence instruction, is a type of barrier instruction that causes a CPU or compiler to enforce an ordering constraint on memory operations issued before and after the barrier instruction. This typically means that operations issued prior to the barrier are guaranteed to be performed before operations issued after the barrier. ——— 维基百科

3.1.1 JVM中的4中内存屏障

  1. LoadLoad屏障:
//抽象场景:
Load1; 
LoadLoad; 
Load2
复制代码

Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  1. StoreStore屏障:
//抽象场景:
Store1; 
StoreStore; 
Store2
复制代码

Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

  1. LoadStore屏障:
//抽象场景:
Load1; 
LoadStore; 
Store2
复制代码

在Store2被写入前,保证Load1要读取的数据被读取完毕。

  1. StoreLoad屏障:
//抽象场景:
Store1; 
StoreLoad; 
Load2
复制代码

在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。

3.1.2 volatile与内存屏障的关系

在一个变量被volatile修饰后,JVM会为我们做两件事:

  1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
  2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

还是使用上面的例子:

这次使用volatile修饰变量 b

int a = 0;//语句1
volatile int b = 1;//语句2

//在线程1中执行的语句
a = 10; //语句3
b = 20; //语句4

//在线程2中执行的语句
if(b == 20){
   System.out.print("a此时已经是10了");
}
复制代码

在编译之后线程1中的语句将类似于

a = 10; //语句3
 ----------- StoreStore屏障 ---------------
 b = 20; //语句4
 ----------- StoreLoad屏障 ---------------
复制代码

由于屏障的存在, 语句3语句4 将无法被指令重排序,从而可以保证在b=20时,a已经被赋值为10了。那么这个程序也就不存在线程安全问题了。

3.1.3 内存屏障的性能影响

内存屏障阻碍了CPU采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。为了达到最佳性能,最好是把要解决的问题模块化,这样处理器可以按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。采用这个方法可以让处理器不受限的执行一个任务单元。

参考: ifeve.com/memory-barr…

3.2 volatile如何实现可见性?

要知道volatile是如何保证可见性的需要先了解下有关CPU缓存的概念。

3.2.1 CPU缓存

我们知道 CPU的运算速度 要比 内存的读写速度 快很多,这就造成了内存无法跟上CPU的情况,由此出现了CPU缓存。其是CPU与内存之间的临时数据交换器,我们常见的CPU会有3级缓存,常称为L1、L2、L3。

下图是Intel Core i7处理器的高速缓存概念模型(图片来自《深入理解计算机系统》)

Java多线程之volatile

当系统运行时,CPU执行计算的过程如下:

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

在上述的缓存模型下,当多核并发执行某项任务时就容易出现问题。eg.

  1. 核0先从内存中读取了变量a。
  2. 核3也从内存中读取了变量a。
  3. 核0修改了变量a,并同步到了主内存中。
  4. 核3开始使用变量a,但值仍然是旧的。

为了解决这类问题,出现了针对CPU的 MESI协议

3.2.2 MESI协议

在早期的CPU中,是通过在总线加LOCK#锁的方式实现的(又称 总线锁 )。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

但这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议。它的方法是 在CPU缓存中保存一个标记位 ,这个标记位有四种状态:

  • M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
  • E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
  • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
  • I: Invalid,无效缓存,这个说明CPU中的缓存已经不能使用了。

CPU的读取遵循下面几点:

  • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
  • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。

举个常见的例子就是:

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。

3.2.3 volatile的可见性原理

了解了上面的内容,就可以很容易的理解volatile是如何实现的了。

  1. 首先被volatile关键字修饰的共享变量在转换成汇编语言时,会加上一个以lock为前缀的指令
  2. 当CPU发现这个指令时,立即做两件事:
      1. 将当前内核高速缓存行的数据立刻回写到内存
      1. 通过MESI协议使在其他内核里缓存了的数据无效,这样其他线程也必须从内存中重新读取数据了。

参考:

crowhawk.github.io/2018/02/10/… blog.csdn.net/nch_ren/art…

4. volatile与synchronized的区别

volatile到此也介绍的不少了,最后来说下其与synchronized的区别。

了解更多synchronized的相关内容,请戳这里。

当你和面试官说到这里时,你最好清楚里面的具体细节,例如是从何种角度来看的有序性,以及如何实现的该特性,不然面试官很容易被问住的。

结语

至此关于volatile的内容到这里就结束了,如果文中有错误的地方、或者有其他关于 volatile 比较重要的内容又没有介绍到的,欢迎在评论区里留言,一起交流学习。

原文  https://juejin.im/post/5e01b9aa518825126f373b58
正文到此结束
Loading...