Java内存模型(十二)

来源:互联网 发布:淘宝十大男模特 编辑:程序博客网 时间:2024/05/21 10:48

12.1 硬件的效率与一致性

计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的告诉缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等到缓慢的内存读写了。

基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(Cache Coherence)。在对各处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory)。当多个处理器运算任务都涉及同一块祝内存区域时,将可能导致各自的缓存数据不一致的情况,为了解决已执行问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作:这类协议:MSI、MESI等等。Java虚拟机的内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
这里写图片描述
除此之外,为了使得处理器内部的运算单元尽可能充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序保证。与处理器的乱序执行优化类似,Java虚拟机的即使编译器中也有类似的指令重排序(Instruction Recorder)优化。

寄存器(register)与缓存的区别
按与CPU远近来分,离得最近的是寄存器,然后缓存,最后内存。
寄存器是最贴近CPU的,而且CPU只与寄存器中进行存取。
而寄存器的数据又来源于内存。于是 CPU<—>寄存器<—–>内存 这就是它们之间的信息交换。
因为如果老是操作内存中的同一址地的数据,就会影响速度。于是就在寄存器与内存之间设置一个缓存。缓存就把从内存提取的数据暂时保存在里面,如果寄存器要取内存中同一位置的东西,就不用老远巴巴地跑到内存中去取,直接从缓存中提取。


什么是内存模型
在处理器层面上,内存模型定义了一个充要条件,“让当前的处理器可以看到其他处理器写入到内存的数据”以及“其他处理器可以看到当前处理器写入到内存的数据”。有些处理器有很强的内存模型(strong memory model),能够让所有的处理器在任何时候任何指定的内存地址上都可以看到完全相同的值。而另外一些处理器则有较弱的内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。这些内存屏障通常在lock和unlock操作的时候完成。内存屏障在高级语言中对程序员是不可见的。

“一个线程的写操作对其他线程可见”这个问题是因为编译器对代码进行重排序导致的。例如,只要代码移动不会改变程序的语义,当编译器认为程序中移动一个写操作到后面会更有效的时候,编译器就会对代码进行移动。如果编译器推迟执行一个操作,其他线程可能在这个操作执行完之前都不会看到该操作的结果,这反映了缓存的影响。

此外,写入内存的操作能够被移动到程序里更前的时候。在这种情况下,其他的线程在程序中可能看到一个比它实际发生更早的写操作。所有的这些灵活性的设计是为了通过给编译器,运行时或硬件灵活性使其能在最佳顺序的情况下来执行操作。在内存模型的限定之内,我们能够获取到更高的性能。

12.1.1 并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

12.2 Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。在此之前,主流程序语言(C/C++等)直接使用物理硬件(或者说操作系统的内存模型),因此,会由于不同瓶体上内存模型的差异,导致程序在一套平台上并发完全正常,二在另外一套平台上并发访问却经常出错。

Java内存模型
描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量(实例域、静态域和数组元素)“ 和 ”从内存或者寄存器获取或存储它们的底层细节”之间的关系。Java内存模型通过使用各种各样的硬件和编译器的优化来正确实现以上事情。

对象最终存储在内存中,但编译器、运行库、处理器或缓存可以有特权定时地在变量的指定内存位置存入或取出变量值。例如,编译器为了优化一个循环索引变量,可能会选择把它存储到一个寄存器中,或者缓存会延迟到一个更适合的时间,才把一个新的变量值存入主存。所有的这些优化是为了帮助实现更高的性能,通常这对于用户来说是透明的,但是对多处理系统来说,这些复杂的事情可能有时会完全显现出来。

JMM 允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员已经使用 synchronized 或 final 明确地请求了某些可见性保证。这意味着在缺乏同步的情况下,从不同的线程角度来看,内存的操作是以不同的次序发生的。

Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面都能正确的运行。

