Java
内存模型(Java Memory Model, JMM
),本文主要参考 JSR -133
内存模型
基本概念
共享变量
- 堆内存:包含类实例、静态字段和数组元素
- 方法区:类字段(全局变量)
堆内存中的变量和方法区的全局变量,在线程之间共享,这些统称为共享变量。其他如虚拟机栈帧(局部变量表,方法返回值)等都是线程私有的
抽象示意图
内存模型决定一个线程对共享变量的写入何时对另一个线程可见,从抽象的角度来看:线程之间的共享变量存储在主内存中,每个线程都有一个私有的工作内存,工作内存中存储了该线程以读/写共享变量的副本。这里仅仅将 JMM
作为一个抽象概念,并不和物理实际内存、高速缓存、寄存器等做一一对应。内存模型抽象示意图:
内存间交互操作
定义了 8 中操作,每种都是原子的、不可再分的(对于 double
和 long
来说理论上有例外,但是商用虚拟机上不会存在这个现象)
lock
锁定:作用于主内存的变量,标注该变量为一条线程独占状态unlock
解锁:作用于主内存的变量,标注该变量被解锁,其他线程能使用read
读取:作用于主内存的变量,它把变量从主内存传输到线程的工作内存中,供load
使用load
载入:作用于工作内存的变量,它把read
操作的变量放入工作内存的变量副本中use
使用:作用于工作内存的变量,每当虚拟机需要使用该变量时,传递给执行引擎assign
赋值:作用于工作内存的变量,每当虚拟机需要给变量赋值时,从执行引擎中读取并赋值给工作内存变量副本store
存储:作用于工作内存的变量,它把工作内存的变量副本传递给主内存中,供write
使用write
写入:作用于主内存的变量,它把store
操作从工作内存中得到的变量放入主内存的变量中
long
和 double
的非原子协定
long
和 double
的非原子协定(Nonatomic Treatment of double and long Variables
):对于 64 位的 long
和 double
类型,允许虚拟机将没有被 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
),作用是使得本CPU
的Cache
写入内存,同时会引起其他CPU Cache
变得无效;相当于Java
内存模式中的store/write
操作,所以可以确保volatile
变量的修过对其他CPU
立即可见 - synchronized
同步块对一个变量的执行unlock
操作前,必须先把此变量同步写回主内存中 - final
变量在构造器中一旦初始化完成,那在其他线程中就能看到这个字段的值
有序性(Ordering
)
如果在本线程中观察,所有的操作都是有序的(As-If-Serial
语义) ;如果在一个线程中观察另外一个线程,所有的操作都是无序的(重排序,包括指令重排序和内存同步延迟)
关键字 volatile
和 synchronized
两个关键字来保证线程之间的操作的有序性:
- volatile
关键字禁止了指令重排序 - synchronized
一个变量在同一个时刻只允许一条线程对其进行lock
操作,决定了持有同一个锁的两个同步块只能串行地进入
总结
synchronized
关键字能满足三种特性;volatile
能部分满足,在有条件情况下可以满足并发安全
重排序
概念
在执行程序时为了提高性能,编译器和处理器常常会改变指令执行顺序或者内存操作顺序,也就是重排序。重排序分三种类型:
- 编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序 - 指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序 - 内存系统的同步延迟
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
其中:1 属于编译器重排序,2 和 3 属于处理器重排序
影响
- 重排序都可能会导致多线程程序出现内存可见性问题
JMM
属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
原则
不管怎么重排序,单线程程序执行的结果不能改变。也就是内存模型中描述的:线程内表现为串行的语义(Within-Thread As-If-Serial Semantics
)。
示例
- 指令执行重排序
1 | int a = 1; |
如上两条指令无论是在编译器优化重排序,还是处理器优化并发执行,并不会影响重排序后的执行结果
- 内存操作重排序
根据JMM
定义,各线程会使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致
1 | Processor A Processor B |
处理器 A
和处理器 B
可以同时把共享变量写入自己的写缓冲区(A1,B1
),然后从内存中读取另一个共享变量(A2,B2
),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3
)。当以这种时序执行时,程序就可以得到 x = y = 0
的结果
数据依赖性
指当前操作依赖前一次操作的结果。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。注意:数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
先行发生原则 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 | private int value = 0; |
这是一个简单的 getter/setter
方法,假设线程 A
在时间上先调用了 setValue(1)
,线程 B
调用了同一个对象的 getValue
,那么线程 B
的返回值是什么? 根据 happens-before
的各项规则,可以看到 8 项规则都不符合,也就是这个操作是并不是线程安全的。
想一个具体的例子来演示错误,并且给出修改方案及测试结果?
- 结论
时间先后顺序与先行发生原则之间基本没有太大的关系,所以衡量安全并发不要受到时间顺序干扰,必须以先行发生原则为准
volatile
关键字
关键字 volatile
可以理解为 Java
虚拟机提供的最轻量级同步机制。有两种特性:
- 保证此变量对所有线程的可见性
- 禁止指令重排序优化
可见性
保证此变量对所有线程的可见性,但是在 Java
中变量复合运算并非原子操作,所以 volatile
变量在并发中是不安全的。保证并发安全需要符合如下两条规则:
- 运算结果并不依赖变量的当前值,或者确保只有单一线程修改变量的值
- 变量不需要与其他状态变量共同参与不变约束
禁止指令重排序优化
总结
volatile
变量读操作的性能消耗与普通变量几乎没有区别,写操作可能会慢一些,但是总体开销要比锁低。