java并发编程的艺术笔记第三章--java内存模型

Java内存模型的基础

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

线程之间如何通信及线程之间如何同步

线程之间的通信机制有两种:共享内存和消息传递。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明.

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

java内存模型的抽象结构

  1. 堆内存.所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享
  2. 线程栈内存:局部变量(Local Variables),方法定义参数和异常处理器参数

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

从源代码到指令序列的重排序

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

内存系统重排序指的是

多核cpu时代每个cpu核都有自己的缓冲区,只有把缓冲区的数据刷新到朱内存中后数据才算是正式生效,但是每个cpu核刷新缓存的时间不一致,导致了最终的效果是数据生效时间不一致.

  • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

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

  • 从处理器排序规则表中可知:常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序

happens-before简介

从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用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。

重排序

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

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性.

编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
数据依赖性是针对单线程的.

as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变 .

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

程序顺序规则

就是代码编写的执行顺序

重排序对多线程的影响

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

顺序一致性

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

数据竞争与顺序一致性

当程序未正确同步时,就可能会存在数据竞争.

数据竞争的定义

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

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。马上我们就会看到,这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

顺序一致性内存模型

两个特性

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

多线程下,保证单线程顺序的情况下,多线程可以自由组合出一个执行顺序,各个线程看到这个组合出的执行顺序,而不是每个线程看到的顺序不一致

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

在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)

虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果

JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执
行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象

未同步程序在顺序一致性模型和JMM的执行特性有如下几个差异

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
  3. JMM不保证对64位的long型和double型变量的写操作具有原子性(拆分成两个32变量,有可能会被分配到不同的cpu总线事物中),而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

volatile的内存语义

volatile的特性

  1. 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  2. 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile写-读建立的happens-before关系

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

volatile的读写的内存语义

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

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。 实际情况是会根据cpu的嗅探技术监控总线,检测到内存数据修改后才会失效,而不是每次都是冲主内存读取数据

volatile的内存语义的实现

JMM针对编译器制定的volatile重排序规则表

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

JMM对通过在volatile前后插入内存屏障来禁止特定的重排序

JSR-133为什么要增强volatile的内存语义

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。

3.5 锁的内存语义

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

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

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

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

锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

锁内存语义的实现

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,马上我们会看到,这个volatile变量是ReentrantLock内存语义实现的关键

  • ReentrantLock的公平锁是通过控制一个全局的volatile的整型变量state来实现的
  • ReentrantLock的非公平锁实在枷锁的时候直接对变量执行cas操作来实现的 CAS同时具有volatile读和volatile写的内存语义

JDK concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面4种方式。

  1. A线程写volatile变量,随后B线程读这个volatile变量。
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令来实现,volatile变量的读/写和CAS可以实现线程之间的通信,整合在一起,就形成了整个concurrent包得以实现的基石,如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。

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

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的

3.6 final域的内存语义

final域的重排序规则

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

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外

读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器, 因为两个操作之间有依赖关系,编译器不会重排序)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

final域为引用类型

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

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

在构造函数返回前,被构造对象的引用不能为其他线程所见(不能再构造方法里把final引用赋值给其他变量,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

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

在之前,其他线程可能会看见final域的默认值.通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

3.7 happens-before

JMM的设计

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

  • 会改变程序执行结果的重排序 禁止重排序
  • 不会改变程序执行结果的重排序 允许重排序

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

例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证

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

as-if-serial语义保证单线程内程序的执行结果不被改变,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()操作成功返回。

3.8 双重检查锁定与延迟初始化

##双重检查锁的由来

  • 错误示范代码一–多线程下重复初始化
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
    if (instance == null) // 1:A线程执行
        instance = new Instance(); // 2:B线程执行
    return instance;
    }
}
  • 错误示范代码二 并发访问量大的时候锁的性能开销过大
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
    if (instance == null)
        instance = new Instance();
    return instance;
    }
}
  • 错误示范代码三 可能被访问到还没有初始化完成的instance对象
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=new Singleton();拆分为编译后的伪代码

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

2和3行可能会被重排序,会导致instance引用不为空但是没初始化完成

根据《The Java Language Specification,Java SE 7Edition》,所有线程在执行Java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。

解决问题的方式有两种:

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

基于volatile的解决方案

用volatile修饰instance 变量,根据volatile的特性会禁止volatile写前后的重排序

基于类初始化的解决方案

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

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

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

上述代码属于情况4

类的初始化锁

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

  1. 第1阶段:线程A获取锁设置state=initializing并释放锁,同一时间竞争锁的线程B阻塞等待
  2. 第2阶段:线程A执行类的初始化操作,同时线程B获得了A释放的锁,读取到state=initializing时,进去condition队列等待。
  3. 第3阶段:线程A初始化完类之后,获取锁,线程A设置state=initialized,释放锁,然后唤醒在condition中等待的所有线程。
  4. 第4阶段:线程B获取锁,state=initialized时,释放锁,结束类的初始化处理。
  5. 第5阶段:线程C(新进入的线程,不是condition中等待的线程)获取锁,state=initialized时,释放锁 结束初始化的处理。

如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案

基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。

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