由上一节的内容我们知道了Java提供了synchronized
、volatile
、原子类等工具来帮助我们我们构建线程安全的程序。那么这一节我们就来探究这些工具的设计理念和实现方法。
之前我用现代计算机的多处理器架构来描述线程安全的出现原因。但实际情况还要比这更复杂些:不同的处理器架构实现多有不同,但Java程序是需要跨平台运行的,所以Java虚拟机就需要提供一个规范来作为应用程序和底层平台之间的桥梁,以此保证 当程序代码按照这个规范正确开发之后,无论在什么平台上执行都能得到正确的结果。这个规范就是Java内存模型(Java Memory Model,简称JMM)。
要想研究JMM是如何让程序正确的运行的,那么我们首先要明确这个“正确”到底该如何定义。
根据冯·诺伊曼模型的经典的串行计算模型,当程序只存在唯一的操作执行顺序,即按代码顺序依次执行,且在执行序列中每个指令的执行结果都对之后的指令可见。这种情况下程序当然是正确运行的,这又被称为 “串行一致性”。但是,这个模型存在着两个问题:
于是JMM对于程序的执行结果的可预测性和易开发性等进行了权衡,规定了JVM必须遵守的一组保证,这些保证规定了对变量进行写入操作的时候在什么时候保证对其他线程可见,以及在什么时候保证操作的原子性。
JMM规定对long
/double
型以外的基本数据类型以及引用类型的共享变量进行读、写操作都具有原子性。另外,JMM还特别规定对volatile
修饰的long
/double
型共享变量进行读、写操作也具有原子性。换而言之,对引用类型以及几乎所有基本数据类型的共享变量进行的读、写操作,Java内存模型都保证它们具有原子性。
之所以long
/double
类型比较特别,是因为JVM允许将64位的读操作或者写操作分解为两个32位的操作。这就导致对long
/double
进行读写时,可能前32位已经更新了,而后32位还未更新,导致得到一个错误值。
在Java语言规范(The Java Language Specification)的17.4.5节这一节中,规范定义了程序执行时所有操作之间存在的三种顺序:Program Order(代码顺序)、Synchronization Order(同步顺序) 和 Happens-before Order(更先发生顺序)。
start
方法 synchronizes-with 被启动的线程中的第一个操作。isAlive()
或者join()
)监听到这个线程已经结束的线程。interrupt()
方法 synchronizes-with 被中断线程检测到 interrupt()
调用可以看到,这三种规则是层层放宽的,而规范对所有平台要求的就是,执行所有代码必须遵守Happens-before Order:
原文如下:
When a program contains two conflicting accesses that are not ordered by a happens-before relationship, it is said to contain a data race. ……A program is correctly synchronized if and only if all sequentially consistent executions are free of data races.If a program is correctly synchronized, then all executions of the program will appear to be sequentially consistent.
规范将两个更先发生顺序规定的操作没有满足happens-before关系称之为数据争用,并且证明当程序运行时不存在数据争用时,这个程序就是正确执行的。
同时需要注意的是,规范虽然通过更先发生顺序定义了何为正确的执行顺序,但是规范没有要求所有的平台必须按照这个顺序执行代码,而是只要代码执行结果跟正确的执行顺序的结果一致,那么平台可以用任意顺序执行(重排序)代码。
It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.
规范还举了个例子:同步顺序有一个规则 用默认值(0,false 或者 null) 对每个变量初始化 synchronizes-with 每一个线程对这个变量的首次使用。 但是实际上,处理器只需要在每个线程第一次使用到这个变量之前给这个变量用默认值赋值就可以了,因为这两种方式的实际效果是一样的。
我们实际上也经常遇到这方面的一个表现:
int a = 5;
int b = 6;
int c = a + b;
对于这段代码,虽然更先发生顺序要求 如果操作A、B在同一个线程执行,且在代码顺序中A在B之前,那么A happens-before B,所以处理器应该要先初始化a,再初始化b。但是实际上,处理器可能先初始化a,也可以先初始化b,因为这两种方式对程序结果没有影响。
Happens-Before规则具有传递性,所以语句之间的可见性是可以累积的。接下来我们具体分析synchronized
和volatile
的安全性是怎么产生的。
现在有两个线程A,B;它们执行操作如下:
对于线程A,B来说,根据同步顺序第一条(简写为SO1)和更先发生顺序第三条(简写为HB3),有A2 happens-before B1(简写为A2 →B1);根据HB1,又有A1→A2、B1→B2,所以根据HB4,有: A1→A2→B1→B2,即:
A在释放锁前执行的所有操作的结果都对B在获取锁后的所有操作可见。 这样就实现了线程间操作的同步。由此我们也可以发现,happens-before 关系产生的可见性是可以延续到A2(锁的临界区)之前的操作A1,但是由于A1在临界区之外,所以虽然B1可以看到A1的执行结果,但却不能保证这个结果是最新的(因为其他线程能在A2之后B1修改A1执行的结果)。
现在有两个线程A,B;它们执行操作如下:
A2修改了一个volatile变量,而B1(在A2执行之后)读取了该变量,根据 SO2,有A2→B1,又根据HB1,又有A1→A2、B1→B2,所以根据HB4,同样有: A1→A2→B1→B2,所以volatile关键字能够对可见性和有序性进行保障,具体表现和上述锁的作用十分类似。
关于JMM关于Happens-Before规则就说到这儿了,我想吐槽的是,我通过书学习这一块的内容的时候,对HB1规则的作用想了很久都没有理解,认为这个跟重排序的表现冲突了。最后看规范才知道,原文说明了处理器不需要按这个规则执行,只需要让执行结果跟这个规则执行的结果一致就可以了。所以说,还是要看文档啊,而如果你看到这觉得我说的也有逻辑不通或者其他问题的话,也还是去看文档吧,文档地址我在前面已经给出来了。