Java内存模型

2020/11/29 posted in  Java并发编程的艺术

Java内存模型的基础

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

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

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

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

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存模型里,同步是显式进行的。在消息传递的并发模型里,同步是隐式进行的。

Java的并发采用的是共享内存模型。

Java内存模型抽象结构

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

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

  1. 编译器优化的重排序
    编译器在不改变单线程程序语义的前提下。

  2. 指令级并行的重排序
    如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序

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

重排序

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

数据依赖性

重新排序上面两个操作的执行顺序,程序的执行结果就会被改变。

单线程在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,单线程中程序的执行结果不能被改变。

程序顺序规则

happens-before具有传递性

顺序一致性

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

顺序一致性内存模型

两个特性:

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

volatile的内存语义

可见性
有序性

volatile 写-读的内存语义

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

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

volatile 内存语义的实现

从表中可以看出

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

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

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

JMM采用了比较保守的做法,在每个volatile写的后面或者在每个volatile读的前面插入一个StoreLoad屏障。

锁的内存语义

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

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

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

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

final域的内存语义

对final域的读写更像是普通的变量访问。

final域的重排序规则

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

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

写final域的重排序规则

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

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

happens-before

定义:

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行加过将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。-- JMM对程序员的承诺。
  2. 两个操作之间存在 happens-before 关系,并不意味着Java平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系执行的结果一致,那么这种重排序并不非法。--JMM对编译器和处理器重排序的约束原则。

规则

  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(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中任意操作happens-before于线程A从ThreadB.join()操作成功返回

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

Java程序中,有时候可能需要延迟对象的初始化操作,在使用对象时才进行初始化,但是很容易出问题。

下面是使用双重检查锁定来实现延迟初始化的示例代码:

public class SingleTon {
	private static SingleTon INSTANCE = null;

	private SingleTon() {
	}

	public static SingleTon getInstance() {
		if (INSTANCE == null) {// ①
			synchronized (SingleTon.class) {// ②
				if (INSTANCE == null) { // ③
					INSTANCE = new SingleTon();// 问题出在这里
				}
			}
		}
		return INSTANCE;
	}
}

在线程执行到③位置处,可能INSTANCE不等于null,但INSTANCE引用的对象有可能还没完成初始化。

  • 基于volatile的解决方案
    把INSTANCE声明为volatile型,就可以实现线程安全的延迟初始化。

  • 基于类初始化(静态内部类)的解决方案

public class SingleTon {
	private static SingleTon INSTANCE = null;

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

}

根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化

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

Java虚拟机规范规定了有且5中情况必须立即对类进行初始化

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先出发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候,以及调用一个类的静态方法的时候
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先出发其初始化
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
  5. 如果一个java.lang.invoke.MethodHandle实例最后的解析结果REG_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄锁对应的类没有进行过初始化,则需要先触发其初始化。