1、前言程序员
通过前面的学习,咱们终于进入了虚拟机最后一部分的学习,内存模型。理解内存模型对咱们理解虚拟机、正确使用多线程编程提供很大帮助。下面开始正式学习。编程
2、Java并发基础数组
在并发编程中存在两个关键问题①线程之间如何通讯 ②线程之间如何同步。缓存
2.1 通讯安全
通讯是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递。多线程
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间经过写-读内存中的公共状态来隐式进行通讯。并发
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过明确的发送消息来显式进行通讯。app
2.2 同步函数
同步是指程序用于控制不一样线程之间操做发生相对顺序的机制。性能
在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码须要在线程之间互斥访问。
在消息传递的并发模型里,因为消息的发送必须在消息的接收以前, 所以同步是隐式进行的。
Java并发采用的是共享内存模型,通讯隐式进行;同步显示指定。
3、Java内存模型
Java内存模型JMM(Java Memory Model)主要目标是定义程序中各个变量(非线程私有)的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。Java中每一个线程都有本身私有的工做内存。工做内存保存了被该线程使用的变量的主内存副本拷贝,线程对变量的读写操做都必须在工做内存进行,没法直接读写主内存中的变量。两个线程没法直接访问对方的工做内存。
3.1 线程、工做内存、内存关系
理解线程、主内存、工做内存之间的关系时,咱们能够类比物理机中CPU、高速缓存、内存之间关系,学过计算机组成原理,咱们知道CPU、高速缓存、内存之间的关系以下
线程、主内存、工做内存的关系图以下
说明:线程的工做内存能够类比高速缓存,JMM控能够类比缓存一致性协议,是工做内存与主内存进行信息交换的具体协议。若线程A要与线程B通讯(访问变量)。首先,线程A把工做线程中的共享变量刷新到主内存中。而后,线程B从主内存读取更新过的变量。
3.2 内存间通讯的指令
内存见通讯,主要指线程私有的工做内存与主内存之间的通讯,如线程间共享变量的传递。主要有以下操做。
说明:①变量从主内存放入工做内存变量副本中实际是分为两步的,第一步是先把主内存的值放在工做内存中,此时尚未放入变量副本中;第二部把已经放在工做内存的值放入变量副本中。相反,变量副本从工做内存到主内存也是分为两步,与前面相似,再也不累赘。总之,两个内存空间的变量值的传递须要两个操做才能完成,这样作是为了提升cpu的效率,不等待主内存写入完成。②read、load操做;store、write操做必须按顺序执行(并不是连续执行)。
上述的8个操做须要知足以下规则
3.3 重排序
在执行程序时为了提升性能,编译器和处理器经常会对指令作重排序。重排序会遵照数据的依赖性,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序。重排序分为以下三种类型。
1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。
2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序。
3. 内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操 做看上去多是在乱序执行。
而咱们编写的Java源程序中的语句顺序并不对应指令中的相应顺序,如(int a = 0; int b = 0;翻译成机器指令后并不能保证a = 0操做在b = 0操做以前)。由于编译器、处理器会对指令进行重排序,一般而言,Java源程序变成最后的机器执行指令会通过以下的重排序。
说明:①编译器优化重排序属于编译器重排序,指令级并行重排序、内存系统重排序属于处理器重排序。②这些重排序可能会致使多线程程序出现内存可见性问题。③JMM编译器重排序规则会禁止特定类型的编译器重排序。④JMM的处理器重排序会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,经过内存屏障指令来禁止特定类型的处理器重排序。
下面一个例子说明了重排序
下面是Process A与Process B与内存之间的交互图
对于最后的结果x = y = 0而言,从内存的角度看,整个指令序列可能以下,A2 -> B2 -> A1 -> B2 -> A3 -> B3。按照这样的指令排序,最后获得的结果就时x = y = 0。
说明:①从内存角度看,A1与A3操做一块儿才算是完成了a变量的写,A1操做是在处理器A的缓冲区中完成写,以后A3操做将缓冲区的变量值同步到内存中。②在程序中,A1发生在A2以前,然而实际的指令序列中,A2发生在A1以前,这就是发生了重排序,处理器操做内存的顺序发生了变化。同理,B1与B2指令也发生了重排序。
3.4 内存屏障指令
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。内存屏障指令分为以下四种
3.5 先行发生原则(happens before)
先行发生原则是判断数据是否存在竞争、线程是否安全的主要依据。若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必需要存在 happens-before 关系,如若是操做A先行发生与操做B,即A操做产生的结果可以被操做B观察到。
以下图示例
线程A 线程B
i = 3; j = i;
结果:j = 3;
说明:线程A中的i = 3先行发生于j = i;则结果必定是j = 3。
具体的happens-before原则以下
1. 程序顺序规则:一个线程中的每一个操做,happens- before 于该线程中的任意后续操做(控制流操做而不是程序代码顺序)。
2. 监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器的加锁。
3. volatile变量规则:对一个 volatile域的写,happens- before于任意后续对这个volatile域的读。
4. 线程启动规则:Thread对象的start()方法happens - before 于此线程的每个动做。
5. 线程终止规则:线程中全部操做都happens - before 于对此线程的终止检测。
6. 线程中断规则:对线程interrupt()方法的调用happens - before 于被中断线程的代码检测到中断事件的发生。
7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)happens - before 于它的finalize()方法的开始。
8. 传递性:若是 A happens- before B,且 B happens- before C,那么 A happens- before C。
说明:happens-before 仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前(可能会发生指令重排)。时间前后顺序与happens - before原则之间没有太大的关系。
3.6 as-if-serial语义
as-if-serial的语义是:无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵照 as-if-serial 语义。为了遵照 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操做作重排序,由于这种重排序会改变执行结果。可是,若是操做之间不存在数据依赖关系,这些操做就可能被编译器和处理器重排序。如以下代码片断
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
其中,操做A、B、C之间的依赖性以下
说明:A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。所以在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和 处理器能够重排序A和B之间的执行顺序。所以,最后的指令序列多是:
A -> B -> C;
B -> A -> C; // 重排序了
套用happens - before规则咱们能够知道:
A happens - before B; // 程序顺序规则
B happens - before C; // 程序顺序规则
A happens - before C; // 传递性
说明:A happens- before B,但实际执行时 B 却能够排在 A 以前执行(第二种指令执行顺序)。在前面咱们讲到,若是 A happens- before B,JMM 并不要求 A 必定要在 B 以前执行。JMM 仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前。这里操做 A 的执行结果不须要对操做 B 可见;并且重排序操做 A 和操做 B 后的执行结果,与操做 A 和操做 B 按 happens- before 顺序执行的结果一致。在这种状况下,JMM 会认为这种重排序并不非法,JMM 容许这种重排序。
对于单线程,JMM能够进行指令重排序,可是必定要遵照as-if-serial语义,这样才能保证单线程的正确性。
对于多线程而言,JMM的指令重排序可能会影响多线程程序的正确性。下面为多线程示例。
class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; //操做A flag = true; //操做B } public void reader() { if (flag) { //操做C int i = a * a; //操做D } } }
说明:变量flag用于标识变量a是否已经写入。若线程A先执行writer函数,而后线程B执行reader函数。由happens - before 规则(程序顺序规则)咱们知道,操做A happens - before B,可是因为操做A与操做B之间没有数据依赖,因此能够进行重排序。同理,操做C与操做D之间也无数据依赖关系(但存在控制依赖关系,JMM容许对存在数据依赖的指令进行重排序),也可进行重排序。
下图展现了重排序操做A、操做B所可能产生的结果。
说明:假设重排序操做A、操做B,且操做C、操做D在操做A、操做B的中间,那么最后线程B的变量i的结果为0;flag为true,则对i进行写入。然而,此时的a还未写入,此时,重排序破坏了多线程的语义,最后写入的i值是不正确的。
下图展现了重排序操做C、操做D可能产生的执行结果。
说明:存在控制依赖的指令也会被重排序,控制依赖会影响并行度。temp变量的出现是由于编译器和处理器会采用猜想执行来克服控制相关性对并行度的影响,从图中咱们能够知道重排序破坏了多线程的语义,最后写入i的值是不正确的。
在单线程程序中,对存在控制依赖的操做重排序,不会改变执行结果(这也是 as- if-serial 语义容许对存在控制依赖的操做作重排序的缘由);但在多线程程序中,对存在控制依赖的操做重排序,可能会改变程序的执行结果。
在多线程中为了保证程序的正确性,咱们须要进行适当的同步,以保证正确的结果。
4、顺序一致性内存模型
顺序一致性内存模型时JMM的参考模型,它提供了很强的内存一致性与可见性,是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。JMM对正确同步的多线程程序的内存一致性作了以下保证
若是程序是正确同步的,程序的执行将具备顺序一致性(sequentially consistent)-- 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。立刻咱们将会看到,这对于程序员来讲是一个极强的保证。这里的同步是指广义上的同步,包括对经常使用同步原语(synchronized,volatile 和 final)的正确使用。
顺序一致性内存模型包括以下两个特性:
1. 一个线程中的全部操做必须按照程序的顺序来执行。
2. (无论程序是否同步)全部线程都只能看到一个单一的操做执行顺序。在顺序一致性内存模型中,每一个操做都必须原子执行且马上对全部线程可见(JMM中并不保证)。
顺序一致性内存模型为程序员提供的视图以下:
说明:每一时刻只有一个线程能够访问内存,当多个线程并发执行时,图中的开关装置能把全部线程的全部内存读/写操做串行化(即在顺序一致性模型中,全部操做之间具备全序关系)。下面这个例子更进一步阐明了在顺序一致性内存模型中各线程操做之间的关系。
假设线程A有A一、A二、A3三个操做,线程B有B一、B二、B3三个操做。
① 若使用监视器锁来进行同步,在A的三个操做完成后,释放监视器锁,以后B得到监视器锁,那么整个操做序列为:A1 -> A2 -> A3 -> B1 -> B2 -> B3。
② 若不使用监视器锁来进行同步,那么整个序列可能为:A1 -> B1 -> A2 -> B2 -> B3 -> A3。其中线程A、B的三个操做是有序的,而且线程A、B看到的操做序列都是同一操做序列,每一个操做都必须原子执行且马上对全部线程可见,可是总体的操做无序。
未同步程序在 JMM 中不但总体的执行顺序是无序的,并且全部线程看到的操做执行顺序也可能不一致。好比,在当前线程把写过的数据缓存在本地内存中,在尚未刷新到主内存以前,这个写操做仅对当前线程可见;从其余线程的角度来观察,会认为这个写操做根本尚未被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存以后,这个写操做才能对其余线程可见。在这种状况下,当前线程和其它线程看到的操做执行顺序将不一致,当前线程认为写数据到缓冲区就完成了写操做,其余线程认为只有数据刷新到主内存才算完成了写操做,因此就致使了线程之间看到的操做序列不相同。
顺序一致性内存模型是经过内存规则保证顺序一致性,顺序一致性是JMM追求的目标,可是JMM模型自己并不进行保证,必须经过适当的同步保证。
4.1 同步程序的执行特性
下面例子展现一个正确同步的程序在JMM和顺序一致性内存模型的操做序列。
class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer() { // 获取锁 a = 1; //操做A flag = true; //操做B } // 释放锁 public synchronized void reader() { // 获取锁 if (flag) { //操做C int i = a; //操做D } } // 释放锁 }
说明:线程A先执行writer方法,线程B执行reader方法。这是一个正确同步的程序,在JMM的操做序列与在顺序一致性模型的操做序列是相同的。
说明:JMM模型中容许临界区的操做重排序(即便有控制依赖),而顺序一致性内存模型中则按照程序顺序执行。线程 A 在临界区内作了重排序,但因为监视器的互斥执行的特性,这里的线程 B 根本没法“观察”到线程 A 在临界区内的重排序。这种重排序既提升了执行效率,又没有改变程序的执行结果。同时,JMM 会在退出临界区和进入临界区这两个关键时间点作一些特别处理,使得线程在这两个时间点具备与顺序一致性模型相同的内存视图。
从这里咱们能够看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽量的为编译器和处理器的优化打开方便之门。
4.2 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是以前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操做读取到的值不会无中生有的冒出来。为了 实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,而后才会在上面分配对象(JVM内部会同步这两个操做)。所以,在已清零的内存空间分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。由于若是想要保证执行结果一致,JMM须要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。并且未同步程序在顺序一致性模型中执行时,总体是无序的,其执行结果每每没法预知。保证未同步程序在这两个模型中的执行结果一致没什么意义。
未同步程序在 JMM 中的执行时,总体上是无序的,其执行结果没法预知。未同步程序在两个模型中的执行特性有下面几个差别:
① 顺序一致性模型保证单线程内的操做会按程序的顺序执行,而 JMM 不保证单线程内的操做会按程序的顺序执行(会进行重排序)。
② 顺序一致性模型保证全部线程只能看到一致的操做执行顺序,而 JMM 不保证全部线程能看到一致的操做执行顺序。
③ JMM不保证对 64 位的 long 型和 double 型变量的读/写操做具备原子性(JDK5以后的读具备原子性,写不具备),而顺序一致性模型保证对全部的内存读/写操做都具备原子性。
5、volatile型变量说明
关键字volatile是Java虚拟机提供的最轻量级的同步机制,当一个变量定义为volatile时,它将具有两种特性,可见性与禁止指令重排序优化。volatile一般会与synchronize关键字作对比。
① 可见性。当一条线程修改了这个变量的值,新值对于其余线程来讲是能够当即得到的,可是基于volatile变量的操做并非安全的(如自增操做),下面两种状况就不太适合使用volatile,而须要使用加锁(synchronize、原子类)来保证原子性。
1. 运算结果并不依赖变量的当前值,或者可以确保只有单一的线程修改变量的值。
2. 变量不须要与其余的状态变量共同参与不变约束。
② 禁止指令重排序优化。不容许对volatile操做指令进行重排序。
下面是是一个volatile的例子。
class VolatileFeaturesExample { volatile long vl = 0L; //使用 volatile 声明 64 位的 long 型变量 public void set(long l) { vl = l; //单个 volatile 变量的写 } public void getAndIncrement () { vl++; //复合(多个)volatile 变量的读/写 } public long get() { return vl; //单个 volatile 变量的读 } }
说明:上述使用volatile关键字的程序与下面使用synchronize关键字的程序效果等效。
class VolatileFeaturesExample { long vl = 0L; // 64 位的 long 型普通变量 public synchronized void set(long l) { //对单个的普通变量的写用同一个 vl = l; } public void getAndIncrement () { //普通方法调用 long temp = get(); //调用已同步的读方法 temp += 1L; //普通写操做 set(temp); //调用已同步的写方法 } public synchronized long get() { // 对单个的普通变量的读用同一个锁同步 return vl; } }
volatile变量的读写与锁的释放与获取相对应。读对应着锁的释放,写对应锁的获取。
5.1 volatile的happens - before关系
前面咱们知道happens - before 关系是保证内存可见性的重要依据。那么在volatile变量与happens - before 之间是什么关系呢,咱们经过一个示例说明
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方法,那么根据happens - before关系,咱们能够知道:
1. 根据程序顺序规则,1 happens before 2; 3 happens before 4。
2. 根据 volatile变量规则,2 happens before 3。
3. 根据 happens before 的传递性,1 happens before 4。
5.2 volatile读写内存语义
1. 读内存语义。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程以后将从主内存中读取共享变量。
2. 写内存语义。当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。这样就保证了volatile的内存可见性。
volatile读写内存语义总结为以下三条:
1. 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息。
2. 线程 B 读一个 volatile 变量,实质上是线程 B 接收了以前某个线程发出的(在写这个 volatile 变量以前对共享变量所作修改的)消息。
3. 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 经过主内存向线程 B 发送消息。
5.3 volatile内存语义的实现
前面讲到,volatile变量会禁止编译器、处理器重排序。下面是volatile具体的排序规则表
说明:从图中能够知道当第一个操做为volatile读时,不管第二个操做为什么种操做,都不容许重排序;当第二个操做为volatile写时,不管第一个操做为什么种操做,都不容许重排序;当第一个操做为volatile写时,第二个操做为volatile读时,不容许重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来讲,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采起保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:
1. 在每一个 volatile 写操做的前面插入一个 StoreStore 屏障。2. 在每一个 volatile 写操做的后面插入一个 StoreLoad 屏障(对volatile写、普通读写实现为不容许重排序,可能会影响性能)。
3. 在每一个 volatile 读操做的后面插入一个 LoadLoad 屏障。
4. 在每一个 volatile 读操做的后面插入一个 LoadStore 屏障(普通读写、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 写 } }
根据程序,最后的指令序列以下图所示
说明:编译器、处理器会根据上下文进行优化,并非彻底按照保守策略进行插入相应的屏障指令。
6、锁
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可让释放锁的线程向获取同一个锁的线程发送消息。
6.1 锁的happens - before 关系
下面一个示例展现了锁的使用
class MonitorExample { int a = 0; public synchronized void writer() { // 1 a++; // 2 } // 3 public synchronized void reader() { // 4 int i = a; // 5 } // 6 }
说明:假设线程 A 执行 writer()方法,随后线程 B 执行 reader()方法。该程序的happens - before关系以下:
1. 根据程序顺序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
2. 根据监视器锁规则,3 happens before 4。
3. 根据传递性,2 happens before 5。
图形化表示以下:
6.2 锁释放获取的内存语义
1. 当线程释放锁时,JMM会把该线程对应的工做内存中的共享变量刷新到主内存中,以确保以后的线程能够获取到最新的值。
2. 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必需要从主内存中去读取共享变量。
锁释放与获取总结为以下三条
1. 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出 了(线程 A 对共享变量所作修改的)消息。
2. 线程 B 获取一个锁,实质上是线程 B 接收了以前某个线程发出的(在释放这个 锁以前对共享变量所作修改的)消息。
3. 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 经过主内存 向线程 B 发送消息。
6.3 锁内存语义的实现
锁的内存语义的具体实现借助了volatile变量的内存语义的实现。
7、final
对于 final 域,编译器和处理器要遵照两个重排序规则:
1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操做之间不能重排序。
2. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操做之间不能重排序。
以下面示例展现了final两种重排序规则。
public final class FinalExample { final int i; public FinalExample() { i = 3; // 1 } public static void main(String[] args) { FinalExample fe = new FinalExample(); // 2 int ele = fe.i; // 3 } }
说明: 操做1与操做2符合重排序规则1,不能重排,操做2与操做3符合重排序规则2,不能重排。
由下面的示例咱们来具体理解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()方法。下面咱们经过这两个线程的交互来讲明这两个规则。
7.1 写final域重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数以外。这个规则的实 现包含下面两个方面:
1. JMM 禁止编译器把 final 域的写重排序到构造函数以外。
2. 编译器会在 final 域的写以后,构造函数 return 以前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数以外。
writer方法的obj = new FinalExample();其实包括两步,首先是在堆上分配一块内存空间简历FinalExample对象,而后将这个对象的地址赋值给obj引用。假设线程 B 读对象引用与读对象的成员域之间没有重排序,则可能的时序图以下
说明:写普通域的操做被编译器重排序到了构造函数以外,读线程 B 错误的读取了普通变量 i 初始化以前的值。而写 final 域的操做,被写 final 域的重排序规则 “限定”在了构造函数以内,读线程 B 正确的读取了 final 变量初始化以后的值。写 final 域的重排序规则能够确保:在对象引用为任意线程可见以前,对象的 final 域已经被正确初始化过了,而普通域不具备这个保障。以上图为例,在读线程 B “看到”对象引用 obj 时,极可能 obj 对象尚未构造完成(对普通域 i 的写操做被重排序到构造函数外,此时初始值 2 尚未写入普通域 i)。
7.2 读final域重排序规则
读 final 域的重排序规则以下:
在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操做(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操做的前面插入一个 LoadLoad 屏障。初次读对象引用与初次读该对象包含的 final 域,这两个操做之间存在间接依赖关系。因为编译器遵照间接依赖关系,所以编译器不会重排序这两个操做。大多数处理器也会遵照间接依赖,大多数处理器也不会重排序这两个操做。但有少数处理器容许对存在间接依赖关系的操做作重排序(好比 alpha 处理器),这个规则就是专门用来针对这种处理器。
reader方法包含三个操做:① 初次读引用变量 obj。② 初次读引用变量 obj 指向对象的普通域 i。③ 初次读引用变量 obj 指向对象的 final 域 j。假设写线程 A 没有发生任何重排序,同时程序在不遵照间接依赖的处理器上执行,下面是一种可能的执行时序:
7.3 final域是引用类型
public class FinalReferenceExample { final int[] intArray; // final 是引用类型 static FinalReferenceExample obj; public FinalReferenceExample () { // 构造函数 int Array = new int[1]; // 1 int Array[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 之间存在数据竞争,此时的执行结果不可预知。
若是想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间须要使用同步原语(lock 或 volatile)来确保内存可见性。
7.4 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 域正确初始化以后的值。
8、JMM总结
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时一般 会把顺序一致性内存模型做为参照。JMM和处理器内存模型在设计时会对顺序一 致性模型作一些放松,由于若是彻底按照顺序一致性模型来实现处理器和 JMM, 那么不少的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
8.1 JMM的happens- before规则
JMM 的happens - before规则要求禁止的重排序分为了下面两类:
1. 会改变程序执行结果的重排序。
2. 不会改变程序执行结果的重排序。
JMM 对这两种不一样性质的重排序,采起了不一样的策略:
1. 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
2. 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 容许这种重排序)
JMM的happens - before设计示意图以下
说明:从上图可知
1. JMM 向程序员提供的 happens- before 规则能知足程序员的需求。JMM 的 happens- before 规则不但简单易懂,并且也向程序员提供了足够强的内存可 见性保证(有些内存可见性保证其实并不必定真实存在,好比上面的 A happens- before B)。
2. JMM 对编译器和处理器的束缚已经尽量的少。从上面的分析咱们能够看出, JMM 实际上是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程 程序和正确同步的多线程程序),编译器和处理器怎么优化都行。好比,若是编译器通过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁能够被消除。再好比,若是编译器通过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器能够把这个 volatile 变量看成一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提升程序的执行效率。
8.2 JMM的内存可见性
Java 程序的内存可见性保证按程序类型能够分为下列三类:
1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
2. 正确同步的多线程程序。正确同步的多线程程序的执行将具备顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 经过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
3. 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是以前某个线程写入的值,要么是默认值(0,null, false)。
这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同以下
9、总结
这一篇的完结也意味着看完了整个JVM,明白了不少JVM底层的知识,读完后感受受益不浅,整个学习笔记有点厚,方便本身之后再精读。还有一个很深的感触就是,只有记下来的知识才是本身的,养成记录的好习惯,一步步变成高手。谢谢各位园友的观看~