Earth Guardian

You are not LATE!You are not EARLY!

0%

Java 内存模型

Java 内存模型(Java Memory Model, JMM),本文主要参考 JSR -133 内存模型

基本概念

共享变量

  • 堆内存:包含类实例、静态字段和数组元素
  • 方法区:类字段(全局变量)
    堆内存中的变量和方法区的全局变量,在线程之间共享,这些统称为共享变量。其他如虚拟机栈帧(局部变量表,方法返回值)等都是线程私有的

抽象示意图

内存模型决定一个线程对共享变量的写入何时对另一个线程可见,从抽象的角度来看:线程之间的共享变量存储在主内存中,每个线程都有一个私有的工作内存,工作内存中存储了该线程以读/写共享变量的副本。这里仅仅将 JMM 作为一个抽象概念,并不和物理实际内存、高速缓存、寄存器等做一一对应。内存模型抽象示意图:

0004-Java-memory-model.jpg

内存间交互操作

定义了 8 中操作,每种都是原子的、不可再分的(对于 doublelong 来说理论上有例外,但是商用虚拟机上不会存在这个现象)

  • lock 锁定:作用于主内存的变量,标注该变量为一条线程独占状态
  • unlock 解锁:作用于主内存的变量,标注该变量被解锁,其他线程能使用
  • read 读取:作用于主内存的变量,它把变量从主内存传输到线程的工作内存中,供 load 使用
  • load 载入:作用于工作内存的变量,它把 read 操作的变量放入工作内存的变量副本中
  • use 使用:作用于工作内存的变量,每当虚拟机需要使用该变量时,传递给执行引擎
  • assign 赋值:作用于工作内存的变量,每当虚拟机需要给变量赋值时,从执行引擎中读取并赋值给工作内存变量副本
  • store 存储:作用于工作内存的变量,它把工作内存的变量副本传递给主内存中,供 write 使用
  • write 写入:作用于主内存的变量,它把 store 操作从工作内存中得到的变量放入主内存的变量中

longdouble 的非原子协定

longdouble 的非原子协定(Nonatomic Treatment of double and long Variables):对于 64 位的 longdouble 类型,允许虚拟机将没有被 volatile 修饰的数据,在读写操作时划分为两次 32 的操作来进行,即不保证它们的 load, store, read, write 是原子性的。
这会导致多线程中同时对他们读写,可能会出现某个线程读取到的既不是原值,也不是被其他线程修改过的值,而是读到了各一半的混合值,但是这种情况在商用虚拟机中并不会出现,因此代码中一般并不会对这两种类型专门申明为 volatile

原子性、可见性、有序性