大部分其他的语言,像C和C++,都没有被设计成直接支持多线程。这些语言对于发生在编译器和处理器平台架构的重排序行为的保护机制会严重的依赖于程序中所使用的线程库(例如pthreads),编译器,以及代码所运行的平台所提供的保障。

JSR133
JSR133的目的是创建一组正式语义,这些正式语义提供了volatile、synchronzied和final如何工作的直观框架。

12.2.1 主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variable)包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,不存在竞争问题。为了获得较好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码执行顺序这类权利。

Java内存模型规定了所有变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器告诉缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者交互如下:
这里写图片描述

主内存主要对应于Java堆中对象的实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更底层来说,主内存就是硬件的内存,二为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。

12.2.2 内存间交互操作

主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,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 a、load b。除此之外,Java内存模型还规定了在执行上述八种基本操作时必须满足如下规则:

  • 不允许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操作)。

12.2.3 从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
这里写图片描述

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

12.2.4 并发编程模型的分类

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

看如下:
这里写图片描述

假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。

这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

常见的处理器允许的重排序类型:
这里写图片描述

可见常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。

12.2.5 对于volatile型变量的特殊规则

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

volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都需要先刷新,执行引擎看不到不一致的情况,因此可认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用sunchronized或java.util.concurrent中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够能保证只有单一线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

volatile的屏蔽指令重排序能使得语义执行正确。volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些。因为它需要在本地代买中插入许多内存屏障指令来保证处理器不发生乱序执行。我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。

锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性。
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

12.2.5.1 volatile写-读的内存语义

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

class VolatileExample{    int a = 0;    volatile boolean flag = false;    public void writer(){         a = 1;         //1         flag = true;   //2    }    public void reader(){        if(flag){       //3             int i = a; //4             ......        }    }}

假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。

下图是线程A执行volatile写后,共享内存的状态示意图:

这里写图片描述

线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下图为线程B读同一个volatile变量后,共享变量的状态示意图。
这里写图片描述

如图所示,在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。

如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

下面对volatile写和volatile读的内存语义做个总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • ·线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

12.2.5.2 volatile的内存语义的实现

volatile重排序规则表:
这里写图片描述
可以看出:

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

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

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

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

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

StoreLoad屏障作用是避免volatile写与后面可能有的volatile读/写操作重排序。

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

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

在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

12.2.5.3 内存屏障

编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要专门做什么处理,就可以保证正确的执行顺序。但在多核处理器上通常需要使用内存屏障指令来确保这种一致性。即使编译器优化掉了一个字段访问(例如,因为一个读入的值未被使用),这种情况下还是需要产生内存屏障,就好像这个访问仍然需要保护。

内存屏障仅仅与内存模型中“获取”、“释放”这些高层次概念有间接的关系。内存屏障并不是“同步屏障”,内存屏障也与在一些垃圾回收机制中“写屏障(write barriers)”的概念无关。内存屏障指令仅仅直接控制CPU与其缓存之间,CPU与其准备将数据写入主存或者写入等待读取、预测指令执行的缓冲中的写缓冲之间的相互操作。这些操作可能导致缓冲、主内存和其他处理器做进一步的交互。但在JAVA内存模型规范中,没有强制处理器之间的交互方式,只要数据最终变为全局可用,就是说在所有处理器中可见,并当这些数据可见时可以获取它们。

几乎所有的处理器至少支持一种粗粒度的屏障指令,通常被称为“栅栏(Fence)”,它保证在栅栏前初始化的load和store指令,能够严格有序的在栅栏后的load和store指令之前执行。无论在何种处理器上,这几乎都是最耗时的操作之一(与原子指令差不多,甚至更消耗资源),所以大部分处理器支持更细粒度的屏障指令。

内存屏障的一个特性是将它们运用于内存之间的访问。尽管在一些处理器上有一些名为屏障的指令,但是正确的/最好的屏障使用取决于内存访问的类型。下面是一些屏障指令的通常分类,正好它们可以对应上常用处理器上的特定指令(有时这些指令不会导致操作)。

LoadLoad 屏障
序列:Load1,Loadload,Load2
确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

StoreStore 屏障
序列:Store1,StoreStore,Store2
确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

LoadStore 屏障
序列: Load1; LoadStore; Store2
确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

StoreLoad 屏障
序列: Store1; StoreLoad; Load2
确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。

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

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

如果有多个进程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但是允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。

12.2.7 原子性、可见性与有序性

java内存模型是围绕着并发过程特征中如何处理原子性、可见性和有序性这三个特征来建立的。

原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,大致可以认为基本数据类型的访问读写是具备原子性的。

字节码指令monitorenter和monitorexit隐式的使用这两个操作标示lock和unlock,这两个字节码指令反映到Java代码中就是同步块–synchronized关键字。

可见性(Visibility):可见性就是当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

除了volatile外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行sotre和wirte操作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看到final字段的值。

有序性(Ordering):如果在本线程内观察,所有的操作都是有序的:如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义。而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

synchronized关键字在需要这三种特性的时候都可以作为其中一种的解决方案。

12.2.8 先行发生原则(happens-before)

12.2.8.1 JMM的设计

设计JMM时需要考虑两个关键因素:

