Skip to content

Latest commit

 

History

History
634 lines (416 loc) · 40.9 KB

JavaMemoryModel.md

File metadata and controls

634 lines (416 loc) · 40.9 KB

本文大部分摘自书籍《Java并发编程的艺术》

Java内存模型

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

  线程之间如何通信及线程之间如何同步(这里的线程指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信,在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
  同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存模型中,同步是显式进行的。程序员必须显示指定某个方式或字段代码需要在线程之间互斥进行。在消息传递的并发模型中,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
  Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。在编写并发程序的过程中,若不理解隐式进行的线程之间的通信的工作机制,很可能遇到各种奇怪的内存可见性问题。

Java内存模型的抽象结构

    Java中,所有实例域、静态域和数组域都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会再线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入合适对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的贝蒂内存,本地内存中存储了该线程以读、写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象结构图如下:

Alt Java内存模型抽象结构

    可以看出,线程A、线程B之间需要通信的话,必须经历两个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存中去。

  • 线程B到主内存中去读取线程A之前已更新过的共享变量。

可看出,JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

指令序列重排序

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

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

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

  • 内存系统的重排序
    由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。下面是指令重排序顺序图:

Alt 指令重排序顺序图

    后面两个重排序均为处理器的重排序。这些重排序可能导致多线程程序出现呢内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

并发编程模型的分类

    现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲弄去,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致。前面的叙述简易的说就是在处理器对内存的写操作之间加了个写缓冲区,也就是处理器首先将写入值存入写缓冲区,这可能造成写入写缓冲区后还没写入内存就执行对内存的读取。为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据的装载先于Store2及所有后续存储指令的装载
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2 及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

    StoreLoad Barriers是一个全能型的屏障,他同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把缓冲区的数据全部刷新到内存中。

happens-before

    从JDK 5开始,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仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

重排序

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

数据依赖性

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

  • 写后读
        写一个变量之后,在读这个变量a = 1; b = a;

  • 写后写
        写一个变量之后,再写这个变量a = 1; a = 2;

  • 读后写
        读一个变量之后,再写这个变量a = b; b = 2;

    可见上面的情况中,重排序会影响执行结果。前面提到,编译器和处理器可能对操作进行重排序,但它们在进行重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。这里的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作哦,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

    as-if-serial语义的意思:不管怎么重排序(编译器和处理器为了提高并行速度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据以来的操作重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。如 int a = 1; int b = 3; int c = a + b;,c依赖于a、b,但是a、b不互相依赖,就可能进行重排序,所以可能有两种执行顺序。对于单线程而言,as-if-serial语义保证了程序达到预期结果。

程序顺序规则

     根据happens-before的程序顺序规则,前面的例子存在3个happens-before关系:

  • a happens-before b

  • b happens-before c

  • a happens-before c

    第三条是根据传递性推导出来的,a happens-before b,实际执行可能对a、b进行重排序,前面说过happens-before不要求前一个操作一定在后一个操作之前执行,JMM仅仅要求前一个操作对后一个操作可见,且前一个操作按顺序排在第二个操作之前,这个操作a的执行结果不需要对操作b可见,且两者重排序后执行结果与不进行重排序执行结果一致,所以JMM认为这种重排序不是非法的,允许这种重排序。

重排序对多线程的影响

    前面我们看到重排序在单线程中不影响执行结果,但是在多线程中若多个线程对同一个变量进行操作时,就需要考虑重排序对程序带来的影响了。

int a = 0;
boolean mark = false;

void write() {
    a = 1;
    mark = true;
}

void validate() {
    if(mark) {
        int b = a * a;
        system.out.println(b);
    }
}

     很显然,上面的write方法是对a的写,并利用mark来标识a是否被写,然后validate方法验证a 是否被写并执行一个a*a的操作,假设线程A执行write方法,然后线程B执行validate方法,会出现以下两种影响:

  • a = 1 操作与mark = true操作没有数据依赖,进行重排序,先执行mark = true,此时线程B读到mark,判断为真,执行下一步操作,若此时变量a任然为0,则语义被重排序破坏,若此时a 已经为1,那么重排序并没有破坏语义。

  • 判断if(mark)操作与int b = a * a操作存在控制依赖,当存在控制依赖时,会影响指令序列的并行度。为此,编译器核处理器会采用猜测执行来客服控制小惯性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a * a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当控制为真时,将计算结果写入变量b中,那就会出现a任然为0的情况下进行计算,直到mark为真时,将计算结果赋给b,造成语义被破坏。单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因。

顺序一致性

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

数据竞争与顺序一致性

     当程序为正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
    当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。JMM对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。

顺序一致性内存模型

    顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。

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

    在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每个线程必须按照程序的顺序来执行内存读写操作。任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,会把所有线程的所有内存读写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。从另一角度来讲,不管是同步还是未同步的程序,在多线程并发执行的情况下,都会有一个执行顺序,每个线程都能看到一个相同的执行顺序,原因是顺序一致性内存模型中的每个操作必须立即对任意线程可见。但是JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作顺序将不一致。

同步程序的顺序一致性效果

    还是之前的例子,现在使用锁来进行同步:

int a = 0;
boolean mark = false;

synchronized void write() {
    a = 1;
    mark = true;
}

synchronized void validate() {
    if(mark) {
        int b = a * a;
        system.out.println(b);
    }
}

    根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码逸出到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。JMM在具体实现上的基本方针为:在不改变程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

    对于未同步或为正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值,JMM保证线程读操作读取到的值不会无中生有。为了实现最小安全性,JMM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。JMM不保证未同步程序的执行结果与改程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要进制大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。未同步程序在这两个模型的执行特性有如下差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。

  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。

  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读写操作都具有原子性。

    第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传递数据到处理器,写事务从处理器传送数据到内存,每个事务会读写内存中的一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它的处理器和IO设备执行内存的读写。

总线工作机制
    若多个处理器同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,总线仲裁会确保所有处理器都能公平的访问内存,假设处理器A获胜,它可以进行总线事务,而其他处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。若在处理器A执行总线事务期间,另外的处理器发起了总线事务,此处理器的请求会被总线禁止。

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

volatile的内存语义

volatile的特性

    理解volatile特性可以把对volatile的单个的读或者写,看成是使用同一个锁对单个读或者写操作做了同步(注意,单个读或者写意思为要么是读,要么是写,请区别于读写操作)。锁的语义决定了临界区代码的执行具有原子性,这意味着,即使是64位的long、double性变量,只要是volatile变量,读或者写就具有原子性。而复合操作如++,这类操作不具有原子性。volatile特性如下:

  • 可见性,对一个volatile变量的读,总是能看到对这个变量最后的写入。

  • 原子性,对任意单个volatile变量的单个读或者写操作具有原子性,复合操作不具有原子性。

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

volatile内存语义实现

    为了实现volatile的内存语义,JMM分别限制了编译器重排序、处理器重排序。volatile重排序的限制如下表:

是否能重排序 第二个操作
第一个操作 普通变量的读/写 volatile变量的读取 volatile变量的写入
普通变量的读/写
volatile变量的读取
volatile变量的写入

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

  • 在每个volatile变量写操作的前面插入一个StoreStore屏障。

  • 在每个volatile变量写操作的后面插入一个StoreLoad屏障。

  • 在每个volatile变量读操作的后面插入一个LoadLoad屏障。

  • 在每个volatile变量读操作的后面插入一个LoadStore屏障。

    上诉策略保证了任意程序,任意平台下都能得到正确的volatile内存语义。由于不同处理器有不同松紧度的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型进行优化,比如省略部分屏障。

锁的内存语义

    锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。当现线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。下面以重入锁(ReentrantLock)的例子来解析锁的实现:

ReetrantLock lock = new ReetrantLock();
public void A() {
   lock.lock();
   try{
        //...
   }
   finally{
        lock.unlock();
   }
}

    在ReentrantLock中通过lock()方法获取锁;unlock()方法释放锁。ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(暂时简称AQS)。AQS使用一个整型的volatile变量(state)来维护同步状态。ReentrantLock中公平锁具体代码如下:

    final void lock() {
        acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

    其中lock()方法调用acquire(1)再到tryAcquire(1),接着会看到获取前面说到的volatile变量赋给c,对volatile变量的操作可以避免重排序,接着进行一系列的判断,其中compareAndSetState(0, acquires)方法目的是将值为0的state利用CAS交换为acquire(也就是1),交换成功,则获取到了锁,对于CAS的具体实现,代码为c++,使用的内嵌的汇编语言实现,也就是cmpxchg(比较并交换CAS)等一系列指令,程序也会根据当前处理器的类型来决定是否为cmpxchg指令叫上lock前缀。若程序在多处理器上运行,则加上lock前缀,否则省略(但处理器能维护但处理器内的顺序一致性)。请查看大佬的帖子。

concurrent 包的实现

使用处理器上提供的高效的机器级别的原子指令,这些原子指令以原子方式对内存执行读改写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读写和CAS可以实现线程之间的通信。这些内容就是concurrent包实现的基石。结合前面的例子,总结出一个通用的同步实现方式:

  1. 声明共享变量为volatile

  2. 使用CAS的原子条件更新来实现线程间的同步

  3. 配合以volatile的读、写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS及非阻塞数据结构可原子变量类,这些concurrent 包中的基础类都是使用这种方式实现的,concurrent包实现示意图如下:

Alt concurrent包实现示意图

final 域的内存语义

final 域的重排序规则

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

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

2)初次读一个包含final 域的对象的引用,与随后初次读这个final 域,这两个操作之间不能重排序。

写final 域的重排序规则

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

1)JMM 禁止编译器把 final 域的写重排序到构造函数之外。

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

反思:写普通域的操作可能被编译器重排序到构造函数之外,意思是普通域不具备如 final 域的禁止重排序到构造函数外的保障。

读 final 域的重排序规则

读 final 域的重排序规则是:

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

引用类型 final 域

对于引用类型,写 final 域的重排序规则对编译器和处理器增加了约束:

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

Happens-before

happens-before 是 JMM 的核心概念。

JMM 的设计

JMM 的设计意图:

1)程序员对内存模型的使用。程序员希望内存模型易于理解、编程。