原子性(Atomicity

Java 内存模型中所有的基本数据类型读写访问具备原子性(long/double 虽然有非原子协议,但是商用虚拟机不会出现)。如果需要需要保证代码块的原子性,需要使用 synchronized 关键字

可见性(Visibility

指一个线程修改了共享变量的值,其他线程能够立即得到这个修改后的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。关键字 volatile, synchronized, final 都能实现可见性

  • volatile
    volatile 修饰的变量通过反汇编发现会多执行一个 lock 前缀的操作,这个操作相当于内存屏障(Memory Barrier or Memory Fence),作用是使得本 CPUCache 写入内存,同时会引起其他 CPU Cache 变得无效;相当于 Java 内存模式中的 store/write 操作,所以可以确保 volatile 变量的修过对其他 CPU 立即可见
  • synchronized
    同步块对一个变量的执行 unlock 操作前,必须先把此变量同步写回主内存中
  • final
    变量在构造器中一旦初始化完成,那在其他线程中就能看到这个字段的值

有序性(Ordering

如果在本线程中观察,所有的操作都是有序的(As-If-Serial 语义) ;如果在一个线程中观察另外一个线程,所有的操作都是无序的(重排序,包括指令重排序和内存同步延迟)

关键字 volatilesynchronized 两个关键字来保证线程之间的操作的有序性:

  • volatile
    关键字禁止了指令重排序
  • synchronized
    一个变量在同一个时刻只允许一条线程对其进行 lock 操作,决定了持有同一个锁的两个同步块只能串行地进入

总结

synchronized 关键字能满足三种特性;volatile 能部分满足,在有条件情况下可以满足并发安全

重排序

概念

在执行程序时为了提高性能,编译器和处理器常常会改变指令执行顺序或者内存操作顺序,也就是重排序。重排序分三种类型:

  • 编译器优化的重排序
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的同步延迟
    由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

0004-java-memory-model-command-sort.jpg

其中:1 属于编译器重排序,2 和 3 属于处理器重排序

影响

  • 重排序都可能会导致多线程程序出现内存可见性问题

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

原则

不管怎么重排序,单线程程序执行的结果不能改变。也就是内存模型中描述的:线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)。

示例

  • 指令执行重排序
1
2
int a = 1;
int b = 2;

如上两条指令无论是在编译器优化重排序,还是处理器优化并发执行,并不会影响重排序后的执行结果

  • 内存操作重排序
    根据 JMM 定义,各线程会使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致
1
2
3
4
5
Processor A         Processor B
a = 1; //A1 b = 2; //B1
x = b; //A2 y = a; //B2
初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0

处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果

0004-java-memory-model-ram-order.png

数据依赖性

指当前操作依赖前一次操作的结果。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。注意:数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

先行发生原则 happens-before

作用及意义

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这些先行发生原则,可以确保两个操作在不需任何同步协助下并发是安全的。

实质

先行发生原则的实质就是禁止重排序

规则

  • 程序次序规则(Program Order Rule
    在同一个线程中,按照程序代码顺序和控制流顺序操作
  • 管程锁定规则(Monitor Lock Rule
    针对同一个锁, unlock 操作在时间上先发生于后面对该锁的 lock 操作
  • volatile 变量规则(Volatile Variable Rule
    volatile 变量的写操作在时间上先行发生于后面对改变的读操作
  • 传递性(Transitivity
    如果操作 A 先行发生于操作 B ,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C
  • 对象终结规则(Finalizer Rule
    对象的初始化先行发生于它的 finalize
  • 线程启动规则(Thread Start Rule
    Thread 对象的 start() 方法先发生于此线程的每个动作
  • 线程终止规则(Thread Termination Rule
    线程中的所有操作都先行发生于对此线程的终止检测。可以通过 Thread.join()/Thread.isAlive() 来检测到线程已经终止
  • 线程中断规则(Thread Interruption Rule
    对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件发生,可以通过 Thread.interrupted() 方法检测是否中断发生

时间上的先后顺序与先行发生的差异

happens-before 关系,并不是说时间上前一个操作必须要先在后一个操作之前执行!而是仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

  • 示例
1
2
3
4
5
6
7
8
9
private int value = 0;

public void setValue(int value){
this.value = value;
}

public int getValue(){
return value;
}

这是一个简单的 getter/setter 方法,假设线程 A 在时间上先调用了 setValue(1) ,线程 B 调用了同一个对象的 getValue ,那么线程 B 的返回值是什么? 根据 happens-before 的各项规则,可以看到 8 项规则都不符合,也就是这个操作是并不是线程安全的。

想一个具体的例子来演示错误,并且给出修改方案及测试结果?

  • 结论
    时间先后顺序与先行发生原则之间基本没有太大的关系,所以衡量安全并发不要受到时间顺序干扰,必须以先行发生原则为准

volatile 关键字

关键字 volatile 可以理解为 Java 虚拟机提供的最轻量级同步机制。有两种特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

可见性

保证此变量对所有线程的可见性,但是在 Java 中变量复合运算并非原子操作,所以 volatile 变量在并发中是不安全的。保证并发安全需要符合如下两条规则:

  • 运算结果并不依赖变量的当前值,或者确保只有单一线程修改变量的值
  • 变量不需要与其他状态变量共同参与不变约束

禁止指令重排序优化

总结

volatile 变量读操作的性能消耗与普通变量几乎没有区别,写操作可能会慢一些,但是总体开销要比锁低。

参考文档:

  1. The Java Language Specification, Java SE 7 Edition-jmm
  2. JSR-133: Java Memory Model and Thread Specification
  3. 深入理解 Java 内存模型
  4. 深入理解 Java 虚拟机