  • 程序员对内存模型的使用。易于理解、易于编程。
  • 编译器和处理器对内存模型的实现。内存模型对编译器和处理器的限制越少越好。

JMM对编译器和处理器的束缚已经尽可能少。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当做一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

JMM的设计示意图:
这里写图片描述

12.2.8.1 happens-before定义

Java中有一个“先发生”(happens-before)原则。它是判断数据是否存在竞争,线程是否安全的主要依据。

JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先发生于B,就是说在发生B之前,A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

任何内存操作,这个内存操作在退出一个同步块前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块后都是可见的,因为所有内存操作happens before释放监视器以及释放监视器happens before获取监视器。

happens-before关系本质上和as-if-serial语义是一回事:

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

12.2.8.3 happens-before规则

如果两个操作之间不在如下的规则内,则就没有顺序性保障,虚拟机可以对它们随意的重排序。

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

Java语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些。一个操作“时间上的先发生”不代表这个操作会是“先行发生”。一个操作“先行发生”也无法推导出这个操作必定是“时间上的先发生”。

同步
同步有几个方面的作用。最广为人知的就是互斥 ——一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。

依据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。

12.2.9 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

重排序是手段,是能力;
happens-before是原则;
as-if-serial是规定依赖关系的重排序规则。

12.2.9.1 数据依赖性

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

这里写图片描述

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

12.2.9.2 as-if-serial语义

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

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

12.2.10 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

12.2.10.1 数据竞争与顺序一致性

当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下。在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语
(synchronized、volatile和final)的正确使用。

12.2.10.2 顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是:A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是:B1→B2→B3。

假设这两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。
这里写图片描述

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的:
这里写图片描述

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

12.2.10.3 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取
到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。
1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
3)JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

第3个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事
务。在在一个处理器执行总线事务期间,总线会禁止其他的处理器和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位变量的写操作将不具有原子性。

12.2.11 锁的内存语义

12.2.11.1 锁的释放-获取的内存语义

锁除了让临界区呼哧执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。线程A在释放锁之前所有课件的共享变量,在线程B获取同一个锁之后,将立即变得对B线程可见。如下代码,A执行writer()方法,B执行reader()方法。

class MonitorExample{    int a = 0;    public synchronized void writer(){ //1        a++;                           //2    }                                  //3    public synchronized void reader(){ //4        int i = a;                     //5        ...                                }                                  //6}

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

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

锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

对锁释放和锁获取的内存语义的总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

12.2.11.2 锁内存语义的实现

借助ReentrantLock源码,来分析锁内存语义的具体实现机制。

示例代码:

class ReentrantLockExample{    int a = 0;    ReentrantLock lock = new ReentrantLock();    public void writer(){        lock.lock();  //获取锁        try{             a++;        }finally{             lock.unlock();//释放锁        }    }    public void reader(){        lock.lock();        try{             int i = a;             ...        }finally{             lock.unlock();        }    }}

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer,使用一个整型的volatile变量(state)来维护同步状态。

ReentrantLock的类图如下:

这里写图片描述

ReentrantLock分为公平锁和非公平锁。

使用公平锁时,加锁方法lock()调用轨迹如下:
1) ReentrantLock:lock()
2) FairSync:lock()
3) AbstractQueuedSynchronizer:acquire(int arg)
4) ReentrantLock:tryAcquire(int acquires)

在第4步真正开始加锁,下面是该方法的源代码:

protected final boolean tryAcquire(int acquires){    final Thread current = Thread.currentThread();    int c = getState(); //获取锁的开始,首先读volatile变量state    if(c == 0){        if(isFirst(current) &&           compareAndSetState(0,acquires)){               setExclusiveOwnerThread(current);               return true;           }        }    }else if(current == getExclusiceOwnerThread()){        int nextc = c + acquires;        if(nextc < 0)           throw new Error("Maximum lock count exceeded");        setState(nextc);        return true;    }    return false;}

加锁方法首先读volatile变量state。

在使用公平锁时,解锁方法unlock()调用轨迹如下:
1) ReentrantLock:unlock();
2) AbstractQueuedSynchronizer:release(int arg)
3) Sync:tryRelease(int release)

