1,前言
volatile常被提及的便是其内存可见性以及禁止指令重排序
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。
2,volatile
2.1,内存模型
既然是多线程,那么就离不开一个点:消息传递
线程之间的通信机制有两种:
Java内存模型(JMM)使用的正是共享内存的方式。
共享内存
在共享内存的模型中,多个线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信
正因为这是一种隐式的通信,才会给程序员进行多线程编程带来麻烦……
消息传递
在消息传递的内存模型中,线程之间并没有公共状态,线程之间必须通过发送消息来显式进行通信
2.2,Java内存模型的抽象结构
在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方法定义参数(Java 语言规范称之为 Formal Method Parameters)和异常处理器参数(ExceptionHandler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。 ----《Java并发编程艺术》第3章
Java内存模型的抽象示意图如下所示: 共享变量全都存储于主内存中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了一些共享变量的副本
由于JMM的消息传递是共享内存的方式,那么如果线程A要传递消息给线程B,步骤应为:
- 线程A将本地内存中更新过的共享变量刷新到主内存去
- 线程B到主内存中去读取线程A之前已更新过的共享变量
这两步本质上可以理解为线程A发送消息给线程B,告诉线程B自己做了修改,线程B便去主内存中读取最新的数据。并且这个过程需要通过主内存,而JMM就是通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。
2.3,内存可见性
我们把一个变量用volatile进行修饰,在多线程场景中读写这个volatile变量会发生什么?
- 线程A写一个volatile变量,当将修改过后的volatile变量刷新到主内存后,这个写操作就算完成了
- 而这个刷新到主内存的操作会被其它线程所感知到,并判断自己的本地内存中是否有这个共享变量
- 若有,便将本地内存置为无效。当线程下次执行时,就会执行缓存行填充,也就是将主内存的数据读取到本地内存中。
tips:这里的过程描述不够严谨,仅仅是为了方便理解,具体的可看《Java并发编程艺术》第3章
上面的过程其实可以这样理解:
- 线程A修改了一个volatile变量,其实是向后面所有用到这个volatile变量的线程发送消息:我已经修改过了这个volatile变量。
- 后续的线程读这个volatile变量时,就会收到这个消息,将本地缓存置为无效并重新去主内存中读取最新的数据。
这也就是volatile写和volatile读的内存语义:
- 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
- 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。【再想想JMM使用的消息通信机制,就会发现很有趣】
那么会有一个小问题,其他线程是怎么知道某个线程对volatile变量做出了修改?
当编译器编译一个volatile写操作时,其汇编指令会带有一个Lock前缀指令,这个指令在总线传播时,会被其他处理器通过“嗅探技术”感知到,并根据缓存一致性协议将本地的缓存行置为无效……
而这个Lock指令会涉及到“总线锁定”以及“缓存锁定”,这里不进行深入。
2.4,禁止指令重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
重排序这项技术是为了更好的提高程序运行效率,但有时候会因为重排序出现执行结果与理论运行结果不一致的现象。
从Java源代码到最终实际执行的指令序列,会经历下面3种重排序:
(1)属于编译器重排序
(2)(3)属于处理器重排序
对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。----《Java并发编程艺术》第3章
通俗点讲:我们只需要关心处理器重排序这一类就好了。
例如有这么一个程序test:
class test{
int a=0;
boolean flag=false;
public void writer(){
a=1;
flag=true;
}
public void reader(){
if(flag){
int i =a * a;
}
}
}
假设有线程A与线程B,A线程执行reader(),B线程执行writer()
正确的场景应该是:线程A执行完操作1、2后,线程B再执行操作3、4
但如果操作1、2发生了重排序之后呢?顺序可能会是这样的:
这时候的i会为0【这里之所以是0是因为默认初始化,会给a赋值为0】
Tips:这里操作3、4也可以发生重排序,也会出现问题,但因为操作3、4的重排序涉及到“猜测执行”,就不在这里展开……
解决方案
上面的示例出现问题就是因为操作1、2发生了重排序,只要禁止这种重排序便不会出现问题。而Java编译器会在生成指令序列的适当位置插入相应的指令来防止特定类型的处理器重排序。也就是我们熟知的内存屏障
【Load=》装载指令=》volatile读】
【Store=》存储指令=》volatile写】
屏障类型 | 指令示例 | 说明 |
---|
LoadLoad Barriers | Load1 ;LoadLoad;Load2 | 确保Load1操作会先于Load2操作及所有后续装载指令 | StoreStore Barriers | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到主内存)先于Store2及所有后续存储指令的存储 | Load Store Barriers | Load1 ;LoadStore;Store2 | 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存 | StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器可见(刷新到主内存)先于Load2及所有后续装载指令的装在。StoreLoad Barriers会使该屏障之前的所有内存访问指令(也就是存储与装载指令完成之后,才执行该屏障之后的内存访问指令) |
从上面的表格不难看出,StoreLoad Barriers是全能型的屏障,但执行该屏障的开销会很昂贵
这里值得一提的是:上面表格描述的是禁止volatile变量之间的重排序,但实际上JSR-133增强了volatile的内存语义,现在内存屏障也会禁止volatile变量与普通变量之间的重排序了!至于为什么这样做,你将上面的程序test中的flag变量用volatile修饰,而变量a不变;再让操作1、2重排序,看看结果会是咋样的……
未完待续……
|