在《Java内存模型(JMM)详解》一文中我们已经讲到了Java内存模型的基本结构以及相关操作和规则。而Java内存模型又是围绕着在并发过程中如何处理原子性、可见性以及有序性这三个特征来构建的。本篇文章就带大家了解一下相关概念、原则等内容。
原子性
原子性即一个操作或一系列是不可中断的。即使是在多个线程的情况下,操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态变量int x两条线程同时对其赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的。
Java内存模型对以下操作保证其原子性:read,load,assign,use,store,write。我们可以大致认为基本数据类型的访问读写是具备原子性的(前面也提到了long和double类型的“半个变量”情况,不过几乎不会发生)。
从Java内存模型底层来看有上面的原子性操作,但针对用户来说,也就是我们编写Java的程序,如果需要更大范围的原子性保障,就需要同步关键字——synchronized来保障了。也就是说synchronized中的操作也具有原子性。
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
Java内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,将主内存作为传递媒介。可回顾一下上篇文章的图。
无论普通变量还是volatile变量都是如此,只不过volatile变量保证新值能够立马同步到主内存,使用时也立即从主内存刷新,保证了多线程操作时变量的可见性。而普通变量不能够保证。
除了volatile,synchronized和final也能够实现可见性。
synchronized实现的可见性是通过“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”来保证的。
主要有两个原则:线程解锁前,必须把共享变量的最新值刷新到主内存中;线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
final的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其他线程中就能看见final的值。
有序性
在Java内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。
有序性是指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致。
因此,上面归纳的前半句指的是线程内保证串行语义执行,后半句则指指“令重排现”象和“工作内存与主内存同步延迟”现象。
同样,Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。
指令重排
计算机执行指令经过编译之后形成指令序列。一般情况,指令序列是会输出确定的结果,且每一次的执行都有确定的结果。
CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果。
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,重新安排语句执行顺序。
- 指令级并行重排序:处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其的执行顺序。
- 内存系统的重排序:处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是乱序执行。
举个例来说明一下多线程中可能出现的重排现象:
class ReOrderDemo {
int a = 0;
boolean flag = false;
public void write() {
a = 1; //1
flag = true; //2
}
public void read() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
在上面的代码中,单线程执行时,read方法能够获得flag的值进行判断,获得预期结果。但在多线程的情况下就可能出现不同的结果。
比如,当线程A进行write操作时,由于指令重排,write方法中的代码执行顺序可能会变成下面这样:
flag = true; //2
a = 1; //1
也就是说可能会先对flag赋值,然后再对a赋值。这在单线程中并不影响最终输出的结果。
但如果与此同时,B线程在调用read方法,那么就有可能出现flag为true但a还是0,这时进入第4步操作的结果就为0,而不是预期的1了。
请记住,指令重排只会保证单线程中串行语义执行的一致性,不会关心多线程间语义的一致性。这也是为什么在写单例模式时需要考虑添加volatile关键词来修饰,就是为了防止指令重排导致的问题。
JMM提供的解决方案
在了解了原子性、可见性以及有序性问题后,看看JMM是提供了什么机制来保证这些特性的。
原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。
而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决。它们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决。volatile的另一个作用就是禁止重排序优化。
除了靠sychronized和volatile关键字之外,JMM内部还定义一套happens-before(先行发生)原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。
先行发生原则
如果仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序会十分麻烦。为此在Java内存模型中,还提供了happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题。该原则是判断数据是否存在竞争、线程是否安全的依据。
happens-before规则:
- 程序次序规则:在一个线程内,程序前面的操作先于后面的操作。
- 监视器锁规则:一个unlock操作先于后面对同一个锁的lock操作发生。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,也就是说读取的值肯定是最新的。
- 线程启动规则:Thread对象的start()方法调用先行发生于此线程的每一个动作。
- 线程加入规则:Thread对象的结束先行发生于join()方法返回。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
还拿上面的具体代码来进行说明:
class ReOrderDemo {
int a = 0;
boolean flag = false;
public void write() {
a = 1; //1
flag = true; //2
}
public void read() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
线程A调用write()方法,线程B调用read()方法,线程A先(时间上的先后)于线程B启动,那么线程B读取到a的值是多少呢?
现在依据8条原则来进行对照。
两个方法分别由线程A和线程B调用,不在同一个线程中,因此程序次序原则不适用。
没有write()方法和read()方法都没有使用同步手段,监视器锁规则不适用。
没有使用volatile关键字,volatile变量原则不适用。
与线程启动、终止、中断、对象终结规则、传递性都没有关系,不适用。
因此,线程A和线程B的启动时间虽然有先后,但线程B执行结果却是不确定,也是说上述代码没有适合8条原则中的任意一条,所以线程B读取的值自然也是不确定的,换句话说就是线程不安全的。
修复这个问题的方式很简单,要么给write()方法和read()方法添加同步手段,如synchronized。或者给变量flag添加volatile关键字,确保线程A修改的值对线程B总是可见。
小结
在这篇文章中介绍了Java内存模型中一些原则,及其衍生出来保证这些原则的方式和方法。也是为我们下面学习volatile这个面试官最爱问的关键字的做好铺垫。欢迎关注微信公众号“程序新视界”继续学习。
原文链接:《Java内存模型相关原则详解》
《面试官》系列文章:
- 《JVM之内存结构详解》
- 《面试官,不要再问我“Java GC垃圾回收机制”了》
- 《面试官,Java8 JVM内存结构变了,永久代到元空间》
- 《面试官,不要再问我“Java 垃圾收集器”了》
- 《Java虚拟机类加载器及双亲委派机制》
- 《Java内存模型(JMM)详解》
- 《Java内存模型相关原则详解》