2)编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对他们的束缚越少越好,这样他们就可以尽可能多的优化来提高性能。

由于两个意图矛盾,所以从两个方面进行了折中。

JMM 把 happens-before 要求禁止的重排序分为了下面两类:

1)会改变程序执行结果的重排序。

2)不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序采取了不同的策略:

1)对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

2)对于不会改变执行结果的重排序,JMM 对编译器和处理器不做要求。

Happens-before 的定义

1)如果一个操作 Happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

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

Happens-before 规则

1)程序顺序规则

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

2)监视器锁规则

对一个锁的解锁,happens-before 于随后对这个锁的加锁。

3)volatile 变量规则

对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

4)传递性

如果 A happens-before B,B happens-before C,那么 A happens-before C。

5)start() 规则

如果线程 A 执行操作 ThreadB.start() 启动线程B,那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。

6)join() 规则

如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。

双重检查锁定与延迟初始化 - 单例模式

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

双重检查锁定

在 Java 中,有时候可能需要推迟一些高开销的对象的初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员会采用延迟初始化,以单例模式为例:

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

假设 A 线程执行代码 if(instance == null) 的同时,B 线程执行 instance = new UnsafeSingleton(),此时线程 A 可能会看到 instance 引用的对象还没有初始化,会造成实例化多个实例。针对上面的情况,可以对 getInstance() 方法进行同步处理:

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

