CH12-内存模型与线程
12.1 概述
多任务处理在现代计算机操作系统中几乎已经以必备功能了。在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力变得强大了,还有一个很重要的原因是计算机的运算速度,与它的存储和通讯子系统的速度,两者之间的差距越来越大:大部分时间都花在了磁盘 IO、网络通讯和数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力压榨出来,否则就会造成很大的浪费,而让计算机同时处理几项任务则是最容易想到、也被证明是非常有效的“压榨”手段。
除了充分利用计算机处理器的能力之外,一个服务端同时对多个客户端提供服务则是另一个更加具体的并发应用场景。衡量一个服务的性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一,它代表着一秒内服务平均能够响应的请求总数,而 TPS 值与程序的并发能力又有着密切的关系。对于计算量相同的任务,程序并发协调的越有条不紊,效率自然就会越高;反之,线程之间频繁阻塞甚至死锁,将会大大降低程序的并发能力。
服务端是 Java 语言最擅长的领域之一,这个领域占有了 Java 应用中最大的一块份额,不过如何写好并发应用程序却是程序开发的难点之一,处理好并发方面的问题通常需要很多经验。幸好 Java 语言和虚拟机提供了很多工具,把并发编程的门槛降低了不少。另外,各种中间件服务器、各类框架都努力的替程序员处理尽可能多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是花费大部分时间来关注服务会同时被多少人调用。但是无论语言、中间件和框架如何先进,我们都不能期望它们能够独立完成并发处理的所有事情,了解并发的内幕也是称为一个高级程序员不可缺少的课程。
12.2 硬件效率与一致性
在正式讲解 JVM 并发相关的知识之前,我们先花费一点时间来了解物理计算机中的并发问题,物理机遇到的并发问题与 JVM 中的情况有不少相似之处,物理机解决并发的方式对 JVM 的实现由很大的参考意义。
“让计算机并发执行多个运算任务”与“充分利用计算机处理器的效能”之间的因果关系看起来顺理成章,实际上并没有想象中那么容易实现,因为所有的运算任务都不可能仅靠处理器的“计算”就能完成,至少要与内存交互,如读取运算数据、存储运算结果等,仅靠寄存器也无法实现。由于计算机存储设备的访问速度与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为处理器与内存之间的缓冲:将运算中用到的数据复制到缓存中以加速运算,当运算结束后在从缓存同步回内存,以避免处理器等待缓慢的内存访问。
基于高速缓存的存储交互很好的解决了处理器与内存之间的速度矛盾,但是也引入了新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又都共享同一内存,如下图所示。当多个处理器的运算任务都涉及同一块内存区域时,将可能导致搁置的缓存数据不一致,如果真的发生这种情况,将各个处理器缓存中的数据同步回内存时又以谁的缓存为准呢?为了解决一致性问题,需要各个处理器访问缓存时都遵循一定的协议,在读写时要根据协议来进程操作,这类协议有 MSI、MESI、MOSI、Firefly、Dragon Protocol 等。JVM 内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
除此之外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结构一致,但并不保证程序中各个语句计算的顺序与代码输入时的顺序一致,因此如果一个计算任务依赖于另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,JVM 的即时编译器中也有类似的指令重排优化。
12.3 Java 内存模型
JVM 规范中试图定义一种内存模型(JMM,JSR-133)来屏蔽掉各种硬件和操作系统在内存访问上的差异,以实现 Java 程序在各种平台下都保持一致的并发效果。在此之前,主流语言(如 C/C++)直接使用物理硬件(或者说是操作系统的内存模型),因此,会由于不同平台的内存差异导致程序在一些平台上的并发效果正常,但在另一些平台上出现非预期的并发效果,因此需要经常针对不同的平台来编写程序。
定义 JMM 并非易事,该模型必须定义的足够严谨,才能让 Java 的并发操作不会产生歧义:但是,也必须定义的足够宽松,使得虚拟机的实现能够由足够的自由空间去利用硬件的各种特性(如寄存器、高速缓存等)来获得更好的执行速度。经过长时间的验证和修补,在 JDK 1.5(实现了 JSR-133)发布后,JMM 就已经成熟和完善起来了。
12.3.1 主内存与工作内存
JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里所说的变量与 Java 编程中所说的变量略有区别,它包括实例字段、静态字段和构造数组对象的元素,不包括局部变量和方法参数,因为后者是“线程私有”的,不会在线程间共享,自然也就不存在竞争问题。为了获得更好的执行效能,JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 调整代码执行顺序的这类权利。
线程私有:如果局部变量是一个引用类型,它引用的对象在堆中可被各个线程共享,但是引用本身位于 Java 栈的局部变量表中,它是线程私有的。
JMM 规定了所有的变量都存储在主内存中(这里是 JVM 内存的一部分,类似于物理机中的内存)。每条线程还有自己的工作内存(类比于 CPU 的高速缓存,但属于线程所有),线程的工作内存中保存了被该线程使用到的变量,这些变量是来自主内存的副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写“主内存中的变量”。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存之间的交互关系如下图。
主内存中的变量:根据 JVM 规范,volatile 变量依然用工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如图直接操作主内存一样。但 volatile 并不例外。
这里所讲的主内存、工作内存与本书第二章所讲的 Java 内存区域中的 Java 堆、栈、方法区等并非同一层次的内存划分。如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要是对应于 Java 堆中对象的实例数据部分,而工作内存则对应于 JVM 栈中的部分区域。从更低的层次来说,主内存就是硬件的内存,而为了获得更好的运行速度,JVM 及硬件系统可能会让工作内存优先存储在寄存器或高速缓存中。
12.3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM 中定义了以下 8 种“操作”来完成:
- lock:作用于主内存中的变量,将变量标识为被一条线程独占。
- unlock:作用于主内存中的变量,将被标识为被一条线程独占状态的变量从该线程释放,释放后该变量可以被其他线程 lock。
- read:作用于主内存中的变量,将变量的值从主内存传输到工作内存,以便后续的 load 使用。
- load:作用于工作内存中的变量,将 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use:作用于工作内存中的变量,将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将执行该操作。
- assign:作用于工作内存中的变量,将一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时将执行该操作。
- store:作用于主内存中的变量,将工作内存中一个变量的值传送到主内存,以便随后的 write 操作使用。
- write:作用于主内存中的变量,将 store 操作从工作内存中得到的变量值放入主内存的变量中。
操作:实现虚拟机时必须保证每一种“操作”都是原子的、不可再分的。对于 double 和 long 类型的变量来说,在操作主内存时会出现字撕裂。
如果要把一个变量从主内存复制到工作内存,就要按顺序执行 read、load 操作;如果要把变量从工作内存同步回主内存,就要按顺序执行 store、write 操作。注意,JMM 只要求上述两个操作必须按顺序执行,而没有要求必须是连续执行,即 read 和 load 之间、store 和 write 之间是可以插入其他指令的(如对另外一些变量的存取操作指令)。除此之外,JMM 还规定了在执行上述 8 种基本操作时必须满足以下规则:
- read 和 laod、store 和 write 必须成对出现。
- 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无故(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存。
- 一个新的变量必须在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load/assign)的变量,即,对一个变量实施 use 和 store 操作之前,必须已经执行过 assign 和 load 操作。
- 一个变量在同一时刻仅允许一个线程对其进行 lock,但 lock 可以被同一线程重复执行多次,多次执行 lock 之后,只有执行相同次数的 unlock 才能完成解锁。
- 如果对一个变量执行 lock,将会清空工作内存中该变量的值而直接使用主内存中该变量的值(lock 引用在主内存),在执行引擎使用该变量前,需要重新执行 load 或 assign 来初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对其执行 unlock;也不允许 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 前,必须先把此变量同步回主内存。
12.3.3 volatile 变量的特殊规则
关键字 volatile 可以说是 JVM 提供的最轻量级的同步机制,但是它并不容易被正确完整的理解。
当一个变量被定义为 volatile,它将具备两种特性,第一是保证此变量对所有线程可见:一旦一条线程修改了该变量,新值对于所有其他线程来说是可以立即得知的。而普通变量无法做到这一点,变量值在线程间的传递需要通过主内存来完成,如:线程 A 修改一个普遍变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成之后再从主内存读取该变量的新值,这时新值才对线程 B 可见。
这种可见性经常被误解,认为以下描述成立:“volatile 对所有线程是立即可见的,对 volatile 变量的所有写操作都能立即反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的”。这句话的论据部分没有错,但是其论据并不能得出“基于 volatile 变量的运算在并发下是安全的”这个结论。volatile 变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中 volatile 变量也会存在不一致的情况,由于每次使用 volatile 变量之前都会先进行刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),“但是 Java 中的运算并非原子操作”,导致 volatile 变量的运算在并发下变得不安全。下面看一个例子。
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String...args) {
Thread[] threads = new Thread[THREADS_COUNT];
for(int i=0; i<THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable(){
@Override
public void run() {
for(int i=0; i<10000; i++){
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
如果上述代码正确的话,最终的输出结果应该是 200000。但实际的运行结果并非如此。问题就出现在自增运算 race++
之中,我们用 javap 反编译这段代码后会得到下面的代码清单,发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成的,从字节码层面很容易就分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,其他线程就可能已经把 race 的值增加了,而在操作栈顶的值就成了过期数据,所以 putstatic 指令执行后就可能把较小的 race 值同步回主内存中。
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race: I
3: iconst_1
4: iadd
5: putstatic #13: //field race: I
8: return
LineNumberTable:
line 14: 0
line 15: 8
实事求是的说,笔者在此使用字节码来分析并发问题,仍然是不严谨的,因为即便编译出来只有一条字节码指令,也并不意味着执行这条指令就是原子操作。一条字节码指令在解释执行时,解释器将要运行许多代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转换成若干条本地机器码指令,此处使用 -XX:+PrintAssembly 参数输出反汇编来分析会更加严谨一些,但是考虑到读者阅读的方便,并且字节码已经能够说明问题,所以此处使用字节码来分析。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(会用 synchronized 或 JUC 中的原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
而在像如下代码清单中所示的这类场景就很适合使用 volatile 变量来控制并发,当 shutdown() 方法被调用时,能够保证所有线程中执行的 doWork() 方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested) {
// do stuff
}
}
使用 volatile 变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 JMM 中描述的所谓“线程内表现为串行的语义”。
如下伪代码所示:
Map configOptions;
char[] configText;
// 此时变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后
// 将 initialized 设置为 true 来通知其他线程配置已经可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
inintialized = true;
// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程 A 初始化好的配置信息
doSomethingWithConfig();
以上描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义 initialized 变量时没有使用 volatile 修饰,就可能由于指令重排的优化,导致位于线程 A 中最后一句的代码“initialized = true”被提前执行,这样在线程 B 中使用配置信息的代码就可能出现问题,而 volatile 关键字则可以避免此类请求的发生。
解决了 volatile 语义的问题,再来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他同步工具时更快吗?确实在某些情况下,volatile 同步机制的性能要优于锁(即 synchronized 或 JUC 中的锁),但是由于虚拟机对锁实行的很多消除和优化,使得我们很难量化的说 volatile 就是会比 synchronized 快上多少。如果让 volatile 自己与自己比较,则可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会满上一些,因为它需要在本地代码中插入许多内存屏障(Memory Barrier)指令来保证处理器不会发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。
本节的最后,我们再来看看 JMM 中对 volatile 变量定义的特殊规则。假设 T 表示一个线程,V 和 W 分别表示两个 volatile 变量,那么在执行 read、load、use、assign、store、write 操作时需要满足如下规则:
- 只有线程 T 对变量 V 执行的前一个动作是 load 的时候,T 才能对 V 执行 use 操作;并且,只有当 T 对 V 执行的后一个动作是 use 时,T 才能对 V 执行 load 动作。T 对 V 的 use 动作可以认为是与 T 对变量 V 的 load 和 read 动作是相关联的,必须一起“连续出现”。(该规则要求在工作内存中,每次使用 V 之前都先从主内存刷新最新值,用于保证能看见其他线程对 B 执行修改后的值)
- 只有当线程 T 对变量 V 执行的前一个操作是 assign 时,T 才能对 V 执行 store 操作;并且,只有当 T 对 V 执行的后一个动作是 store 时,T 才能对 V 执行 assign 操作。T 对 V 的 assign 动作可以认为是 T 对 V 的 store 和 write 动作相关联的,必须一起连续出现。(该规则要求在工作内存中,每次修改 V 后都必须立刻将其同步回主内存,用于保证其他线程可以看到当前线程对 V 所做的修改)
- 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定动作 F 是与动作 A 相关联的 load 或 store 操作,假定动作 P 是与动作 F 相应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,假定动作 G 是与动作 B 相关联的 load 或 store 操作,假定动作 Q 是与动作 G 相关联的对变量 W 的 read 或 write 动作。如果 A 先于 B,那么 P 先于 Q。(这条规则要求 volatile 修饰的变量不会被指令重排序优化,保证代码执行的顺序与程序代码的顺序相同)
12.3.4 对于 long 和 double 型变量的特殊规则
JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 种操作都具有原子性,但是对于 64 位的数据类型,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证数据类型为 long 和 double 的非原子性协定。
如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个即非原值又非其他线程修改值的“半个变量”数值。
不过这种读取到“半个变量”的情况非常罕见,因为 JMM 虽然允许虚拟机不把 long 和 double 变量的读写操作实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这么做。在实际开发中,目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作实现为原子操作,因此我们在编写代码时一般不需要把 long 的 double 变量专门声明为 volatile。
12.3.5 原子性、可见性、有序性
介绍完 JMM 相关的操作和规则,我们再整体回顾一下该模型的特征。JMM 是围绕着在并对过程中如何处理原子性、可见性、有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。
“原子性(Atomicity)”:由 JMM 来直接保证的原子性变量操作包括 read、load、assign、use、store、write 这 6 个,我们大致认为基本数据类型的访问读写是具备原子性的(long 和 double 除外)。
如果应用场景需要一个更大范围的原子性保证,JMM 还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
“可见性(Visibility)”:可见性就是指当一个线程修改了共享变量的值,其他线程能够立即获得这些最新的值。上文在讲解 volatile 变量的时候我们已经详细讨论过这一点。JMM 是通过在变量修改后将新值同步回主内存、在变量去读前从主内存刷新变量值这个种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别在于 volatile 的特殊规则保证了新值能够立即同步主内存,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了 volatile 之外,Java 还提供了两个关键字实现可见性,即 synchronized 和 final。同步块的可见性是由“对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中(store & write)”这条规则获得的;而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逸出是一件很危险的事情,其他线程可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看到final 字段的值。如下代码所示,变量 i 与 j 都具备可见性,它们无需同步就能被其他线程正确的访问。
public static final int i;
public finl int j;
static {
i = 0;
j = 0; // 也可以选择则构造函数中初始化 j
}
“有序性(Ordering)”:JMM 的有序性前面讲解 volatile 时也详细讨论过,Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一时刻仅允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。
介绍完并发的三种重要特性,读者有没有发现 synchronized 关键字在需要这三种特性时都可以作为一种解决方案?看起来是万能的?的确,大部分的开发控制操作都能通过使用 synchronized 来完成。synchronized 的“万能”间接造就了它被开发者滥用的局面,越“万能”的并发控制,通常也伴随着越大的性能影响。
12.3.6 先行发生(happens-before)原则
如果 JMM 中所有的有序性都只靠 volatile 和 synchronized 来完成,那么有一些操作就会变得很啰嗦,但是我们在编写 Java 并发代码的时候并没有感觉到这一点,这是因为 Java 语言中有一个“先行发生”原则。该原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据。基于该原则,我们可以通过几条规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题。
先行发生是 JMM 中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,其实就是说在发生 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括对内存中共享变量值的修改、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明,如下所示的三行伪代码:
// 线程 A
i = 1;
// 线程 B
j = i;
// 线程 C
i = 2;
假设线程 A 中的操作线性发生于线程 B 中的操作,那么我们就可以确定在线程 B 的操作执行之后,变量 j 的值一定为 1。得出该结论的依据有两个,一是根据现行发生原则,“i=1”的结果可以被观察到;二是线程 C 登场之前,线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和 B 之前的线性发生关系,而 C 出现在线程 A 和 B 的操作之间,但是 C 与 B 没有先行发生关系,那么 j 的值会是多少呢?答案是不确定。1 或是 2,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。
下面是 JMM 中一些“天然的”线性发生关系,这些线性发生关系无需任何同步协助就已经存在,可以直接在编码时使用。如果两个操作之间的关系不在次列,并且也无法从下列规则中推导出来的话,它们就没有顺序性保证,虚拟机可以对他们进行随意重排。
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于发生在后面的操作。准确的说应该是控制流程序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管理锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指的是时间上的先后顺序。
- 线程启动规则:Thread 对象的 start 方法先行发生于此线程内的所有动作。
- 线程终止规则:线程内的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize 方法的开始。
- 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C。
Java 语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些了,笔者演示一下如何使用这些规则去判定操作间是否具有顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面的例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
上述代码显示的是一组再普通不过的 getter/setter 方法,假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?
我们依次分析一下先行发生规则,由于两个方法分别是由线程 A 和 B 调用,不再一个线程中,所以程序次序在这里不适用;由于没有同步块,自然就不会发生 lock 和 unlock 操作,所以管理程序规则不适用;由于 value 变量没有被 volatile 修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和代码中的操作扯不上关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程 A 的操作在时间上先于线程 B,但是无法确定 B 中 getValue 的返回结果,换句话说,这里的操作不是线程安全的。
那么如何修复这个问题呢?我们至少有两种比较简单的方案可供选择:呀么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖于 value 的原值,满足了 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。
通过上面的例子,我们可以得出一个结论:一个操作“时间上先行发生”不代表这个操作会是“先行发生”,那么如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的场景就是多次提到的“指令重排序”,如下代码所示:
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
根据程序次序规则,第一行的操作先先行发生于第二行的操作,但是第二行代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程中没有办法感知到这一点。
上面两个例子综合得出一个结论:时间上的先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
12.4 Java 与线程
并发不一定要依赖多线程(如 PHP 中很常见的多进程并发),但是在 Java 里面谈论并发,大多数与线程脱不开关系。因此我们就从 Java 线程在虚拟机中的实现讲起。
12.4.1 线程的实现
我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址、文件 IO 等),又可以独立调度(线程是 CPU 调度的最基本单位)。
主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个 java.lang.Thread 类的实例就代表了一个线程。不过 Thread 类与大部分的 Java API 有着显著的区别,它的所有关键方法都被实现为 Native。在 Java API 中一个 Native 方法可能意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能为了执行效率而使用 Native 方法,不过通常最高效率的手段也就是平台相关的手段)。正因为如此,作者把本节标题定位“线程的实现”,而非“Java 线程的实现”。
实现线程主要有三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
1. 使用内核线程实现
内核线程(KLT)就是直接由操作系统内核支持的线程,这些线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看做是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LWP)。轻量级进程就是我们通常意义上所说的线程,由于每个轻量级进程都有一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的冠以称为“一对一的线程模型”,如下图所示:
由于内核线程的支持,每个轻量级进程都称为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种进程操作,如创建、撤销及同步都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2. 使用用户线程实现
广义来讲,一个线程只要不是内核线程,就可以被认为是用户线程(UT),因此从这个定义上来讲轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核线程之上的,许多操作都需要使用系统调用,因此效率也会受限制。
而狭义上的用户线程值得是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之前是 1:N 的关系称为 “一对多的线程模型”,如下图:
使用用户线程的优势在于不需要内核支援,劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换、调度都是需要考虑的问题,而且由于操作系统只能把处理器资源分配到进程级别,那些诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其它处理器上”这类问题解决起来都会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,处理以前在不支持多线程的操作系统中的多线程程序与少数具有特殊需求的程序之外,现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,但最终都放弃了。
3. 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现外,还有一种将内核线程与用户线程结合的实现方式。在这种混合实现中,既存在用户线程,也存在轻量级继承。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、销毁等操作依然廉价,并且可以支持大规模的用户级线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以利用内核提供的线程调度功能及处理器映射功能,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了进程被阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比例是不一定的,是 M:N 的关系,被称为“多对多线程模型”,如下图:
许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 M:N 的线程模型实现。
4. Java 线程的实现
Java 线程在 JDK 1.2 之前,是基于名为“绿色线程(Green Threads)” 的用户线程实现的,而在 JDK 1.2 中,线程模型被替换为基于操作系统原生线程模型来实现。因此在目前版本的 JDK 中,操作系统支持怎样的线程模型,在很大程度上决定了 JVM 虚拟机的线程是怎样的映射的,这点在不同平台上没有办法达成一致,虚拟机规范中也并未限定 Java 线程需要使用哪些线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对 Java 程序的编码和运行过程来说,这些差异都是透明的。
对于 Sun JDK 来说,它的 Windows 与 Linux 版都是使用一对一的线程模型来实现的,一条 Java 线程就映射到一条轻量级线程之中,因为 Windows 和 Linux 系统提供的线程模型就是一对一的(尽管存在一些多对多模型的实现,但没有成为主流)。
而在 Solaris 平台中,由于操作系统的线程特性可以同时支持一对一(通过 Bound Threads 或 Alternate Libthread)和多对多(通过 LMP/Thread Based Synchronization 实现)的线程模型,因此在 Solaris 版的 JDK 中也对应提供了两个平台专有的虚拟机参数:-XX:+UseLWPSynchronization(默认值) 和 -XX:+UseBoundThreads 来明确指定虚拟机要使用的线程模型。
12.4.2 Java 线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,协同式(Cooperative)和抢占式(Preemptive)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情完成之后才会进行线程切换,切换操作对线程自己来说是可以预知的,所以没有什么线程同步的问题。Lua 语言中的“协同例程”就是这类实现。它的缺点很明显:线程执行时间不可控,甚至如果一个线程编写有问题,一直不告诉系统进行线程切换,那么程序会一直阻塞在那里。很久以前的 Windows 3.x 系统就是使用协同式来实现多进程多任务的,那是相当的不稳定,一个进程坚持不让出 CPU 执行时间就会导致整个系统崩溃。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在 Java 中,Thread.yield 可以让出执行时间,但是线程本身没办法主动获得执行时间)。在这种实现线程调度的方式下,线程的执行实现是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。与前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现的多进程,当一个进程出了问题,我们还可以使用任务管理器来把这个进程杀掉,而不至于导致系统崩溃。
虽然说 Java 多线程调度是由系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一些执行时间,另外的一些线程多则可以少分配一些——这项操作可以通过设置线程优先级来完成。Java 语言一共设置了 10 个级别的线程优先级,在两个线程同时处于 Read 状态时,优先级越高的线程越容易被系统选择执行。
但是线程优先级并不是很靠谱,原因是 Java 的线程是被映射到系统的原生线程(内核线程)上来实现的,所以线程调度最终还是由操作系统说了算,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与 Java 线程的优先级实现一一对应,如 Solaris 中有 2147483648 种优先级,但 Windows 中仅有 7 种,比 Java 线程优先级多的系统还好说,中间留下一点空位就是了,但比 Java 线程优先级少的系统,就不得不出现几个优先级相同的情况了,下表展示了 Java 线程优先级与 Window 线程优先级之间的对应关系,Windows 平台的 JDK 中使用了除 THREAD_PRIORITY_IDLE 之外的其余 6 种线程优先级。
上文说到“线程优先级并不是很靠谱”,不仅仅是说在一些平台上不同的优先级实际会变得相同这一点,还有其他情况让我们不能太依赖优先级:优先级可能会被系统自动改变。例如在 Windows 系统中存在一个名为“优先级推进器”的功能,它的大致作用即使当系统发现一个线程被执行的“特别勤奋努力”的话,可能会越过线程优先级来为其分配执行时间。因此我们不能在程序中通过优先级来完全准确的判断一组状态都为 Ready 的线程中哪个会先执行。
12.4.3 状态转换
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态:
- New:创建后但尚未启动。
- Runnable:包括了操作系统线程状态的 Running 和 Ready,也就是处于这种状态的线程有可能在执行,也可能正在等着操作系统为其分配执行时间。
- Waiting:无限期等待,不会被分配 CPU 执行时间,需要等待被其他线程显式的唤醒。以下方法会让线程陷入 Waiting 状态:
- Object.wait()
- Thread.join()
- LockSupport.park()
- Timed Waiting:期限等待,不会被分配 CPU 执行时间,在一定时间后它们会由操作系统自动唤醒。以下方法会让线程陷入 Timed Waiting 状态:
- Thread.sleep()
- Object.wait(time)
- Thread.join(time)
- LockSupport.parkNanos(nanos)
- LockSupport.parkUntil(time)
- Blocked:线程被阻塞。“阻塞状态”与“等待状态”的区别是,“阻塞状态”在等着获得一个排他锁,这个事件将在另一个线程放弃该锁的时候发生;而“等待状态”则是在等待一段时间、发生唤醒动作。在程序等待进入同步区域的时候,线程将进入这种状态。
- Terminated:线程已被终止,线程已被结束执行。
上述 5 种状态在约到特定事件时会发生切换,它们之间的转换关系如下图:
12.5 总结
本章中,我们了解了虚拟机 JMM 的结构及操作,并讲解了原子性、可见性、有序性在 JMM 中的体现,介绍了先行发生原则的规则及应用。另外,我们还了解了线程在 Java 语言中的实现方式。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.