0%

JVM 内存模型

并发处理的广泛应用是使得 Amdahl 定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。

为了解决内存和处理器的速度矛盾,增加了高速缓存进行存储交互。为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-of-Order Execution)优化。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

CPU Cache模型

在计算机中,所有的运算操作都是由 CPU 的寄存器来完成的,CPU 指令的执行过程需要涉及数据的读取和写入操作,CPU 所能访问的所有数据只能是计算机的主存(通常是指 RAM),虽然 CPU 的发展频率不断地得到提升,但受制于制造工艺以及成本等的限制算机的内存反倒在访问速度上并没有多大的突破,因此 CPU 的处理速度和内存的访问速度之间的差距越拉越大,通常这种差距可以达到上千倍,极端情况下甚至会在上万倍以上。

由于两边速度严重的不对等,通过传统FSB直连内存的访问方式很明显会导致 CPU 资源受到大量的限制,降低 CPU 整体的吞吐量,于是就有了在 CPU 和主内存之间增加缓存的设计,现在缓存的数量都可以增加到 3 级了,最靠近 CPU 的缓存称为 L1,然后依次是 L2,L3 和主内存,CPU缓存模型如下图所示。

由于程序指令和程序数据的行为和热点分布差异很大,因此 L1 Cache 又被划分成了 L1i(i 是 instruction 的首字母)和 L1d(d 是 data 的首字母)这两种有各自专门用途的缓存,CPU Cache 又是由很多个 Cache Line 构成的,Cache Line 可以认为是 CPU Cache 中的最小缓存单位,目前主流 CPU Cache 的 Cache Line 大小都是 64 字节。

通过上图,我们可以发现主内存的读写速度远远落后于 CPU Cache 的速度,更别说 CPU 本身的计算速度了。

Cache 的出现是为了解决 CPU 直接访问内存效率低下问题的,程序在运行的过程中,会将运算所需要的数据从主存复制一份到 CPU Cache 中,这样 CPU 进行计算时就可以直接对 CPU Cache 中的数据进行读取和写入,当运算结束之后,再将 CPU Cache 中的最新数据刷新到主内存当中,CPU 通过直接访问 Cache 的方式替代直接访问主存的方式极大地提高了 CPU 的吞吐能力,有了 CPU Cache 之后,整体的 CPU 和主内存之间交互的架构大致如下图所示。

但随着中间缓存的引入,数据不一致的问题也随之而来,通常主流的解决方式有两种:

  • 通过总线加锁的方式
  • 通过缓存一致性协议

第一种方式常见于早期的 CPU 当中,而且是一种悲观的实现方式,CPU 和其他组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行的,如果采用总线加锁的方式,则会阻塞其他 CPU 对其他组件的访问从而使得只有一个 CPU(抢到总线锁)能够访问这个变量的内存。这种方式效率低下,所以就有了第二种通过缓存一致性协议的方式来解决不一致的问题(见下图)。

在缓存一致性协议中最为出名的是 Intel 的 MESI 协议,MESI 协议保证了每一个缓存中使用的共享变量副本都是一致的,它的大致思想是,当 CPU 在操作 Cache 中的数据时,如果发现该变量是一个共享变量,也就是说在其他的 CPU Cache 中也存在一个副本,那么进行如下操作:

  1. 读取操作,不做任何处理,只是将 Cache 中的数据读取到寄存器。
  2. 写入操作,发出信号通知其他 CPU 将该变量的 Cache line 置为无效状态,其他 CPU 在进行该变量读取的时候不得不到主内存中再次获取。

Java内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型 [JSR-133](Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如 C/C++ 等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

主内存与工作内存

Java 内存模型的主要目标定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内在和从内存中取出变量这样的底层细节此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量(Local variables),方法定义参数(formal method parameters)和异常处理器参数(exception handler parameters)。前者存储在堆内存中,堆内存在线程之间共享,而后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了获得较好的执行效能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施。

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示。

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作內存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

这也就是程序不同步的原因之一:私有堆栈和公共堆栈的值不同步,因此在 Java 中可以通过 vollatile、Sync、Lock 等机制,使得线程主体强制从主内存读写数据来保证阶段性的数据一致。

内存间的交互操作

关于主内存与作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步叫主内存之类的实现细节,Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外)

  • 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 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说,就是对一个变量实施 use、 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。

