并发编程笔记(三):Java 内存模型(二)

并发系列的文章都是根据阅读《Java 并发编程的艺术》这本书总结而来,想更深入学习的同学可以自行购买此书进行学习。


一 锁的内存语义

众所周知,锁可以让临界区互斥执行。但锁的另一个同样重要的功能却常常被大家忽略:锁的内存语义。

1. 锁的释放 - 获取建立的 happens-before 关系

锁是 Java 并发编程中最重要的同步机制。锁除了可以让临界区互斥外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

来看一个锁释放 — 获取的简单示例代码:

class MonitorExample{
    int a = 0;
    
    public synchronized void writer(){
         a++;
        
     }

    public synchronized void reader(){
        int i = a;
        ......
    }   
}

假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程包含的 happens-before 关系可以分为 3 类:

  1. 根据程序次序规则:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens-before 6。
  2. 根据监视器锁的规则,3 happens-before 4。
  3. 根据 happens-before 的传递性,2 happens-before 5

因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取到同一个锁之后,将立刻变得对 B 线程可见。

2. 锁的释放和获取的内存语义

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

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

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

3. 锁内存语义的实现

我们通过 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 中,通过 lock() 方法获取锁,调用 unlock() 方法释放锁。这个类的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer (AQS)。AQS 使用一个整型的 volatile 变量 state 来维护同步状态,这个变量时 ReentrantLock 内存语义实现的关键。类图如下:

ReentrantLock 分为公平锁和非公平锁,首先看下公平锁,使用公平锁时,加锁方法 lock() 调用轨迹如下:

  1. ReentrantLock : lock()。
  2. FairSync : lcok()。
  3. AQS : acquire(int arg)。
  4. ReentrantLock : tryAcquire(int acquires)。

