【Java并发编程的艺术】【学习笔记】Java内存模型(JMM)

 2019-12-10 15:59  阅读(649)
文章分类:Java Core

1、Java内存模型(JMM)

1.1、线程通信机制

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

在共享内存的并发模型中,线程之间共享程序的公共状态,通过写—读内存中的公共状态进行隐式通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

JMM采用的就是共享内存的方式,Java线程之间的通信总是隐式进行。

1.2、内存模型

1.2.1、重排序

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

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

从Java源码到最终实际执行的指令序列,会分别经历下面3中重排序:

源代码 -> 1:编译器优化重排序 -> 2:指令集并行重排序 -> 3:内存系统重排序 -> 最终执行的指令序列

1属性编译器重排序,2和3属于处理器重排序。这些重排序会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。

对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

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

编译器和处理器在重排序时,会遵循数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖不被编译器和处理器考虑。因此,重排序在多线程环境下可能会导致数据不安全。

1.2.2、顺序一致性

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

当程序未正确同步的时候,就可能会存在数据竞争。Java内存模型规格对数据竞争的定义如下:

在一个线程中写一个变量,
在另一个线程中读这个变量,
写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。(这里的同步是指广义上的同步,包括对常用同步原语synchronized、volatile和final的正确使用)

顺序一致性内存模型有两大特性

  1. 一个线程中的所有操作必须按照程序的顺序来执行。
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摇摆的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

1.2.3、happens-before

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

与程序员密切相关的happens-before规则如下:

程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

监视器规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

注意两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行! happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作前

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

JMM设计

程序员对内存模型的使用,希望内存模型易于理解、易于编程,希望基于一个强内存模型来编写代码。

编译器和处理器对内存模型的实现,希望内存模型对它们的束缚越少越好,可以尽可能多的优化来提高性能,希望实现一个弱内存模型。

由于这两个因素相互矛盾,所以jsr-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制尽可能放松。

因此JMM把happens-before要求禁止的重排序分为两种:

  • 会改变程序结果的重排序,JMM要求编译器和处理器必须禁止

  • 不会改变程序结果的重排序,JMM不做要求。

    JMM在遵循一个基本原则:只要不改变程序的执行结果(单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都可以。

1.2.4、as-if-serial

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

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

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为写单线程的程序员产生了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

1.3、synchronized

在多线程并发中synchronized一直是元老级角色,称呼它为重量级锁。但是,Java1.6对synchronized进行了各种优化后,有些情况他就不那么重了。

synchronized实现同步的基础:java中的每一个对象都可以作为锁。

对于普通同步方法,锁是当前实例对象。

对于静态同步方法,锁是当前类的Class对象。

对于同步代码块,锁是Synchronized括号里配置的对象。

1.3.1、原理

从JVM规范中可以看到Synchonized对JVM里面的实现原理**,JVM基于进入和退出监视器(Monitor)对象来实现方法同步和代码块同步**,但是两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的。但是,方法的同步同样可以使用这个两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorexter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

monitorenter和monitorexit之间的区域被称为临界区,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

1.3.2、实现机制

synchroized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。

Java对象头的长度

| 长度|内容|说明| | :-----: | :-----: | :-----: | | 32/64bit | MarkWord | 存储对象的hashCode或锁信息等 | | 32/64bit | ClassMetadataAddress | 存储到对象类型数据的指针(KlassPointer) | | 32/64bit | Arraylength | 数组的长度(如果当前对象是数组) |

Java对象头里面的Mark Word里面默认存储对象的HashCode、分代年龄和锁标记位。

32位JVM的Mark Word的默认存储结构:

| 锁状态|25bit|4bit|1bit是否是偏向锁|2bit锁标志位| | :-----: | :-----: | :-----: | :-----: | :-----: | | 无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |

Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的数据,在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。

| 锁状态|25bit|4bit|1bit是否是偏向锁|2bit锁标志位| | :-----: | :-----: | :-----: | :-----: | :-----: | | 轻量级锁 | 指向栈中锁记录的指针(1) | 指向栈中锁记录的指针(2) | 指向栈中锁记录的指针(3) | 00 | | 重量级锁 | 指向互斥(重量级锁)的指针(1) | 指向互斥(重量级锁)的指针(2) | 指向互斥(重量级锁)的指针(3) | 10 | | GC标记 | 空 | 空 | 空 | 11 | | 偏向锁 | 线程ID | Epoch | 对象分待年龄 | 1 |

在64位JVM下,Mark Word是64bit大小的,其存储结构如下:

| 25bit|31bit|1bit|4bit|1bit|2bit| | :-----: | :-----: | :-----: | :-----: | :-----: | :-----: | | 锁状态 | | | cmsfree | 分代年龄 | 偏向锁 | | 无锁 | unused | hashCode | | | 0 | | 偏向锁 | ThreadID(54bit) | Epoch(2bit) | | | 1 |

1.3.3、锁优化

Java 1.6为了减少获得锁和释放锁带来的性能消耗,引入了”偏向锁“和”轻量级锁“,在Java 1.6中,锁一共有4中状态,级别东低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁(主要尽可能避免不必要的CAS操作,如果竞争锁失败,则升级为轻量级锁)。

当一个线程访问同步快并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁);如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