由于 getInstance() 方法进行了同步处理,使得单例为线程安全的,但是 synchronized 将导致性能开销,且如果此方法被多个线程频繁的调用,将会导致程序执行性能下降。若此方法不会被多个线程频繁调用,那么这个延迟初始化方案将能提供令人满意的性能。但聪明的人们相处了一个更好的解决办法:双重检查锁定。

public class SafeSingleton {
    private static UnsafeSingleton instance;
    
    private UnsafeSingleton() {}
    
    public static UnsafeSingleton getInstance() {
        if(instance == null) {
            sychronized(SafeSingleton.class) {
                if(instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

上面的代码中第一次检查后进入的线程将存在锁竞争,竞争后存在一个线程首先得到锁并进入同步代码块,那么为什么需要第二个检查呢,第二个检查避免后续的线程进入后多次初始化。第一次检查避免了不为空时对锁的获取带来的资源消耗,因此可大量降低性能消耗。这种双检查锁定看起来似乎比较完美,但这是一个错误的优化!因为在执行第一次检查时,可能 instance 正在进行初始化而并没有完成初始化,而此时可能引用到了一个为初始化完成的实例。

问题所在

instance = new SafeSingleton 分为三部分 + 额外部分:

1)分配内存空间

2)进行初始化

3)设置instance 指向内存空间

4)初次访问对象

Java 保证重排序不会改变单线程内的程序执行结果,那么意味着 2)3)可能存在重排序,因为 Java 内存模型保证了2)一定会排在4)之前执行,所以2)3)进行重排序根本不影响单线程程序执行结果。若在2)3)进行了重排序,那么当线程执行到3)时,若存在线程 B 执行到第一个检查,此时发现 instance 并不为 null,将返回一个还没有执行2)初始化的对象。

