JMM
在串行程序中我们不需要担心数据访问的一致性和安全性,对于串行程序而言,如果我们读取一个变量,这个变量的值是1,那么我们读到的也一定是1,就这么简单的问题在并行程序中居然会变的复杂起来。事实上,并行程序相比串行程序复杂了很多,如果我们不加以控制,任由线程胡乱并行,即使原本是1的数值,我们也有可能读到2.因此需要深入了解并行机制的前提下,再定义一种规则,保证多个线程间可以有效的、正确协同工作。而JMM也就是为此而生。
JMM的关键技术点都是围绕着多线程的原子性、一致性和有序性来建立的,首先需要清除这些概念。
原子性
原子性指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对应一个静态全局变量int i,两个线程同时对它赋值,线程A给他赋值1,线程B给他赋值-1.那么不管这2个线程以何种方式、何种步调工作,i的值要么是1,要么是-1.线程A和线程B之间是没有干扰的。这就是原子性的特点,不可被中断。
如果所示,线程A、B一旦开始不会受其他线程干扰,A的结果是i=1(一个单元),B的结果是i=-1(一个单元),每个单元都会按照自己的预期修改i,所以i最终只会有两种情况发生,1或者-1.
可见性
可见性指当一个线程修改了某一个共享变量的值,其他的线程是否能够立即知道这个修改。显然,对于串行程序来说,可加性问题是不存在的。因为我们在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量一定是修改后的值。
注意:上述概念解释中用的是 是否能够知道 ,所以我们在多线程开发中需要做的是如何保证可见性。
但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必能够马上知道这个改动。还是以上面的修改i为示例:
如上图,在CPU1,CPU2中分别有两个线程A、B对共享变量i做修改。
第一步:线程A执行i = 1操作 (此时i赋值为1)
第二步:CPU1上的线程A将变量i进行了优化,将其缓存在cache中或者寄存器里。(线程A读取i将从cache中获取,i = 1)
第三步:如果此时CPU2中的线程B也对i做了修改(此时i = -1),但是这个修改对线程A是不可见的(不可见指此时线程A并不知道线程B也修改了i值),那么对于线程A而言它仍然是从缓存中获取i,这时候i还是老的数据 i = 1。 这时候就出现了数据不一致问题。
除了上面提到的缓存优化或者硬件优化(有些内存读写可能不会立即触发,而胡先进入一个硬件队列等待)会导致可见性问题外,指令重排(后面再介绍)以及编译器的优化,都有可能导致一个线程的修改不会被其他线程察觉的情况(正如这里的线程B修改了i 值,但是线程A并没有察觉到i被修改,还认为i=1呢)。
有序性
有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码,我们总习惯性的认为代码的执行是从前往后,一次执行的。这么理解也不能说完全错误,因为一个线程内而言确实是这样的。但是在并发时,程序的执行可能会出现乱序。给人直观的感觉就是:写在前面的代码,可能会在后面执行。
然而,有序性问题正是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必是一致的。
看一个简单的例子:
public class OrderExample {
int a = 0;
boolean flag = false;
public void write(){
a = 1;
flag = true;
}
public void reader(){
if (flag){
int i = a+1;
其他逻辑 ……
}
}
}
上图中对这个重排序导致有序性问题解释的应该算直观了。所以多线程程序执行时可能发生这个问题,当然如果不出现重排序那肯定不会出现有序性问题。然而指令重排会不会发生?何时发生?如何重排?这个我们都是无法预测的。这也就是多线程安全编程需要去解决的。
不过需要注意的是对于一个线程而言,它所看到的的指令执行顺序一定是一致的(否则应用根本无法正常工作)。另外串行程序中不需要担心,需要指令重排可以保证串行语义一致,但是没有义务保证并行多线程间的语义也一致。
可能会好奇那指令重排既然有问题,为什么还要这样做呢?之所以那么做,完全是处于性能考虑。我们知道,一条指令的执行是可以分为很多不走的。简单的说,可以分为以下几步:
(1)取值IF
(2)译码和取寄存器操作数ID
(3)执行或者有效地计算EX
(4)寄存器访问MEM
(5)协会WB
每个步骤都涉及到不同的硬件资源,比如寄存器、存储器等。对此,工程师发明了流水线技术来执行指令,如下图,显示了流水线的工作原理:
如上图可以看出指令2开始执行时,指令1并未执行完(只完成了第一步),这样设计好处是加入这里的每一个步骤都需要花费1毫秒,那么指令2等待指令1完全执行后在执行则需要等待5毫秒,而使用流水线技术指令2只需要等待1毫秒就可以执行了。如此大的性能提升可能是需要去利用或者争取实现的,所以有了流水线技术。
但是需要注意的流水线总是害怕被中断的。流水线满载时,性能确实相当不错,但是一旦被中断所有的硬件设备都会进入一个停顿期,再次满载又需要几个周期,因此性能损耗会变大,所以工程师需要想办法如何保证流水线不被中断。
而指令重排就是为了尽量少的中断流水线,当然指令重排只是减少流水线中断的一种技术,CPU的设计中,还有很多方案来保证。
哪些指令不能重排:Happen-Before规则
哇,这个就比较重要了,也就是我们如何实现并发下禁止指令重排而进一步保证线程安全的一些办法。首先并不是所有的指令都可以被重排,以下罗列了一些基本规则,这些规则是指令重排不可以违背的:
(1)程序顺序原则:一个线程内保证语义的串行性
(2)volatile规则:volatile变量的 写先发生于读, 即如果读写同时执行不可以指令重排为先读后写的顺序,这样通过禁止指令重排来保证有序性,另外volatile可以保证多线程的可见性(上面的第二点中的示例:线程B修改了i = -1 后会立即通知线程A,具体的实现是将修改后的i值同步到主内存,同时让线程A的缓存失效,这样线程A必须从主内存读最新的i)
(3)锁规则:解锁(unlock)必然发生在随后的加锁(lock)前(注意强调的是unlock操作必然发生在后续的对同一个锁的lock之前)
(4)传递性:A先于B,B先于C,那么A必然先于C
(5)线程的start()方法先于它的每一个动作
(6)线程的所有操作先于线程的终结
(7)线程的中断先于被中断的代码
(8)对象的构造函数执行、结束先于finalize()方法
以顺序原则为例,重排后的指令绝对不能改变原有的串行语义,比如:
a = 1;
b = a+1;
由于第2条语句依赖第一条的执行结果。如果冒然交换两条语句的执行顺序,那么程序的语义就会修改。因此这种情况是不允许发生的。所以,这也是指令重排的一条基本原则。
总结
理解了原子性、一致性和有序性再去学习多线程编程,才会更加清晰一些。
个人网站:relaxheart网