在引入volatile之前有必要先谈谈内存模型
一、计算机内存模型
计算机在执行程序的时候,每条指令都是在CPU中执行的,执行完了把数据存放在主存当中,也就是计算机的物理内存。
刚开始没问题,但是随着CPU技术的发展,执行速度越来越快。而由于内存的技术并没有太大的变化,导致从内存中读写数据比CPU慢,浪费CPU时间。
于是在CPU和内存之间增加高速缓存。这样就引入新的问题:缓存一致性。在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
计算机内存模型.png
这个模型存在三个问题:缓存一致性,处理器优化,指令重排。
二、Java 内存模型(JMM)
Java虚拟机也有自己的内存模型。Java 内存模型(JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型.png
Java内存模型有三大特征
- 原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 有序性即程序执行的顺序按照代码的先后顺序执行。
它还有一个很重要的原则是 happens-before 原则。
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作 - 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
有必要解释一下程序次序规则。意思就是书写顺序不等于执行顺序。在单线程中,哪怕优化了因为结果不变还是保证了“顺序”。但在多线程中就不一定了。比如
private int a = 5;
private boolean init = false;
public void setData(int num) {
a = num;
init = true;
}
public void readData() {
if(init) {
System.out.println(a);
} else {
System.out.println("uninit");
}
}
比如这个例子。如果一个线程执行setData,一个线程执行readData。可能出现的情况是setData方法内部指令优化以后init先执行,但是a还是旧值。导致readData输出uninit
(理论上会出现,但是本人测试了好多次并没有出现。。。。。。)
所以,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
PS:计算机内存模型和硬件有关。JMM是一种规范,用来处理共享内存的竞争问题的。两者从根本意义上来讲是不同的
三、Java内存模型的实现
了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
1、volatile
volatile 的特性
- (1)禁止进行指令重排序。(实现有序性)
- (2)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
(1)防止重排序最经典的就是 double check
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
- (1)分配内存空间。
- (2)初始化对象。
- (3)将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:
- (1)分配内存空间。
- (2)将内存空间的地址赋值给对应的引用。
- (3)初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
(2)实现可见性
先来看一段代码
public class MainTest {
int a = 1;
int b = 2;
public void change(){
a = 3;
b = a;
}
public void print(){
System.out.println("b="+b+";a="+a);
if (b == 3 && a == 1) {
System.out.println("捕获异常");
}
}
public static void main(String[] args) {
while (true){
final MainTest test = new MainTest();
new Thread(new Runnable() {
@Override
public void run() {
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.print();
}
}).start();
}
}
}
为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
疑惑:change之后ab都是3,可能出现没有同步完的情况,那么如果a没同步完,b=3;a=1那可以理解。那如果b没有同步完,答案不就是b=2;a=3么?为啥这个没有?
对volatile变量的写操作与普通变量的主要区别有两点:
- (1)修改volatile变量时会强制将修改后的值刷新的主内存中。
- (2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。
通过这两个操作,就可以解决volatile变量的可见性问题。
2、使用volatile关键字的场景
通常来说,使用volatile必须具备以下2个条件:
- 1)对变量的写操作不依赖于当前值
- 2)该变量没有包含在具有其他变量的不变式中
事实上,使用volatile关键字需要保证操作是原子性操作,这样才能保证并发时能够正确执行。
synchronized关键字(下一篇会讲到)是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。(下一篇讲synchronized)
参考:
Java并发编程:volatile关键字解析
Java 并发编程:volatile的使用及其原理
【死磕Java并发】-----Java内存模型之happens-before
再有人问你Java内存模型是什么,就把这篇文章发给他。
网友评论