(1)偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不出于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向与其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

(2)关闭偏向锁

偏向锁在Java6和Java7里默认启用,但是它在应用程序启动几秒钟之后才激活,如果不有必要可以使用JVM参数来关闭延迟:-XX:BaiseLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁:

对于绝大部分的锁,在整个生命周期内部是不会存在竞争的。在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。通过CAS来获取锁和释放锁。缺点是在多线程环境下,其运行效率比重量级锁还要慢。

(1)轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁

自选锁。因为线程频繁挂起、唤醒负担较重,每个线程占用锁的时间很短,线程挂起再唤醒得不偿失。所以,该线程等待一段时间,不会被立即挂起,通过循环方式使用CAS方式,看持有锁的线程是否会很快释放锁。缺点是自旋次数无法确定。

适应性自旋锁。自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自旋成功,则可以增加自旋次数,如果获取锁经常失败,那么自旋次数会减少。

(2)轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。例如for循环内部获取锁。

锁消除:若不存在数据竞争的情况,JVM会消除锁机制。判断已经是,变量逃逸。

1.4、volatile

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的”可见性”。如果volatile变量修饰词使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度

如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

1.4.1、特性

volatile可见性:对一个volatile的读,总可以看到对这个变量最终的写

volatile原子性:volatile对单个读/写具有原子性,但是复合操作除外,例如i++.

1.4.2、操作系统语义

volatile是如何保证可见性的?

在X86处理器下,有volatile变量修饰的共享变量进行写操作的时候会多出Lock前缀的指令。通过IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。

    Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存(因为它会锁住总线,导致其他CPU不能访问总线)。目前的处理器里,LOCK#信号一般不锁总线,而是锁缓存,比较锁总线开销比较大。

    在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被成为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

  2. 这个写回内存的操作会使在其他CPU缓存了该内存地址的数据无效。

    IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32处理器和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

1.4.3、volatile的使用优化

在JDK7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

LinkedTransferQueue的代码如下:

/** 队列中的头部节点 */
    private transient f?inal PaddedAtomicReference<QNode> head;
    /** 队列中的尾部节点 */
    private transient f?inal PaddedAtomicReference<QNode> tail;
    static f?inal class PaddedAtomicReference <T> extends AtomicReference T> {
      // 使用很多4个字节的引用追加到64个字节
      Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
      PaddedAtomicReference(T r) {
        super(r);
      }
    } 
    public class AtomicReference < V> implements java.io.Serializable {
      private volatile V value;
      // 省略其他代码
    }

**追加字节能优化性能?**它使用一个内部类型来定义队列的头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64个字节。一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一个64个字节。

为什么追加64字节能够提高并发编程的效率?因为64位处理器的L1、l2或l3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味这,如果队列的头节点和尾节点都不足64字节的话,处理器会将他们都读到同一个高速缓存行中,在多处理器下,每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,是头、尾节点在修改时,不会相互锁定。

以下两种场景不应该使用这种方式:

  1. 缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
  2. 共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到告诉缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小。

