前两篇介绍了有关并行程序的一些与语言无关的关键概念。本篇我们来回到Java,说一下JMM——Java内存模型(Java memory model)。 由于并发程序下数据访问的一致性和安全性将受到严重挑战,所以需要在了解并行机制的前提下再定义一种规则,保证多个线程可以有效地、正确地协同工作。而JMM就是为此而生的。 JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此首先必须了解这些概念。
1.原子性
顾名思义,一个原子性的操作是不可分割的。即使多个线程一起执行,一个原子操作一旦开始,就不会被其他线程干扰。
比如对于一个变量int i,有两个线程同时对它进行赋值操作,A线程将它赋值为1,B线程将它赋值为-1,那么无论这两个线程以什么方式工作,i最终的值要么是1要么是-1。线程A和线程B之间是没有干扰的,这就是原子性的不可中断。
但是如果i的类型为long的话,对于32位操作系统来说,i的读写操作就不是原子性的了,也就是说如果两个线程同时对long类型的数据进行操作,线程之间会有干扰。因为32位系统对于64位的数据实际上是分两次操作的,第一次操作前32位,第二次操作后32位。在两次操作中间可能会有其它线程对数据进行写入,这时就可能会将数据写乱,出现意想不到的后果。
2.可见性
可见性是指当一个线程修改了某一个共享变量的值,其它线程是否能够立即知道这个修改。
对于串行程序来说是不存在可见性问题的,因为后续步骤是一定可以读到由之前步骤修改的新值的。但在并行程序中就不见得了。如果一个线程修改了某个全局变量,其他线程未必可以马上知道这个改动。
除此之外,指令重排序(稍后介绍)也有可能引发此问题。
Thread1 Thread2
1: r2 = A; 3: r1 = B;
2: B = 1; 4: A = 2;
上述两个线程并行执行,指令1、2属于线程1,指令3、4属于线程2。从指令的执行顺序来看,r2==2且r1==1的情况似乎是不可能出现的。但实际上并没有办法从理论上保证这种情况不出现。因为编译器可能将指令重排成如下情况:
Thread1 Thread2
B = 1; r1 = B;
r2 = A; A = 2;
在这种情况下就有可能出现r2==2且r1==1。
3.有序性
这里先介绍一下指令重排序。简单来说指令重排序是指在不影响数据依赖顺序的情况下,编译器为了优化程序性能而采取的对指令顺序做重新排序但不影响单线程内执行结果的操作。
根据指令重排序的定义,我们可以认为一段代码在单个线程内是按照先后顺序执行的(保证串行语义一致),但是在并发环境下就未必了。多线程环境下由于存在指令重排序,有可能会引发有序性问题:
int a = 0;
boolean flag = false;
public void write(){
a = 1;
flag = true;
}
public void read(){
if (flag) {
int i = a + 1;
}
}
假设线程A执行write()方法,线程B执行read()方法,如果发生指令重排,那么线程B在执行到第10行时,i的值不一定是我们所预料的a + 1 = 2,因为在执行时指令顺序可能是如下图所示,代码中write()方法里的两条指令的执行顺序被颠倒了:
4.Happen-Before规则
虽然JVM允许指令进行一定的重排,但指令重排是有原则的,并非所有的指令都可以随便改变执行位置。以下列举了一些指令重排不可违背的基本原则:
- 程序顺序规则:一个线程内保证语义的串行性
- volatile规则:volatile变量的写先发生于任意后续对此变量的读操作
- 锁规则:unlock操作必然先发生于后续的lock操作
- 传递性:A先于B,B先于C,那么A必然先于C
- 线程的start()方法先于这个线程处于STARTED状态后的每一个动作
- 线程中的所有操作都会在任何其他线程对该线程的join操作返回之前发生
- 线程interrupt()方法的调用先于被中断线程的检测到中断发送的代码
- 对象的构造函数执行、结束先于finalize()方法
以上这些原则都是为了保证指令重排不会破坏原有的语义结构。
关于对Happen-Before原则的讲解请参照:
- www.logicbig.com/tutorials/c…
- 《Java高并发程序设计实战》.电子工业出版社.葛一鸣、郭超编著