volatile中文意为挥发物,不稳定的。在Java中也是一个关键字,用于修饰变量。
在JMM(Java Memory Model,Java内存模型)中,有main memory,每个线程也有自己的memory (例如寄存器)。为了性能,一个线程会在自己的memory中保持要访问的变量的副本。
这样就会出现同一个变量在某个瞬间,在一个线程的memory中的值可能与另外一个线程memory中的值,或者main memory中的值不一致的情况。
Java内存模型图:
volatile
无论是修饰 实例变量 还是 静态变量 ,都需要放在 数据类型
关键字之前,即放在 String
、 int
等之前。 volatile
和 final
不能同时修饰一个变量。volatile 是保证变量被写时其结果其他线程可见,而final已经让该变量不能被再次写了。 关于原子性、可见性和有序性的介绍,之前的一篇文章有了介绍,传送门
例如我们常碰到的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 复制代码
temp = i + 1
的操作, 要注意,此时的 i 的值还没有变化,然后B线程也执行了 temp = i + 1
的操作,注意,此时A,B两个线程保存的 i 的值都是5,temp 的值都是6 i = temp
(6)的操作,此时i的值会立即刷新到主存并通知其他线程保存的 i 值失效, 此时B线程需要重新读取 i 的值那么此时B线程保存的 i 就是6 i=temp
(6),所以导致了计算结果比预期少了1。 参考: www.cnblogs.com/simpleDi/p/…
那么如何保证i++这种操作的线程安全呢?
synchronized
关键字或者 Lock
。至于为什么,可以看下synchronized与原子性 synchronized(object){ i++; } 复制代码
java.util.concurrent.atomic.AtomicInteger
,它使用的是CAS(compare and swap,比较并替换)算法,效率优于第 1 种。 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了"); } 复制代码
内存屏障
。 内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,其是一种CPU指令,所以像Java、c++、c语言都有此概念。
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. ——— 维基百科
//抽象场景: Load1; LoadLoad; Load2 复制代码
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
//抽象场景: Store1; StoreStore; Store2 复制代码
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
//抽象场景: Load1; LoadStore; Store2 复制代码
在Store2被写入前,保证Load1要读取的数据被读取完毕。
//抽象场景: Store1; StoreLoad; Load2 复制代码
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。
在一个变量被volatile修饰后,JVM会为我们做两件事:
还是使用上面的例子:
这次使用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了。那么这个程序也就不存在线程安全问题了。
内存屏障阻碍了CPU采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。为了达到最佳性能,最好是把要解决的问题模块化,这样处理器可以按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。采用这个方法可以让处理器不受限的执行一个任务单元。
参考: ifeve.com/memory-barr…
要知道volatile是如何保证可见性的需要先了解下有关CPU缓存的概念。
我们知道 CPU的运算速度 要比 内存的读写速度 快很多,这就造成了内存无法跟上CPU的情况,由此出现了CPU缓存。其是CPU与内存之间的临时数据交换器,我们常见的CPU会有3级缓存,常称为L1、L2、L3。
下图是Intel Core i7处理器的高速缓存概念模型(图片来自《深入理解计算机系统》)
当系统运行时,CPU执行计算的过程如下:
在上述的缓存模型下,当多核并发执行某项任务时就容易出现问题。eg.
为了解决这类问题,出现了针对CPU的 MESI协议 。
在早期的CPU中,是通过在总线加LOCK#锁的方式实现的(又称 总线锁 )。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。
但这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议。它的方法是 在CPU缓存中保存一个标记位 ,这个标记位有四种状态:
CPU的读取遵循下面几点:
举个常见的例子就是:
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。
了解了上面的内容,就可以很容易的理解volatile是如何实现的了。
参考:
crowhawk.github.io/2018/02/10/… blog.csdn.net/nch_ren/art…
volatile到此也介绍的不少了,最后来说下其与synchronized的区别。
了解更多synchronized的相关内容,请戳这里。
当你和面试官说到这里时,你最好清楚里面的具体细节,例如是从何种角度来看的有序性,以及如何实现的该特性,不然面试官很容易被问住的。
至此关于volatile的内容到这里就结束了,如果文中有错误的地方、或者有其他关于 volatile
比较重要的内容又没有介绍到的,欢迎在评论区里留言,一起交流学习。