不过这种追加字节的方式在Java 7下可能不生效,因为java 7变得更加智慧,它会淘汰或重排序无用字段,需要使用追加字节的方式。

1.4.4、内存语义

volatile写的内存语义如下:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

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

内存语义的总结

线程A写一个volatile变量,实质上是线程A向接下来要读这个volatile变量的某个线程发出(其对共享变量所做修改的)信息。

线程B读一个volatile变量,实质上是线程B接受了之前某个线程发出的(在写这个vokatike变量之前对共享变量所做修改的)信息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

1.4.5、实际机制

JMM如何实现volatile写/读的内存语义。

为了实现volatile的内存语义,JMM会分别限制编译器和处理器重排序。编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量和普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

1.4.6、内存模型

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量和普通变量重排序。

线程A 线程B

2、A线程写volatile变量

3、线程B读同一个volatile

4、线程B读共享变量

1、A线程修改共享变量

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

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

所以,volatile的内存模型依赖于重排序和happens-before。

1.5、DCL(Double Check Lock)

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

下面是非线程安全的延迟初始化对象的示例代码:

public class UnsafeLazyInitialization {
      private static Instance instance;
      public static Instance getInstance() {
        if (instance == null)        // 1:A线程执行
          instance = new Instance(); // 2:B线程执行
        return instance;
      }
    }

在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。这时候A线程就会看到instance引用的对象还没完全初始化。

线程安全的延迟初始化实例代码:

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

通过在方法上增加synchronized实现同步。如果getInstance()方法被多个线程频繁调用,将会导致程序执行性能的下降。

在早期的JVM中,synchronized存在巨大的性能开销。因此,人们想出一个“聪明”的技巧:Double Check Lock。

错误Double Check Lock实例代码:

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
    }

这里代码,逻辑上是没有问题的。如果第一次检查instance不为null,那么久不需要执行下面的加锁和初始化操作。问题出在第7行。

1.5.1、问题的根据

创建一个对象,这一行代码可以分解为如下3行伪代码。分配内存空间,初始化对象,设置实例指向分配的内地地址。

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

上面2和3之间,可能会被重排序。重排序之后:

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

根据Java语言规范,所有线程在执行程序时,必须要遵守intra-thread semantics(线程内语义)。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不改变单线程程序执行结果的重排序。上面2和3之间虽然被重排序了,但这个重排序并不会违法intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提供程序的执行性能。

一旦2和3发现重排序,那么B线程就有可能在第4行判断instance不为null。但是instance还没被A线程初始化。

1.5.2、解决方案

在知晓问题的根源之后,又两个办法来实现线程安全的延迟初始化。

  1. 不允许2和3重排序
  2. 允许2和3重排序,但不允许其他线程“看到”这个重排序。

方案一: 讲Instance声明为volatile。在多线程环境中,伪代码2和3之间的重排序会被禁止。

public class SafeDoubleCheckedLocking {
      private volatile static Instance instance;
      public static Instance getInstance() {
        if (instance == null) {
          synchronized (SafeDoubleCheckedLocking.class) {
            if (instance == null)
              instance = new Instance(); // instance为volatile,现在没问题了
          }
        }
        return instance;
      }
    }

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

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

线程A和B同时调用getInstance()方法,getInstance()方法中会触发静态内部类InstanceHolder的加载。在执行类的初始化期间,JVM会去获取一个Class对象的初始化锁,也就是说线程A和B只有一个能执行初始化。如果线程A执行了,那InstanceHolder中的instance就会在线程A中被初始化。B看不到A的重排序初始化。

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

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

1.6、final域的内存语义

对于final域,编译器和处理器要遵循两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1.6.1、写final域的重排序规则

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

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

写final域的重排序规则可以确保:在对象引用为任意线程可以见之前,对象final域已经被正确初始化过了,而普通域不具有这个保障。

1.6.2、读final域的重排序规则

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

初次度对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。

读final域的重排序规则可以确保:在第一个对象的final域之前,一定会先读包含这个final的对象的引用。

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

为了在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了,需要一个保证:在构造函数内,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能再构造函数中“溢出”。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 【Java并发编程的艺术】【学习笔记】Java内存模型(JMM)

相关推荐