概述
JMM(Java Memery Model),Java内存模型。再切换到并发,并发程序复杂的原因是因为并发程序访问的数据和执行顺序等有复杂的情况。而并发程序中的数据访问与JMM关系密切的,所以这里把并发和JMM联系起来说明。
因为程序中的变量自然是与JMM关联的。比如有一个共享变量的值为1,在内存中值为1,线程A读的值为1,如果线程安全没有控制好,线程B修改了该共享变量的值,后面的线程再读的值变了。怎么保证并发情况下,共享数据的可见性和一致性,都需要结合JMM具体分析。
原子性(Atomicity)
原子性是指一个操作是不可中断的,即使在多个线程一起执行的情况下,一个操作一旦开始,就不会被其它线程干扰,最后仍是完整执行。
举例来说,线程A和线程B同时对共享数据(比如全局变量)修改,假设线程A先进行修改,那线程B是干扰不了线程A对共享数据的修改操作的,会在线程A操作完成以后,线程B再进行修改。
但有一些特殊情况,比如对于32位操作来说,long型数据的读写不是原子性的(long类型数据是64位)。明白点说,就是多个线程同时对long类型的数据进行写或读,对线程之间的结果会有干扰。
可见性(Visibility)
可见性是指当一个线程修改了一个共享变量后,其它线程是否能立即知道共享变量被修改了。对串行程序来说,不存在可见性的问题。因为当修改了某个变量,在后续的步骤中读取这个变量的值时,读取的是修改后的值。
但在并发情况下,就存在可见性的问题。倘若一个线程修改了某一个共享变更的值,那其它线程不一定马上知道共享变量被修改了。对于共享变量i=1,如果线程A使用它并将其值改为2,如果线程B能够其它这个修改,即变更i对线程B具有可见性,那线程会在i=2的基础上进行操作。如果变量i不具有可见性,那么多个线程操作,i的值就会乱,线程B拿的i=1,其实此时i=2。所以在并发时要注意此问题。指令重排和编辑器的优化,可能导致线程的修改不会立即被其它线程知道。
有序性(Ordering)
对于一个线程的执行代码,总是认为是从前往后顺序执行的。如果串行确实是这样,但并发的情况下不一定的。写在前面的代码会在后面执行。因为是程序在执行是,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
class OrderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(false){
int i = a + 1;
}
}
}
假设有两个线程A和B执行此代码,线程A执行reader()方法,如果发生指令重排,那线程B在执行int i = a + i这行代码时,不一定能看到a的值已经被赋值为1了。这种情况是可能存在的。之所以用指令重排是为了提高CPU的性能,但也可能导致乱序问题。
指令重排
指令重排是有原则的,并非所有的指令都可以随意改变执行的顺序,以下的原则是指令重排要遵守的。
- 程序顺序原则:一个线程内保证语义的串行性;
- volatile规则:volatile修饰的变量的写先于读发生,这保证了volatile所修饰变量的可见性;
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前;
- 传递性:A先于B,B等于C,那A必先于C;
- 线程的start()方法先于它的每一个动作;
- 线程的所有操作先于线程的终结(Thread.join());
- 线程的中断(interrupt())先于被中断线程的代码;
- 对象的构造函数的执行、结束先于finalize()方法。
以程序的顺序原则为例,重排后的指令绝不能改变原有的串行语义,比如:
a = 1;
b = a + 1;
由于第二行代码依赖于第一行的执行结果,如果交换两行代码的执行顺序,那程序的语义就会修改。因此这种情况下是绝对不会发生的,这也是指令重排的基本原则。
另外,锁规则强调unlock操作必然发生在后面的对同一个锁的lock之前。也就是说,如果对一个锁解锁,再加锁,那加锁会在解锁之前发生。这么做,加锁就没办法获得锁了。
注:以上内容参考《实战Java高并发程序设计(第2版)》。