第3步开始真正释放锁,该方法代码:

protected final boolean tryRelease(int releases){    int c = getState() - releases;    if(Thread.currentThread() != getExclusiveOwnerThread())       throw new IllegalMonitorStateException();    boolean free = false;    if(c == 0){       free = true;       setExclusiveOwnerThread(null);    }    setState(c); //释放锁的最后,写volatile变量state    return free;}

释放锁的最后写volatile变量state。

公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的下层读取同一个volatile变量后立即变得对获取锁的线程可见。

非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取,使用非公平锁时,加锁方法lock()调用轨迹:
1)ReentrantLock:lock()。
2)NonfairSync:lock()。
3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。

第3步真正开始加锁,源代码:

protected final boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);}

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

这里我们分别从编译器和处理器的角度来分析,CAS如何同时具有volatile读和volatile写的内存语义。

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

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

对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式:
1)利用volatile变量的写-读所具有的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。

12.2.11.3 concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面4种方式。

1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器
都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。

首先,声明共享变量为volatile。
然后,使用CAS的原子条件更新来实现线程之间的同步。
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图:
这里写图片描述

12.2.12 final域的内存语义

12.2.12.1 final域的重排序规则

对于final域,编译器和处理器都要遵守两个重排序规则。
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

public class FinalExample{    int i;                //普通变量    final int j;          //final变量    static FinalExample obj;    public 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方法。

12.2.12.2 写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2各方面:
1)JMM禁止编译器把final域的写重排序到构造函数之外。
2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

writer()方法:obj= new FinalExample();这行代码包含两个步骤:
1)构造一个FinalExample类型的对象。
2)把这个对象的引用赋值给引用变量obj。

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

这里写图片描述

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

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

12.2.12.3 读final域的重排序规则

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

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

reader()方法包含3个操作:
1)初次读引用变量obj。
2)初次读引用变量obj指向对象的普通域j。
3)初次读引用变量obj指向对象的final域i。

现在假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,则一种可能的执行时序:
这里写图片描述

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

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

12.2.12.4 final域为引用类型

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造器函数内对一个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        }    }}

假设首先线程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之间存在数据竞争,此时的执行结果不可预知。

12.2.12.5 为什么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域正确初始化之后的值。

12.2.13 双重检查锁定与延迟初始化

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定(Double-Checked Locking)是常见的延迟初始化技术,但它是一个错误的用法。

12.2.13.1 双重检查锁定的由来

public class SafeLazyInitialization{    private static Instance instance;    public synchronized static Instance getInstance(){        if(instance == null)             instance = new Instance();        return instance;    }}