指令重排序

普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓的“线程内表现为串行的语义”(Within Thread As-If-Serial Semantics)。

从硬件架构上讲,指令重排序是指 CPU 采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU 需要能正确处理指令依赖情况以保障程序能得岀正确的执行结果。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量
读后写 a = b;b = 1; 读一个变量之后,再写这个变量

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。而编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。即使是重排序后的操作,对于最后的执行结果也是无影响的。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

重排序分类

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序

    现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序

    由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义可以看出,JMM 同样遵从这一目标。

对于volatile型变量的特殊规则

当一个变量定义为 volatile 之后,它将具备两种特性。

可见性

第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。

但这个可见性只在程序获取到或修改它时得到保证,如果操作不是原子性的,仍需通过加锁来保证并发一致性。

值得一提的是,Java 还有两个关键字能实现可见性,即 synchronized代码块 和 final。

同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行store、 write操作)”这条规则获得的

final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见 final 字段的值。

禁止指令重排序优化

第二是禁止指令重排序优化。禁止指令重排序的原理是在执行 voliate 变量赋值时,在赋值操作的后面加上一个内存屏障(Memory Barrier 或 Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个 CPU 访问内存时,并不需要内存屏障;但如果有多个 CPU 访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。并且在 voliate 变量赋值操作完成后,需要进行将工作内存变量拷贝至主内存变量的操作。本 CPU 的 Cache 写入了主内存,该写入动作也会引起别的 CPU 或者别的内核无效化(Invalidate)其 Cache,这种操作相当于对 Cache 中的变量做了一次 Java 内存模式中所说的“store”和”write”操作。通过这样一个操作,可让前面 volatile 变量的修改对其他 CPU 立即可见。因此,其他 CPU 把修改同步到工作内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。

大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁之中选择的唯一依据仅仅是 volatile 的语义能否满足使用场景的需求。

对于long和double型变量的特殊规则

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性,但是对于 64 位的数据类型(long 和 double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虛拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定(Nonatomic Treatment of double and long variables)。

如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。这个现象与处理器总线的工作机制密切相关。

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。

下面让我们通过一个示意图来说明总线的工作机制:

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long / double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的无效值。

不过这种读取到“半个变量”的情况非常罕见(在目前商用 Java 虚拟机中不会出现),因为 Java 内存模型虽然允许虚拟机不把 long 和 double 变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现在实际开发中,目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的 long 和 double 变量专门声明为 volatile。

happen-before原则

“先行发生”(happens- before)原则是判断数据是否存在竞争、线程是否安全的主要依据。Java 内存模型实现了以下几种天然的先行发生关系。

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构
  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序
  • 线程启动规则(Thread Start Rule):Thread 对象的 start 方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Fermination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule);对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

示例一:对happen-before的理解

假设线程 A 中的操作i=1先行发生于线程 B 的操作j=i,那么可以确定在线程 B 的操作执行后,变量 j 的值一定等于 1,得出这个结论的依据有两个:一是根据先行发生原则,i=1的结果可以被观察到;二是线程 C 还没“登场”,线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和线程 B 之间的先行发生关系,而线程 C 出现在线程 A 和线程 B 的操作之间,但是线程 C 与线程 B 没有先行发生关系,那 j 的值会是多少呢?答案是不确定!1 和 2 都有可能,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。

示例二:对happen-before的分析

public class Solution {
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = 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 变量规则来实现先行发生关系。

示例三:对happen-before的注意事项

一个操作“时间上的先发生”不代表这个操作会是“先行发生”,并且一个操作“先行发生”不代表这个操作会是“时间上的先发生”呢。针对第二条,如下代码

//代码定义
int i = 1;
int j = 2;
//运行顺序
int j = 2;
int i = 1;

Java线程模型

并发不一定要依赖多线程(如PHP中很常见的多进程并发)。

线程的实现

关于线程实现,OS 线程 | 用户级和内核级线程有详细的介绍。

对于 Sun JDK 来说,它的 Windows 版与 Linux 版都是使用一对一的线程模型实现的,一条 Java 线程就映射到一条轻量级进程之中,因为 Windows 和 Linux 系统提供的线程模型就是一对一的。而在 Solaris 平台中,由于操作系统的线程特性可以同时支持一对一(通过 Bound Threads 或 Alternate Libthread 实现)及多对多(通过 LWP/Thread Based Synchronization 实现)的线程模型,因此在 Solaris 版的 JDK 中也对应提供了两个平台专有的虚拟机参数-XX:+UseLWPSynchronization(默认值)-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。

如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。

Lua 语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的 Windows 3.x 系统就是使用协同式来实现多进程多任务的,相当不稳定,一个进程坚持不让出 CPU 执行时间就可能会导致整个系统崩溃。

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在 Java 中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。与前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程“杀掉”,而不至于导致系统崩溃。

虽然 Java 线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点 —— 这项操作可以通过设置线程优先级来完成。Java 语言一共设置了 10 个级别的线程优先级(Thread.MIN_PRIORITYThread.MAX_PRIORITY),在两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

不过,线程优先级并不是太靠谱,原因是 Java 的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与 Java 线程的优先级一一对应,如 Solaris 中有 2147483648(2^32)种优先级,但 Windows 中就只有 7 种,比 Java 线程优先级多的系统还好说,中间留下点空位就可以了,但比 Java 线程优先级少的系统,就不得不出现几个优先级相同的情况了,

上文说到“线程优先级并不是太靠谱”,不仅仅是说在一些平台上不同的优先级实际会变得相同这一点,还有其他情况让我们能太依赖优先级:优先级可能会被系统自行改变例如,在 Windows 系统中存在一个称为“优先级推进器”(Priority Boosting,当然它以被关闭掉)的功能,它的大致作用就是当系统发现一个线程执行得特别“勤奋努力”的话,可能会越过线程优先级去为它分配执行时间。因此,我们不能在程序中通过优先级来完全准确地判断一组状态都为 Ready 的线程将会先执行哪一个。

状态转换

Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这 5 种状态分别如下。

image-20210807191730335

  • 新建(New):创建后尚未启动的线程处于新建状态。
  • 运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready。也就是说,处于此状态的线程有可能正在执行(Running),也有可能正在等待着 CPU 为它分配执行时间(Ready)。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:
    • Object.wait()方法、Thread.join()方法、
    • LockSupport.park()方法
    • I/O 阻塞
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配 CPU 执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
    • Thread.sleep(timeout)方法、Object.wait(timeout)方法、Thread.join(timeout)方法
    • LockSupport.parkNanos()方法、LockSupport.parkUntil()方法
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个 Monitor Lock,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

线程锁与队列

每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程。一个线程被唤醒后,才会进入就绪队列,等待 CPU 的调度;反之,一个线程被 wait 后,就会进入阻塞队列,等待下一次被唤醒。

内存语义

锁释放和获取的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。

对比锁释放 - 获取的内存语义与 volatile 写 - 读的内存语义,可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

CAS内存语义

compareAndSet()方法调用简称为 CAS。JDK 文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。

编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作重排序。

下面分析常见的 intel x86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。

//sun.misc.Unsafe 类的 compareAndSwapInt() 方法
public final native boolean compareAndSwapInt(Object o, long offset,
                                             int expected,
                                             int x);

可以看到这是个本地方法调用。这个本地方法在 openjdk 中依次调用的 c++ 代码为:unsafe.cppatomic.cppatomic_windows_x86.inline.hpp

这个本地方法的最终实现在 openjdk 的如下位置:openjdk-7-fcs-src-b147-27_jun_2011\openjdk\hotspot\src\os_cpu\windows_x86\vm\atomic_windows_x86.inline.hpp(对应于 windows 操作系统,X86 处理器)。下面是对应于 intel x86 处理器的源代码的片段:

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                      __asm je L0      \
                      __asm _emit 0xF0 \
                      __asm L0:
 
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint     compare_value) {
 // alternative for InterlockedCompareExchange
 int mp = os::is_MP();
 __asm {
   mov edx, dest
   mov ecx, exchange_value
   mov eax, compare_value
   LOCK_IF_MP(mp)
   cmpxchg dword ptr [edx], ecx
 }
}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要 lock 前缀提供的内存屏障效果)。