第四步时候真正开始加锁,下面是 tryAcquire(int acquires) 方法的源代码

  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 == getExclsiveOwnerThread()){
            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. AQS : release(int arg)
  3. Sync : tryRelease(int release)

从第三步开始释放锁,下面是源代码:

  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 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。

非公平锁的释放和公平锁一样,所以仅仅分析非公平锁的获取,加锁方法 lock() 调用轨迹如下:

  1. ReentrantLock : lock()
  2. NonfairSync : lock()
  3. AQS : compareAndSetState(int expect , int update)

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

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

改方法以原子操作的方式更新 state 变量,CAS 操作具有 volatile 读和写的内存语义。

为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 前后的任意内存操作重排序。

在 inter X86 处理器中,CAS 是如何同时具有 volatile 读写的内存语义的呢?下面是 sun.misc.Unsafe 类的 compareAndSwapInt() 方法的源代码:

    public final native boolean compareAndSwapInt(Object o ,long offset, 
                                                  int expected, 
                                                  int x);

这是一个本地方法调用。对应于 X86 处理器的源代码如下:

    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前缀。如果程序运行在多处理器上,则加上lock前缀,单处理器上则省略,单处理器自身会维护处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果。

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

  1. 确保对内存的读-改-写操作原子执行。在 Pentium 以及之前的处理器中,lock 前缀指令将锁住总线,开销很大。之后的处理器中,Inter 使用缓存锁定来保证指令执行的原子性。
  2. 禁止该指令,与之前和之后的读写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

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

通过上面对 ReentrantLock 的分析可以看出,锁释放—获取的内存语义的实现至少有下面两种方式:

  1. 利用 volatile 变量的写—读所具有的内存语义。
  2. 利用 CAS 锁附带的 volatile 读和写的内存语义。
4. concurrent 包的实现

volatile 变量的读写和 CAS 可以实现线程之间的通信,它们形成了整个 concurrent 包得以实现的基石。如果仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:

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

final 域的内存语义

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

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

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

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

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

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

3. final 域为引用类型

对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

但在多线程情况下构造函数之外对 final 引用对象成员域的写入与读取存在数据竞争,JMM 不保证这些线程的写入对其他线程可见,如果想要确保这种原子性,还是需要使用同步原语(lock 和 volatile)确保内存可见性。

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 的后面,执行 reader() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如下:

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

5. final 语义在处理器中的实现

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

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

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

在旧的内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变。

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

happens-before

happens-before 是 JMM 最核心的概念。对应 Java 程序员来说,理解 happens-before 是理解 JMM 的关键。

1. JMM 的设计

从 JMM 设计者的角度,在设计 JMM 时,需要考虑两个关键因素。

  1. 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
  2. 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型

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

double pi = 3.14;         //A
double r = 1.0;           //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在 3 个 happens-before 关系,如下:

  • A happens-before B
  • B happens-before C
  • A happens-before C

在 3 个 happens-before 关系中,2 和 3 是必须的,但 1 是不必要的。因此 JMM 把 happens-before 要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序
  • 不会改变程序结果的重排序

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

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

下图是 JMM 的设计示意图:


从上图可以看出两点:

  1. JMM 向程序员提供的 happens-before 规则能满足程序员的需求。JMM 的 happens-before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性(有些内存可见性保证其实不一定真实存在,步入上面的 A happens-before B)。
  2. JMM 对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致分析后认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致分析后,认定一个 volatile 变量只会被单个线程访问,那么编译器可以把这个 volatile 变量当做一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
2. happens-before 的定义

JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程内,也可以在不同线程之间。因此 JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果 A 线程的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 和 b 在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。

JSR-133 对 happens-before 关系的定义如下:

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法,JMM 允许这种重排序

上面第一条是JMM 对程序员的承诺。从程序员角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 Java 内存模型将向程序员保证——A 操作的结果将对 B 可见,且 A 的执行顺序在 B 之前。注意,这只是 Java 内存模型向程序员做出的保证。

上面第二条是JMM 对编译器和处理器重排序的约束原则。JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都可以。JMM 这么做的原因是:程序员对这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变。因此,happens-before 关系本质上和 as-if-serial 语义是一回事

as-if-serial 和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

3. happens-before 规则

JSR-133 定义了如下 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 多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。

1. 双重检查锁的由来

Java 程序中,有时候可能需要推迟一些高开销的对象初始化操作,在使用这些对象的时候才进行初始化,此时,一般都会蚕蛹延迟初始化,但要正确的实现线程安全的延迟初始化需要一定技巧,否则很容易出现问题。下面是一个非线程安全的延迟初始化对象的示例代码:

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

A 线程执行代码 1 的同时,B 线程执行代码 2 ,此时,线程 A 可能会看到 instance 引用的对象还没有完全初始化。对于以上类,我们可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化:

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

由于对 getInstance() 方法做了同步处理,synchronized 将导致性能下降,当该方法被多个线程频繁调用,将导致程序执行性能的下降。

早期的 JVM 中,synchronized 存在巨大的性能开销。因此,人们想出了一个「聪明」的技巧:双重检查锁定,人们想通过双重锁定来降低同步的开销。下面是双重锁定的代码:

    public class DoubleCheckedLocking{
        private static Instance instance;

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

双重锁定可以大幅降低 synchronized 带来的性能开销,看起来似乎很完美,但这是一个错误的优化!线程执行到第四行的时候,代码读取到 instance 不为 null 时,instance 引用的对象可能还没有完成初始化。

5. 问题的根源

出现问题的根源就在于代码的第七行(instance = new Singleton();)创建了一个对象,这行代码可以分解为三行伪代码:

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

上面代码中 2 和 3 之间可能会被重排序,那么在将 instance 指向分配的内存地址的时候,对象还没有被初始化。这种重排序没有改变单线程程序执行的结果,可以提高程序的执行性能。

但这种重排序就有可能导致后面访问这段代码的线程在访问 instance 所引用的对象的时候,这个对象还没有被初始化过。在知晓了问题的根源后,我们可以想出两个办法来实现线程安全的延迟初始化:

  • 不允许 2 和 3重排序。
  • 允许 2 和 3 重排序,但不允许其他线程「看到」这个重排序。
3. 基于 volatile 的解决方案

我们可以把上面的DoubleCheckedLocking示例代码中的 instance 声明为 volatile 型,就可以实现线程安全的延迟初始化。当声明对象的引用为 volatile 后,伪代码中的 2 和 3 之间的重排序在多线程环境中将会被禁止。

4. 基于类初始化的解决方案

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

基于这个特性,可以实现另一种线程安全的延迟初始化方案,这个方案经常被用来作为实现单例模式

    public class InstanceFactory{
        private static class InstanceHolder{
            public static Instance instance = new Instance();
        }

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

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

这个方案的实质是:允许伪代码中的 2 和 3 重排序,但不允许非构造线程「看到」这个重排序。

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

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

InstanceFactory示例代码中,首次执行 getInstance() 方法的线程将导致 InstanceHolder 类被初始化,这符合上面的情况 4 。

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

为了便于理解,书中的作者把类初始化的处理过程分为 5 个阶段。

第一阶段:通过在 Class 对象上同步,获取 Class 对象的初始化锁,来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设现在有 A 和 B 两个线程在争夺初始化锁,然后 A 争夺到了锁,发现 Class 对象还没有被初始化,初始化状态 state = noInitialization。此时 A 将会设置 state = initializing。

类初始化1

第二阶段:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待。

类初始化2

第三阶段:线程 A 设置 state = initialized,然后唤醒在 condition 中等待的所有线程。

类初始化3

第四阶段:线程 B 结束类的初始化处理

类初始化4

线程 A 在第二阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放初始化锁;线程 B 在第 4 阶段的 B1 获取同一个初始化锁,并在第 4 阶段的 B4 之后才开始访问这个类。根据 JMM 规范的锁规则,存在如下 happens-before 关系:

线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程 B 一定能看到。

第五阶段:线程 C 执行类的初始化的处理

类初始化5

在第三阶段之后,类已经完成了初始化。因此线程 C 在第五阶段的类初始化处理过程相对简单一点。只需要经理一次锁的获取—释放,其他线程的类初始化过程都经历了两次。

线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。

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

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问延迟初始化的字段的开销。大多数情况,正常的初始化要优于延迟初始化。如果要对实例字段进行线程安全的延迟初始化,请使用 volatile,如果确实需要对静态字段进行线程安全的延迟初始化,请用基于类初始化的方案。

Java 内存模型综述

1. 处理器的内存模型

顺序一致性是一个理论参考模型,JMM 和处理器内存模型设计的时候通常会以顺序一致性模型作为参考。但 JMM 和处理器都会对这一模型进行一定程度的放松。如果完全按照顺序一致性模型来处理,那么很多处理器和编译器优化都会被禁止,执行性能将会受到很大影响。

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

2. 各种内存模型之间的关系

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性是一个理论参考模型。常见的处理器内存模型一般比常用的语言内存模型要弱,处理器和语言内存模型都比顺序一致性内存模型弱。通处理器内存模型一样,越追求性能的语言,内存模型设计得会越弱,但易编程性就会越差。

3. JMM 的内存可见性保证

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

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

最小安全性保证线程读到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。最小安全性保证与 64 位数据的非原子性写并不矛盾。

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

JSR-133 对 JDK 5 之前的旧内存模型的修补主要有两个:

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

推荐阅读更多精彩内容