
2.4 JMM
虽然本章并没有明确说明,但是实际上已经定义 JMM 的大部分内容。那么,到底什么是内存模型呢?
一个语言的内存模型指的是一种规范,它描述了变量的写操作在什么情况下对其他线程可见。读者可以认为一个处理器修改了变量v之后就会立刻改变其相应的内存,然后其他处理器可瞬时发现v的新值。这种内存一致性模型称为顺序一致性。
但从ThreadSharedStateAccessReordering示例中可以看到,顺序一致性实际上只是用户的“一厢情愿”,处理器和编译器实际上并不是这么做的。写操作极少会立即作用于主存;在计算机系统中,处理器与主存之间存在着一个多级缓存结构,利用缓存可以提高性能,但只保证数据会最终写入主存。为了在不改变串行语义的情况下获得最优性能,编译器会利用缓存来推迟或避免主存写操作。这么做是合理的。虽然本书中的示例中有很多同步原语,但是在实际的程序中,每个线程用于实际计算的时间远远多于通信时间。
为了既保证并发程序的行为的可预测性,又让编译器最大程度地优化程序,内存模型实际上是一种权衡的产物。并不是每门编程语言或每个平台都有内存模型。比如,纯函数式编程语言就不支持变量修改,所以根本就不需要内存模型。
不同处理架构会导致不同的内存模型;如果不定义好 synchronized 语句或易失读写等操作的精确语义,则几乎无法正确编写出对所有平台都适用的 Scala 并发程序。Scala 继承了 JVM 的内存模型,该内存模型定义了一系列程序中各种行为的前发生(happens-before)关系。
在JVM中,这些行为包括易失读写、对象监控器的获取与释放、启动线程和等待线程结束等。如果一个行为A发生于行为B之前,则行为B可以发现行为A的内存写操作。不管程序运行在哪种机器上,这些前发生关系都是合法的;JVM有义务确保这些关系的正确性。虽然前面已经提到这些规则的部分内容,但这里还是有必要给出一个概述。
● 程序指令顺序(program order):在线程中,程序指令顺序决定了它的各个行为之间的先后发生的顺序。
● 监控器锁定(monitor locking):对一个监控器的解锁发生在此监控器后续被锁定之前。
● 易失字段(volatile fields):易失字段的写操作发生在此易失字段后续的读操作之前。
● 线程启动(thread start):线程的start方法发生在此线程中所有行为之前。
● 线程终止(thread termination):一个线程中的任意行为发生在另一个线程完成其join方法之前。
● 传递性(transitivity):如果行为A发生在行为B之前,而行为B发生在行为C之前,则行为A发生在行为C之前。
虽然“前发生关系”这个名字有些奇怪,但这种机制确保了线程之间能够发现对方的写操作。但它并不用于建立程序中不同语句之间的时序关系。当我们说写操作A发生于读操作B之前,那么写操作A的结果对读操作B一定是可见的。而A是否在B之前发生则取决于程序的执行过程。
前发生关系描述了不同线程之间的写操作的可见性。
此外,JMM还保证易失读写操作,以及监控器锁定和解锁都不会重排序。前发生关系确保了非易失读写操作也不能任意重排序。具体而言,前发生关系进行了如下保证。
● 非易失读操作不能通过重排序出现在程序指令顺序更靠前的程序易失读操作(或监控器锁定)之前。
● 非易失写操作不能通过重排序出现在程序指令顺序更靠后的易失写操作(或监控器解锁)之后。
还有一些高层的构造也构成了前发生关系,它们是基于上述规则而实现的。比如,interrupt 的调用发生在被中断的线程检测到此调用信号之前,这是因为在传统的实现方式中,interrupt的调用是通过监控器来唤醒线程的。
后文介绍的Scala并发API也在各种方法调用之间建立了前发生关系。在这些情况下,程序员需要自己保证一个变量的写操作与所有此变量的新值的读操作构成前发生关系。如果做不到这一点,就会出现所谓的数据争用。
不可变对象和终态字段
前面介绍了使用前发生关系避免数据争用的必要性,但凡事有例外。如果一个对象只包含终态字段,而且对外围对象的引用在构造函数完成之前对其他线程不可见,那么,此对象可视为不可变的,共享时就无须使用同步了。
在Java中,终态字段通过final关键字来标识。在Scala中,将一个对象字段声明为final表示此字段的getter方法不能在子类中重载。如果一个字段被声明为val,则它本身也是终态的。它们的区别如下面代码所示。
class Foo(final val a: Int, val b: Int)
上面的类定义对应于下列Scala编译器编译出来的Java类。
class Foo { // 以下为Java代码 final private int a$; final private int b$; final public int a() { return a$; } public int b() { return b$; } public Foo(int a, int b) { a$ = a; b$ = b; } }
注意,a 和 b 字段在 JVM 层面都是终态的,因此在共享时无须同步。区别在于 a的getter方法无法在Foo的子类中重载。Scala中的重新赋值意义下的终态和重载意义下的终态完全是两码事。
因为,Scala同时采用了函数式编程和面向对象编程,它的很多语言特性实际上对应于不可变对象。一个匿名函数可以捕捉到外围类或被提升对象的引用,如下面代码所示。
var inc: () => Unit = null val t = thread { if (inc != null) inc() } private var number = 1 inc = () => { number += 1 }
局部变量number被匿名函数所捕捉,所以需要进一步提升。最后一行代码会被编译成如下匿名Function0类的实例。
number = new IntRef(1) // 捕捉到的局部变量提升成了对象 inc = new Function0 { val $number = number // 注意,val声明表示终态字段 def apply() = $number.elem += 1 }
这里的inc赋值和线程t对inc的读操作之间并不存在前发生关系。不过,如果线程t发现inc不为null,调用inc仍然是正确的。这是因为$number字段已经正确初始化,它存储在不可变的匿名函数对象中。Scala编译器保证匿名函数值中只包含正确初始化的终态字段。匿名类、自动装箱(auto-boxed)原语以及值类(value class)都有同样的理念。
不过,在本书使用的 Scala 中,某些容器虽然号称不可变,但是不能在不同步的情况下共享,比如List和Vector。虽然它们的外部API不允许对其进行修改,但是它们包含了非终态字段。
即使一个对象貌似不可变,稳妥起见,也应尽量通过正确的同步机制实现线程间的共享。