基于 volatile 的解决方案

public class SafeSingleton {
    private volatile static UnsafeSingleton instance;
    
    private UnsafeSingleton() {}
    
    public static UnsafeSingleton getInstance() {
        if(instance == null) {
            sychronized(SafeSingleton.class) {
                if(instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

基于类初始化的解决方案

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

public class SafeSingleton {
    private static class InstanceHolder {
        public static SafeSingleton instance = new SafeSingleton();
    }
    
    private UnsafeSingleton() {}
    
    public static SafeSingleton getInstance() {
        return InstanceHolder.instance;
    }
}

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

1)T 是一个类,而且一个 T 类型的实例被创建。

2)T 是一个类,且 T 中声明的一个静态方法被调用。

3)T 中声明的一个静态字段被赋值。

4)T 中声明的一个静态字段被使用,而且这个字段不是常量字段。

5)T 是一个顶级类,而且一个断言语句嵌套在 T 内部被执行。

SafeSingleton 中,首次执行 getInstance 方法的线程将导致 InstanceHolder 类被初始化(情况4)。由于 Java 语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用 getInstance() 方法来初始化 InstanceHolder 类)。因此,在 Java 中初始化一个类或者接口时,需要做细致的同步处理。

Java 语言规范规定,对于每一个类或者接口,都有唯一的初始化锁与之对应。JVM 在类初始化期间会获取这个初始化锁。对于类的初始化过程可分为:

1)通过在 Class 对象上同步,即获取 Class 对象的初始化锁来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。假设Class 对象未被初始化且线程 A 抢先获取到这个锁。

2)线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待。

3)线程 A 设置 state=initialized,然后唤醒在 condition 中等待的所有线程。

4)线程 B 结束类的初始化处理。

注:condition 与 state 是虚构的,Java 语言规范并没有规定这一点。

Java 内存模型总结

处理器的内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会以顺序一致性内存模型为参考。在设计时,JMM 和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。根据不同类型的读写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为如下几种类型:

1)放松程序中写-读操作的顺序,由此产生了 Total Store Ordering 内存模型(TSO)。

2)在1)的基础上,继续放松程序中写-写操作的顺序,由此产生了 Partial Store Order 内存模型(PSO)。

3)在1)2)的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了 Relaxed Memory Order 北村模型(RMO) 和 PowerPC 内存模型。

这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的。

内存模型 处理器 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 为了使得在不同处理器下为程序员展示的是一个一致的内存模型,所以 JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。

各种内存模型之间的关系

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。这几种模型强弱不一。常见的4中处理器内存模型比常用的 3 种语言内存模型弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。

JMM 内存可见性保证

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

1)单线程程序

单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中执行结果相同。

2)正确同步的多线程程序

正确同步的多线程程序的执行将具有顺序一致性。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序为此程序员提供内存可见性保证。

3)未同步/ 未正确同步的多线程程序

JMM 为他们提供了最小安全保证:线程执行时读取到的值,要么是之前某个线程写入的值,要么默认值。数据不会凭空产生。

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

JSR-133 对 Jdk 5 之前的旧内存模型的修补主要为以下两个:

1)增强 volatile 的内存语义

旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写-读和锁的释放-获取具有相同的内存语义。

2)增强 final 内存语义

在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。在保证 final 引用不会从构造函数内逸出的情况下,final 具有了初始化安全性。