本文参考:
什么是volatile关键字?
《深入理解Java虚拟机》周志明 12章
Java 并发基础之内存模型
墙裂推荐先看引用的第一篇文章,会带你快速了解Volatile,本文是在其基础上的整合补充。
0. 正文
JMM,全称Java Memory Model(Java内存模型),JVM通过这个模型来屏蔽硬件和操作系统的访问差异。
1. JMM基本结构
在JMM中,所有的变量都储存在主内存中,而相应的,每条线程都会有自己的工作内存,在工作时候,线程会将自己操作的变量拷贝到自己的工作内存
详细解释一下。
下面的引用来自:程序猿小灰
主内存(Main Memory)
主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。
工作内存(Working Memory)
工作内存可以简单理解为计算机当中的CPU高速缓存,但又不完全等同。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。
线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
同时数据相关操作,又像一个协议,来规定主内存和工作内存的工作原则。
引用一段话,强调一下本地内存(工作内存)其实是一个抽象的概念。
所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换的,所以可见性问题依然是存在的。这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。
对于JMM的工作模式,我们也需要通过一个简单的插画来了解一下。
简单解释一下。
1.主内存有一个VAR变量 = 1 ,某线程需要访问修改,就Copy一份到自己的内存中。
- 在自己的工作内存中将VAR修改为3,
- 将修改后的值通过写会主内存,覆盖掉原来的值。
(注意为了画图简洁,图1和图3最长的连接线不是真实路径,真正的读写操作是需要走JMM操作的。)
那么这么做,会出现什么问题呢?想想之前学习的内容,很容易会想到原子性这个问题。
也就是当我们加入一个线程的时候,就出现了下图这种情况。
依次来看四张图。
- 首先线程1 Copy主内存的VAR = 1
- 线程1修改VAR为3
- 此时线程2读取了值 VAR = 1。
- 线程1 写会VAR = 3到主内存。
这样看起来也没什么不妥吧,但是如果线程1对值的修改必须在线程2前执行呢?
你就想这不就是一个同步的问题么?加一个Sychronized不就OK了?
首先不说Sychronized不能作用于变量(当然这是底层的实现了),而且Sychronized的投入也很大(特别是在jdk1.7之前),这个时候就需要轻量级的Volatile登场了。
2. Volatile
Volatile可以保证两个特性,一是可见性,二是防止指令重排,关于这两个特性,其实你应该了然于胸了,但这里还是详细说一下。
可见性
可见性是指当一个线程修改了某一个变量的值,新值对于其他线程是立即得知的,想象一下上面的第一幅图,三步操作,当线程A修改完VAR值之后,线程B需要知道被修改后的值是多少。
那么如何才能实现这个性质?
我们就要先了解Java的一个原则 先行发生(happens-before)。
先行发生原则是两个事件的结果之间的关系,如果一个事件发生在另一个事件之前,结果必须反映,即使这些事件实际上是乱序执行的(通常是优化程序流程)。
说起来有点难以理解,我们举个栗子。
如果说A happens-before B,就是意味着B在操作的时候,已经能观察到A所产生的影响,比如说修改了变量,调用了方法,发送了信息等等。
如下面这种情况。
//在A线程运行
i = 1;
//在B线程运行
j = i;
//在C线程运行
i = 2;
如果A happens-before B ,那么B一定是在A对i赋完值之后才执行的,才不会产生0这样的值。
继续保持A happens-before B,再考虑C,如果B和C没有先行关系,那么B的值就有了不确定性,可能是1,也可能是2 【注意时间先后顺序与先行发生的原则没有关系,比如说ACB时间顺序,但是C和B不一定是先行关系】
看起来很神奇的样子,也能保证数据修改的安全性,但是Java中无需任何同步手段就可以满足先行原则的也就只有8种,具体如下。
- 程序次序规则
- 管程锁定规则
- volatile变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
其他的就不深究了,具体看一下volatile变量规则。
Volatile的变量规则是,对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
这个意思就是,如果我们给变量加上volatile修饰符,系统会为我们匹配volatile变量规则,而如果没有此修饰符,就需要手动完成同步。
此外 happens-before 原则 跟 真正的时间顺序之间没有太大的关系,也就是说时间是先发生不代表这个操作会先行发生(上面ABC的例子),而反之也不成立,一个典型的例子就是指令重排
那么指令重排是什么呢?
有序性(防止指令重排)
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。
下面是小灰的一段代码,我再来详细解释一下。
boolean contextReady = false;
//在线程A中执行:
context = loadContext();
contextReady = true;
//在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
线程B循环等待上下文context的加载,一旦context加载完成,contextReady == true的时候,才执行doAfterContextReady 方法。
我们刚才说在单线程条件下,是不会改变运行结果的,所以具体如何实现的,我们就不深究了。
而在多线程情况下,如果发生指令重排,先实现了contextReady = true;
后完成了loadContext(),意味着B会在没有加载完上下文的情况下执行下面的操作。
但是这里的重排可不是代码顺序的重排,而是指令的重排,或者执行顺序的重排。
说起来,指令重排怎么做到的?很神奇的样子,具体这些不是我们探究的内容,我们只需要知道在多线程的情况下,指令重排可以导致一些问题。
那如何解决?答案还是volatile。
volatile会为我们的操作加内存屏障,关于内存屏障我们还要了解一下。
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
通俗点讲,在两个操作之间有一堵墙,墙前的指令只能在墙后的指令执行前执行,也就是不能随意的交换顺序。
内存屏障分为4种,
LoadLoad屏障:
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
抽象场景:Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障:
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
抽象场景:Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。
上面内容引自小灰的文章。
当一个变量被volatile修饰后,JVM会为我们做两件事:
1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
我们可以看到在上面的例子中,我们加上内存屏障之后,如下效果。
其中context = ??? 这一句是赋值,也就是store操作,contextReady也是赋值,所以就加storestore屏障。
在contextReady赋值之后,下面就是可以给别的线程或者其他命令提供写好的值,于是就是storeload屏障。
上面说过的4种类型的屏障,他们的名字就规定了屏障前后的操作,而他们的功能也就是禁止呼唤屏障前后的指令互换,所以很好理解。
相应的,对于线程B,就是如下这种情形。
------------LoadLoad屏障------------
while( ! contextReady ){
------------LoadStore屏障------------
sleep(200);
}
可以看到 内存屏障 不仅可以实现防止指令重排,也好像可以满足 happens-before原则,可认为前者是后者的一种实现。
3. 填坑
梳理一下。
JMM与volatile
前面我们谈论了volatile的可见性,也说过了volatile的可见性是由happens-before原则来限定的,也说过内存屏障可以说是happens-before的一种实现。
但是JMM模型,在读一个 volatile 变量的时候,具体是怎么操作的呢?
很容易想到,对于使用volatile变量进行工作时,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性也会立即刷入到主内存,再搭配happens-before原则,就可以保证可见性了。
对long和double的特殊规则
在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据读写操作划分为2次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的read、load、store、write这4个操作的原子性。这点就是所谓的long和double的非原子协定。如果有多个线程共享一个并未被volatile修饰的64位数据类型变量(long或double类型),并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。
但是尽管如此,我们的long double还是不需要声明为volatile,因为他们都已经被实现了原子性。
volatile总结
1.volatile 保证了属性的可见性,有序性
2.volatile 只能修饰变量且只能同时修饰一个,所以在多个变量情况下产生的并发问题还是需要同步锁来解决。(这就凸显了sychronized与volatile的区别了)
4. 总结
上文是对JMM的简单认识和volatile的简单认识,大部分内容是参考小灰的文章和周志明老师的《深入理解Java虚拟机》一书,但其中可能有一些理解的偏差,所以还望指正。