《Java并发编程的艺术》读书笔记 第三章 Java内存模型

《Java并发编程的艺术》读书笔记 第三章 Java内存模型

文章目录

      • 《Java并发编程的艺术》读书笔记 第三章 Java内存模型
        • 1.Java内存模型的基础
        • 2.重排序
          • 2.1 数据依赖性
          • 2.2 as-if-serial语义
          • 2.3 程序顺序规则
          • 2.4 重排序对多线程的影响
        • 3.顺序一致性
          • 3.1 数据竞争和顺序一致性
          • 3.2 顺序一致性内存模型
          • 3.3 未同步程序的执行特性
        • 4.volatile的特性
          • 4.1 volatile的特性
          • 4.2 volatile 写-读建立的happens-before关系
          • 4.3 volatile 写-读的内存语义
          • 4.4 volatile内存语义的实现
          • 4.5 JSR-133 为什么要增强volatile的内存语义
        • 5.锁的内存语义
          • 5.1 锁的释放-获取建立的happens-before关系
          • 5.2 锁的释放和获取的内存语义
          • 5.3 锁内存语义的实现
          • 5.4 concurrent包的实现
        • 6.final域的内存语义
          • 6.1 final域的重排序规则
          • 6.2 写final域的重排序规则
          • 6.3 读final域的重排序规则
          • 6.4 final域为引用类型
          • 6.5 为什么final引用不能从构造函数内”逸出“
          • 6.6 final语义在处理器中的实现
        • 7.happens-before
          • 7.1 JMM的设计
          • 7.2 happens-before的定义
          • 7.3 happens-before规则
        • 8.双重检查锁定和延迟初始化
          • 8.1 双重检查锁定的由来
          • 8.2 基于volatile的解决方案
          • 8.3 基于类初始化的解决方案
        • 9.Java内存模型综述
          • 9.1 处理器的内存模型
          • 9.2 各种内存模型之间的关系
          • 9.3 JMM的内存可见性保证
          • 9.4 JSR-133 对旧内存模型的修补

1.Java内存模型的基础
2.重排序

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

2.1 数据依赖性

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

名称 代码示例 说明
写后读 a=1;
b=a;
写一个变量之后,再读这个位置
写后写 a=1;
a=2;
写一个变量之后,再写这个变量
读后写 a=b;
b=1;
读一个变量之后,再写这个变量

​​ ​ ​ 上面三种情况,只要重排序两个操作的顺序,结果就会被改变。编译器和处理器重排序的时候,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

2.2 as-if-serial语义

​ ​ ​ as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改编。编译器、runtime和处理器都必须遵守as-if-serial语义。编译器不会对由数据以来的语句进行重排序,但是如果没有数据依赖的语句,是可以重排序的。

2.3 程序顺序规则

​ ​ ​ 重排序操作A和操作B的执行结果,与操作A和操作B按照happens-before顺序执行的结果一致,JMM会认为这种重排序并不非法,允许这种重排序。

2.4 重排序对多线程的影响

​​ ​ ​ 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因),但是在多线程程序中,对存在控制以来的操作重排序,可能会改变程序的执行结果。

3.顺序一致性

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

3.1 数据竞争和顺序一致性

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

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

3.2 顺序一致性内存模型

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

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

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

3.3 未同步程序的执行特性

​ ​ ​ 未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法阈值。未同步程序在两个模型中的执行特性有如下几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如正确同步的多线程程序在临界区内的重排序)。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
4.volatile的特性
4.1 volatile的特性

​ ​ ​ 一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。

​ ​ ​ 锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

​ ​ ​ ​ volatile变量自身具有下列特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性
4.2 volatile 写-读建立的happens-before关系

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

4.3 volatile 写-读的内存语义

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

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

4.4 volatile内存语义的实现

​ ​ ​ volatile重排序规则表如下所示。
《Java并发编程的艺术》读书笔记 第三章 Java内存模型_第1张图片

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

​ ​ ​ 为了实现volatile语义,编译器生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障
4.5 JSR-133 为什么要增强volatile的内存语义

​ ​ ​ 旧的内存模型中,虽然不允许volatile变量之间重排序,但是允许volatile变量和普通变量重排序。JSR-133中增强volatile的内存语义:严格限制编译器和处理器对volatile变量和普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。

