绝大多数的运算任务都不可能只靠处理器“计算”就能完成。 处理器至少要与内存交互, 如读取运算数据、存储运算结果等, 这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务) 。 由于计算机的存储设备与处理器的运算速度有着几个数量级的差距, 所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache) 来作为内存与处理器之间的缓冲: 将运算需要使用的数据复制到缓存中, 让运算能快速进行, 当运算结束后再从缓存同步回内存之中, 这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾, 但是也为计算机系统带来更高的复杂度, 它引入了一个新的问题: 缓存一致性(Cache Coherence) 。 在多路处理器系统中, 每个处理器都有自己的高速缓存, 而它们又共享同一主内存(Main Memory) , 这种系统称为共享内存多核系统(Shared Memory Multiprocessors System) , 如图12-1所示。 当多个处理器的运算任务都涉及同一块主内存区域时, 将可能导致各自的缓存数据不一致。 如果真的发生这种情况, 那同步回到主内存时该以谁的缓存数据为准呢? 为了解决一致性的问题, 需要各个处理器访问缓存时都遵循一些协议, 在读写时要根据协议来进行操作, 这类协议有MSI、 MESI(Illinois Protocol) 、 MOSI、Synapse、 Firefly及Dragon Protocol等。 从本章开始, 我们将会频繁见到“内存模型”一词, 它可以理解为在特定的操作协议下, 对特定的内存或高速缓存进行读写访问的过程抽象。 不同架构的物理机器可以拥有不一样的内存模型, 而Java虚拟机也有自己的内存模型, 并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。
除了增加高速缓存之外, 为了使处理器内部的运算单元能尽量被充分利用, 处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution) 优化, 处理器会在计算之后将乱序执行的结果重组, 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致, 因此如果存在一个计算任务依赖另外一个计算任务的中间结果, 那么其顺序性并不能靠代码的先后顺序来保证。 与处理器的乱序执行优化类似, Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder) 优化。
二、 Java内存模型
《Java虚拟机规范》 中曾试图定义一种“Java内存模型”来屏蔽各种硬件和操作系统的内存访问差异, 以实现让Java程序在各种平台下都能达到一致的内存访问效果。 在此之前, 主流程序语言(如C和C++等) 直接使用物理硬件和操作系统的内存模型。 因此, 由于不同平台上内存模型的差异, 有可能导致程序在一套平台上并发完全正常, 而在另外一套平台上并发访问却经常出错, 所以在某些场景下必须针对不同的平台来编写程序。
定义Java内存模型并非一件容易的事情, 这个模型必须定义得足够严谨, 才能让Java的并发内存访问操作不会产生歧义; 但是也必须定义得足够宽松, 使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、 高速缓存和指令集中某些特有的指令) 来获取更好的执行速度。 经过长时间的验证和修补, 直至JDK 5 发布后, Java内存模型才终于成熟、 完善起来了。
2.1 主内存与工作内存
Java内存模型的主要目的是定义程序中各种变量的访问规则, 即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。 此处的变量(Variables) 与Java编程中所说的变量有所区别, 它包括了实例字段、 静态字段和构成数组对象的元素, 但是不包括局部变量与方法参数, 因为后者是线程私有的, 不会被共享, 自然就不会存在竞争问题。 为了获得更好的执行效能, Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互, 也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。 Java内存模型规定了所有的变量都存储在主内存中 。 每条线程还有自己的工作内存(Working Memory, 可与前面讲的处理器高速缓存类比) , 线程的工作内存中保存了被该线程使用的变量的主内存副本, 线程对变量的所有操作(读取、 赋值等) 都必须在工作内存中进行, 而不能直接读写主内存中的数据。 不同的线程之间也无法直接访问对方工作内存中的变量, 线程间变量值的传递均需要通过主内存来完成, 线程、 主内存、 工作内存三者的交互关系如图
这里所讲的主内存、 工作内存与第2章所讲的Java内存区域中的Java堆、 栈、 方法区等并不是同一个层次的对内存的划分, 这两者基本上是没有任何关系的。 如果两者一定要勉强对应起来, 那么从变量、 主内存、 工作内存的定义来看, 主内存主要对应于Java堆中的对象实例数据部分, 而工作内存则对应于虚拟机栈中的部分区域。 从更基础的层次上说, 主内存直接对应于物理硬件的内存, 而为了获取更好的运行速度, 虚拟机(或者是硬件、 操作系统本身的优化措施) 可能会让工作内存优先存储于寄存器和高速缓存中, 因为程序运行时主要访问的是工作内存。
2.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议, 即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步回主内存这一类的实现细节, Java内存模型中定义了以下8种操作来完成。 Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、 不可再分的。 ·lock(锁定) : 作用于主内存的变量, 它把一个变量标识为一条线程独占的状态。
·unlock(解锁) : 作用于主内存的变量, 它把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定。
·read(读取) : 作用于主内存的变量, 它把一个变量的值从主内存传输到线程的工作内存中, 以便随后的load动作使用。
·load(载入) : 作用于工作内存的变量, 它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
·use(使用) : 作用于工作内存的变量, 它把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
·assign(赋值) : 作用于工作内存的变量, 它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
·store(存储) : 作用于工作内存的变量, 它把工作内存中一个变量的值传送到主内存中, 以便随后的write操作使用。
·write(写入) : 作用于主内存的变量, 它把store操作从工作内存中得到的变量的值放入主内存的变量中。如果要把一个变量从主内存拷贝到工作内存, 那就要按顺序执行read和load操作, 如果要把变量从工作内存同步回主内存, 就要按顺序执行store和write操作。
注意, Java内存模型只要求上述两个操作必须按顺序执行, 但不要求是连续执行。 也就是说read与load之间、 store与write之间是可插入其他指令的, 如对主内存中的变量a、 b进行访问时, 一种可能出现的顺序是read a、 read b、 load b、 load a。
除此之外, Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
·不允许read和load、 store和write操作之一单独出现, 即不允许一个变量从主内存读取了但工作内存不接受, 或者工作内存发起回写了但主内存不接受的情况出现。
·不允许一个线程丢弃它最近的assign操作, 即变量在工作内存中改变了之后必须把该变化同步回主内存。
·不允许一个线程无原因地 把数据从线程的工作内存同步回主内存中。
·一个新的变量只能在主内存中“诞生”, 不允许在工作内存中直接使用一个未被初始化(load或assign) 的变量, 换句话说就是对一个变量实施use、 store操作之前, 必须先执行assign和load操作。
·一个变量在同一个时刻只允许一条线程对其进行lock操作, 但lock操作可以被同一条线程重复执行多次, 多次执行lock后, 只有执行相同次数的unlock操作, 变量才会被解锁。
·如果对一个变量执行lock操作, 那将会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行load或assign操作以初始化变量的值。 ·如果一个变量事先没有被lock操作锁定, 那就不允许对它执行unlock操作, 也不允许去unlock一个被其他线程锁定的变量。 ·对一个变量执行unlock操作之前, 必须先把此变量同步回主内存中(执行store、 write操作) 。 这8种内存访问操作以及上述规则限定, 再加上稍后会介绍的专门针对volatile的一些特殊规定, 就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。
2.3 对于volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制, 但是它并不容易被正确、 完整地理解, 以至于许多程序员都习惯去避免使用它, 遇到需要处理多线程数据竞争问题的时候一律使用synchronized来进行同步。 了解volatile变量的语义对后面理解多线程操作的其他特性很有意义, 在本节中我们将多花费一些篇幅介绍volatile到底意味着什么。
Java内存模型为volatile专门定义了一些特殊的访问规则, 在介绍这些比较拗口的规则定义之前,先用一些不那么正式, 但通俗易懂的语言来介绍一下这个关键字的作用。
当一个变量被定义成volatile之后, 它将具备两项特性:
第一项是保证此变量对所有线程的可见性, 这里的“可见性”是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知的。 而普通变量并不能做到这一点, 普通变量的值在线程间传递时均需要通过主内存来完成。
比如,线程A修改一个普通变量的值, 然后向主内存进行回写, 另外一条线程B在线程A回写完成了之后再对 主内存进行读取操作, 新变量值才会对线程B可见。
关于volatile变量的可见性, 经常会被开发人员误解, 他们会误以为下面的描述是正确的: “volatile变量对所有线程是立即可见的, 对volatile变量所有的写操作都能立刻反映到其他线程之中。 换句话说, volatile变量在各个线程中是一致的, 所以基于volatile变量的运算在并发下是线程安全的”。 这句话 的论据部分并没有错, 但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全的”这样的结论。 volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看, 各个线程的工作内存中volatile变量也可以存在不一致的情况, 但由于每次使用之前都要先刷新, 执行引擎看不到不一致的情况, 因此可以认为不存在一致性问题) , 但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的, 我们可以通过一段简单的演示来说明原因, 请看代码清单12-1中演示的例子。
由于volatile变量只能保证可见性, 在不符合以下两条规则的运算场景中, 我们仍然要通过加锁(使用synchronized、 java.util.concurrent中的锁或原子类) 来保证原子性:
1.运算结果并不依赖变量的当前值, 或者能够确保只有单一的线程修改变量的值。
2.变量不需要与其他的状态变量共同参与不变约束。
解决了volatile的语义问题, 再来看看在众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他的同步工具更快吗? 在某些情况下, volatile的同步机制的性能确实要优于锁 , 但是由于虚拟机对锁实行的许多消除和优化, 使得我们很难确切地说volatile就会比synchronized快上多少。 如果让volatile自己与自己比较, 那可以确定一个原则: volatile变量读操作的性能消耗与普通变量几乎没有什么差别, 但是写操作则可能 会慢上一些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
不过即便如此, 大多数场景下volatile的总开销仍然要比锁来得更低。 我们在volatile与锁中选择的唯一判断依 据仅仅是volatile的语义能否满足使用场景的需求。 本节的最后, 我们再回头来看看Java内存模型中对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前都必须先从主内存刷新最新的值, 用于保证能看见其他线程对变量V所做的修改。
·只有当线程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修饰的变量不会被指令重排序优化, 从而保证代码的执行顺序与程序的顺序相同。