synchronized将导致性能开销。如果getInstance()方法不会被多个线程频繁调用,那么这个延迟初始化方案将能提供令人满意的性能。

为了解决synchronized的性能开销问题,如下的方式应运而生。
双重检查锁定的实现:

public class DoubleCheckedLocking{//1    private static Instance instance;//2     public static Instance getInstance(){//3        if(instance == null){//4:第一次检查            synchronized(DoubleCheckedLocking.class){//5:加锁                if(instance == null){//6:第二次检查                    instance = new Instance();//7:问题的根源出在这里                }            }//8        }//9        return instance;//10    }//11}

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行,代码读取到instance不为null时,instance引用对象有可能还没有初始化完成。

12.2.13.2 问题的根源

双重检查锁定示例代码的第7行(instance = new Singleton();)创建了一个对象。这行代码可以分解为如下3行伪代码:

memory = allocate(); //1:分配对象的内存空间ctorInstance(memory);//2:初始化对象instance = memory;   //3:设置instance指向刚分配的内存地址

上面3行为代码中的2和3之间,可能会被重排序(一些JIT编译器上),如下:

memory = allocate(); //1:分配对象的内存空间instance = memory;   //3:设置instance指向刚分配的内存地址                       注意,此时对象还没有被初始化ctorInstance(memory);//2:初始化对象

2和3虽然被重排序了,但是没有违反intra-thread semantics(保证重排序不会改变单线程内的程序结果执行)。

这里写图片描述

由于单线程内要遵守intra-thread semantics。从而能保证A线程的执行结果不会被改变。但是如下的多线程实例,B线程将看到一个哈没有被初始化的对象:
这里写图片描述

回到DoubleCheckedLocking实例代码第7行(instance = new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象还没有被A线程初始化。

这里写图片描述

知晓根源后,可以想出2个办法来实现线程安全的延迟初始化。
1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序。

12.2.13.3 基于volatile的解决方案

public class SafeDoubleCheckedLocking{    private volatile static Instance instance;    public static Instance getInstance(){        if(instance == null){             synchronized (SafeDoubleCheckedLocking.class){                if(instance == null)                    instance = new Instance();            }        }        return instance;    }}

这个解决方案需要从JDK1.5或更高版本。

当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。

12.2.13.3 基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案(Initialization On Demand Holder idiom)。

public class InstanceFactory{    private static class InstanceHolder{        public static Instance instance  = new Instance();    }    public static Instance getInstance(){        return InstanceHolder.instance;//这里将导致InstanceHolder类被初始化    }}

假设两个线程并发执行getInstance()方法,下面是执行的示意图:

这里写图片描述

这个方案的实质是:允许2和3的重排序,但不允许非构造线程“看到”这个重排序。

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。在首次发生下列任一一种情况时,一个类或接口类型T将被立即初始化:

1)T是一个类,而且T类型的实例被创建。
2)T是一个类,且T中声明的一个静态方法被调用。
3)T中声明的一个静态字段被赋值。
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
5)T是一个顶级类(Top Level Class),而且一个断言语句嵌套在T内部被执行。

由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口。因此,在Java中初始化一个类或者接口时,需要做细致的同步处理。

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

基于类初始化的方案实现代码更简洁,但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。

12.3 Java内存模型综述

12.3.1 处理器的内存模型

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时会以顺序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放松。

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

12.3.2 JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为下列3类。

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该线程在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序莱维程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

这里写图片描述

最小安全性保障与64位数据的非原子性写并不矛盾。最小安全性“发生”在对象被任意线程使用之前。64位数据的非原子写“发生”在对象被多个线程使用的过程中(写共享变量)。

12.3.3 JSR-133对旧内存模型的修补

JSR-133对JDK5之前的旧内存模型的修补主要有两个。

  • 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile得写-读和锁的释放-获取具有相同的内存语义。
  • 增强了final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性。
0 0
原创粉丝点击