​ ​ ​ volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保整个临界区代码的执行具有原子性。

5.锁的内存语义
5.1 锁的释放-获取建立的happens-before关系

​ ​ ​ 锁除了让临界区互斥执行之外,还可以让释放锁的线程获取同一个锁的线程发送消息。

5.2 锁的释放和获取的内存语义

​ ​ ​ 当线程释放锁的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。下面对锁释放和锁获取的内存语义做个总结:

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

​ to do…

5.4 concurrent包的实现

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

​ ​ ​ AQS,J.U.C,这些concurrent包中的基础类都是使用这种模式实现的。concurrent包中的高层类又是依赖于这些基础类来实现。

6.final域的内存语义
6.1 final域的重排序规则

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

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

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

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

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

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

6.4 final域为引用类型

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

6.5 为什么final引用不能从构造函数内”逸出“

​ ​ ​ 在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。不过需要保证,在构造函数内部,不能让这个被构造对象的引用被其它线程所见(对象引用不能再构造函数中逸出)。

6.6 final语义在处理器中的实现

​ ​ ​ 在X86处理器中,final域的读/写不会插入任何内存屏障(因为X86处理器不会对写-写操作和存在间接依赖关系的操作进行重排序)。

7.happens-before
7.1 JMM的设计

​ ​ ​ 其实前面也提到过,只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),JMM对编译器和处理器不做要求,怎么优化都可以。

7.2 happens-before的定义

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

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

两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序的结果和happens-before关系执行的结果一样,JMM也是允许这种重排序的。

7.3 happens-before规则

​​ ​ ​ JSR-133中对happens-before规则的定义如下:

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

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

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

4.传递性:如果Ahappens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC

5.start()原则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

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

8.双重检查锁定和延迟初始化

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

8.1 双重检查锁定的由来

​ ​ ​ 人们想通过双重检查锁定来降低同步的开销,因为早期JVM,synchronized存在巨大的性能开销(包括无竞争的synchronized)。下面是一个例子。

public class Singleton {  
    private  static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  //会出现问题!
        }  
        }  
    }  
    return singleton;  
    }  
}  

​ ​ ​ 当线程执行到第4行的时候,代码读取到singleton不为null时,instance引用的对象有可能还没有完成初始化。PS:可以通过使用volatile修饰变量,禁止指令重排序,这样就没问题了。

​ ​ ​ 关于该问题的具体分析,可以看看之前写的一篇博客,在博客的最下方(volatile部分),清楚的解释了该问题。点我查看。

8.2 基于volatile的解决方案

​ ​ ​ 同上,可参考给出的博客,在这里给出改后的代码。

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  
        }  
        }  
    }  
    return singleton;  
    }  
}  
8.3 基于类初始化的解决方案

​ ​ ​ 基于下面这个特性,可以实现另一种线程安全的延迟初始化方案。

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

​ ​ ​ 下面是给出的代码。

public class SingletonFactory{
    private static class SingletonHolder{
        public static Singleton singleton=new Singleton();
    }
    public static Singleton getSingleton(){
        return SingletonHolder.singleton;  //这里将导致SingletonHolder类被初始化。
    }
}

to do…

9.Java内存模型综述
9.1 处理器的内存模型

​​ ​ ​ 根据对不同类型的读/写操作组合执行顺序的方式,可以把常见处理器的内存模型划分为如下几种类型:

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

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

  • 在前面2条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了Relaxed Memory Order内存模型和PowePC内存模型

    这里的放松是以两个操作之间不存在数据依赖性为前提的。

​ ​ ​ 性能越强,处理器内存模型就会越弱。常见的处理器内存模型比JMM要弱,Java编译器在生成字节码的时候,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。不过处理器不同,插入的内存屏障种类和数量也不同。

9.2 各种内存模型之间的关系

​ ​ ​ JMM是语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是个理论参考模型。

9.3 JMM的内存可见性保证

​ ​ ​ Java程序的内存可见性保证可以分为下面三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性。
  • 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
9.4 JSR-133 对旧内存模型的修补

​ 修补主要有2个:

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

你可能感兴趣的:(并发编程)