intel 的手册对 lock 前缀的说明如下:

  1. 确保对内存的读 - 改 - 写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4,Intel Xeon 及 P6 处理器开始,intel 在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在 lock 前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读 / 写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低 lock 前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义。

经过上面的分析,已经介绍清楚为什么 JDK 文档说 CAS 同时具有 volatile 读和 volatile 写的内存语义了。

现在对公平锁和非公平锁的内存语义做个总结:

  • 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
  • 公平锁获取时,首先会去读这个 volatile 变量。
  • 非公平锁获取时,首先会用 CAS 更新这个 volatile 变量, 这个操作同时具有 volatile 读和 volatile 写的内存语义。

锁释放 - 获取的内存语义的实现至少有下面两种方式:

  1. 利用 volatile 变量的写 - 读所具有的内存语义。
  2. 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

CAS 的 ABA 问题

CAS 可以认为它是一种乐观锁,其实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差类会导致数据的变化。

假设 X 链表为 HEAD->A->B->A,只一次 CAS (A->D) 的话,结果应为 HEAD->A->B->D。但如果中间被其他线程抢占,如被删除尾部两个节点,现在 X 链表为 HEAD->A,再进行一次 CAS (A->D) 结果为 HEAD->D。

尽管操作是原子且正确的,但不代表过程没有问题,其造成的结果是不正确的。

对ABA问题的解决

新增版本号机制,对每次修改版本号自增,对比版本号即可知道是否在执行过程中操作对象发生了变化。

volatile内存语义

重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下面是 JMM 针对编译器制定的 volatile 重排序规则表:

从上表我们可以看出:

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

volatile写内存屏障

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:

上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:

上图中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

 class VolatileBarrierExample {
   int a;
   volatile int v1 = 1;
   volatile int v2 = 2;
 
   void readAndWrite() {
       int i = v1;           // 第一个 volatile 读
       int j = v2;           // 第二个 volatile 读
       a = i + j;            // 普通写
       v1 = i + 1;           // 第一个 volatile 写
       v2 = j * 2;           // 第二个 volatile 写
   }// 其他方法
}

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化:

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个 StoreLoad 屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 x86 处理器为例,上图中除最后的 StoreLoad 屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86 处理器平台可以优化成:

前文提到过,x86 处理器仅会对写 - 读操作做重排序。X86 不会对读 - 读,读 - 写和写 - 写操作做重排序,因此在 x86 处理器中会省略掉这三种操作类型对应的内存屏障。在 x86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存语义。这意味着在 x86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)。

JSR-133 为什么要增强 volatile 的内存语义

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。

因此在旧的内存模型中 ,volatile 的写 - 读没有监视器的释放 - 获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写 - 读和监视器的释放 - 获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读 / 写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替监视器锁,请一定谨慎。

final内存语义

final的重排序规则

与前面介绍的锁和 volatile 相比较,对 final 域的读和写更像是普通的变量访问。对于 final 域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含 final 域的对象的引用,与随后初次读这个对象的 final 域,这两个操作之间不能重排序。
public class FinalExample {
   int i;                            // 普通变量
   final int j;                      //final 变量
   static FinalExample obj;
 
   public void FinalExample () {     // 构造函数
       i = 1;                        // 写普通域
       j = 2;                        // 写 final 域
   }
 
   public static void writer () {    // 写线程 A 执行
       obj = new FinalExample ();
   }
 
   public static void reader () {       // 读线程 B 执行
       FinalExample object = obj;       // 读对象引用
       int a = object.i;                // 读普通域
       int b = object.j;                // 读 final 域
   }
}

这里假设一个线程 A 执行 writer () 方法,随后另一个线程 B 执行 reader () 方法。下面我们通过这两个线程的交互来说明两个重排序规则。

写final域的重排序规则

写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

现在让我们分析writer()方法。writer()方法只包含一行代码:finalExample = new FinalExample()。这行代码包含两个步骤:

  1. 构造一个 FinalExample 类型的对象;
  2. 把这个对象的引用赋值给引用变量 obj。

假设线程 B 读对象引用与读对象的成员域之间没有重排序,下图是一种可能的执行时序:

在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,读线程 B 正确的读取了 final 变量初始化之后的值。

写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B“看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域 i 的写操作被重排序到构造函数外,此时初始值 2 还没有写入普通域 i)。

读final域的重排序规则

读 final 域的重排序规则如下:

在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。

reader() 方法包含三个操作:

  1. 初次读引用变量 obj。
  2. 初次读引用变量 obj 指向对象的普通域 j。
  3. 初次读引用变量 obj 指向对象的 final 域 i。

现在我们假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。

读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。

final域是引用类型

public class FinalReferenceExample {
    final int[] intArray;                     //final 是引用类型
    static FinalReferenceExample obj;

    public FinalReferenceExample () {        // 构造函数
       intArray = new int[1];              //1
       intArray[0] = 1;                   //2
    }

    public static void writerOne () {          // 写线程 A 执行
       obj = new FinalReferenceExample ();  //3
    }

    public static void writerTwo () {          // 写线程 B 执行
       obj.intArray[0] = 2;                 //4
    }

    public static void reader () {              // 读线程 C 执行
       if (obj != null) {                    //5
           int temp1 = obj.intArray[0];       //6
       }
    }
}

这里 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

对上面的示例程序,我们假设首先线程 A 执行writerOne()方法,执行完后线程 B 执行writerTwo()方法,执行完后线程 C 执行reader()方法。下面是一种可能的线程执行时序:

在上图中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lock 或 volatile)来确保内存可见性。

为什么final引用不能从构造函数内“逸出”

前面我们提到过,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面示例代码:

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample () {
       i = 1;                              //1 写 final 域
       obj = this;                          //2 this 引用在此“逸出”
    }

    public static void writer() {
       new FinalReferenceEscapeExample ();
    }

    public static void reader {
       if (obj != null) {                     //3
           int temp = obj.i;                 //4
       }
    }
}

假设一个线程 A 执行writer()方法,另一个线程 B 执行reader()方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且即使在程序中操作 2 排在操作 1 后面,执行read()方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如下图所示:

从上图我们可以看出:在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。

final 语义在处理器中的实现

现在我们以 x86 处理器为例,说明 final 语义在处理器中的具体实现。

上面我们提到,写 final 域的重排序规则会要求译编器在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 障屏。读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。

由于 x86 处理器不会对写 - 写操作做重排序,所以在 x86 处理器中,写 final 域需要的 StoreStore 屏障会被省略掉。同样,由于 x86 处理器不会对存在间接依赖关系的操作做重排序,所以在 x86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。也就是说在 x86 处理器中,final 域的读 / 写不会插入任何内存屏障!

JSR-133 为什么要增强 final 的语义

在旧的 Java 内存模型中 ,最严重的一个缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整形 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为了 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变(示例链接)。

为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用),就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。

扩展

处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  1. 放松程序中写 - 读操作的顺序,由此产生了Total Store Ordering内存模型(简称为 TSO)。
  2. 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了Partial Store Order内存模型(简称为 PSO)。
  3. 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了Relaxed Memory Order内存模型(简称为 RMO)和 PowerPC 内存模型。

注意,这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称 对应的处理器 Store-Load 重排序 Store-Store 重排序 Load-Load 和 Load-Store 重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序。因为它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。

上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

由于常见的处理器内存模型比 JMM 要弱,Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:

如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 Java 程序员呈现了一个一致的内存模型。

语言内存模型,处理器内存模型和顺序一致性内存模型的对比

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

从上图我们可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。