Java内存模型(Java Memory Model,简称JMM),即Java虚拟机定义的一种用来屏蔽各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能够达到一致的内存访问效果的内存模型。本篇文章大致涉及到五个要点:1、Java内存模型的基础,主要介绍JMM抽象结构;2、Java内存模型中内存屏障;3、Java内存模型中的重排序;4、happens-before原则;JMM相关的三个同步原语(synchronized,volatile,final)。
1.Java内存模型的抽象结构
在java中,共享变量是指所有存储在堆内存中的实例字段,静态字段和数组对象元素,因为堆内存是所有线程共享的数据区。而局部变量,方法定义参数,异常处理参数不会在线程之间共享,它们不存在内存可见性问题,也不会受到Java内存模型的影响。
Java内存模型决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,Java内存模型定义了线程与主内存之间的抽象关系:线程之间的共享变量存储主内存中,每个线程都有一个私有的本地内存,也叫工作内存,本地内存存储了该线程需要读/写的共享变量的副本。本地内存是JMM的一个抽象的概念,其实并不真实存在。Java内存模型的抽象示意图如下:
从上图来看,如果线程A和线程B之间要通信的话,必须要经历下面的两个过程:
1.线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2.线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图说明以上两个过程:
如上图:假设初始时,X的值为0,首先线程A要先从主内存中读取共享变量x的值,并将其副本存储在自己的本地内存。接着线程A要把共享变量x的值更新为1,也就是先把本地内存中的x的副本的值更新为1,然后再把本地内存中刚更新过的共享变量刷新到主内存,此时主内存中共享变量x的值为1。然后线程A向线程B发送通知:哥们儿,我已更新了共享变量的值。
随后,线程B接收到线程A发送的通知,也从主内存中读取共享变量x的值,并将其副本存储在自己的本地内存,接着线程B也要修改共享变量的值,先将本地内存B中的副本x修改为2,再将本地内存中的x的值刷新到主内存,此时主内存中共享变量x的值就被更新为了2。
从整体上来看,上述的两个过程实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
2.Java内存模型的内存屏障
为了保证内存的可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令类禁止特定类型的处理器重排序,java内存模型(JMM)把内存屏障指令分为4类:
- LoadLoad(Load1,LoadLoad,Load2):确保load1数据的装载先于load2及所有后序装载指令的装载。
- LoadStore(Load1,LoadStore,Store2):确保Load1数据的装载先于Store2及所有后序存储指令刷新内存。
- StoreStore(Store1,StoreStore,Store2):确保Store1数据刷新内存先于Store2及所有后序存储指令刷新内存。
- StoreLoad(Store1,StoreLoad,Load2):确保Store1数据刷新内存先于Load2及所有后序装载指令的装载。该屏蔽指令会使该屏蔽之前的所有内存访问指令执行完成后才执行屏蔽之后的内存访问指令。并且这个指令是一个全能的指令,同时具备以上三个内存屏蔽指令的功能。
3.Java内存模型中的重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。重排序分为3种类型:
- 编译器优化重排序。编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓冲和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从java源代码到最终实际执行的指令序列,会经历下面3种重排序:
上述1属于编译器重排序,编译器将java源码编译成字节码时进行一次重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成字节码指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级别的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的的内存可见性保证。
3.1 数据依赖性
如果两个操作访问同一个共享变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分为以下3种类型:
3.3 重排序对多线程的影响
重排序会可能影响多线程程序的执行结果,请看下面的示例代码:
public class ReorderExample{
int a = 0;
boolean flag = false;
@Test
public void writer(){
a = 1; //1
flag = true; //2
}
@Test
public void reader(){
if(flag){ //3
int i = a; //4
System.out.print(i);
}
}
}
flag变量是个标记,用来标识变量a是否被写入。这里我们假设有两个线程A和B,线程A首先执行writer方法,随后线程B执行reader方法。问题是线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
答案是否定的,并不一定能看到。
由于操作1和操作2不存在数据依赖关系,编译器和处理器可以对这两个操作重排序;同理,操作3和操作4也不存在数据依赖关系,编译器和处理器也可以对这两个操作重排序。下面我们先来看下,当操作1和操作2重排序时,会产生什么效果。程序执行时序图如下:
如上图,操作1和操作2做了重排序。程序执行时,线程A首先将标记变量flag写为true,随后线程B读取这个变量,由于条件为真,线程B将读取共享变量a,而此时,共享变量a还没有被线程A写入,所以多线程程序的语义就被重排序破坏了。
下面再看下,当操作3和操作4重排序时会产生什么效果。下面是操作3和操作4重排序后程序的执行时序图:
在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度,为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取共享变量a,然后会把共享变量a的值保存到一个名为重排序缓冲(Reorder Buffer,ROD)的硬件缓存中。当操作3的条件为真时,就把保存到ROB中的共享变量a的值写入到变量i中。
从上图中我们也可以看出,猜测执行实质上对操作3和操作4做了重排序。重排序破坏了多线程程序的语义。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程中程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
- happens-before原则
从JDK1.5开始,Java使用新的JSR-133内存模型,该模型使用happens-before原则来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在单线程内,也可以在多线程之间。
happens-before规则如下:
- 程序顺序规则(Program Order Rule): 一个线程中的每个操作先行发生于该线程中的后序任意操作。
- 监视器锁规则(Monitor Lock Rule): 对一个锁的解锁先行发生于随后对这个锁的加锁。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写,先行发生于任意后序对这个volatile变量的读。
- 传递性(Transitivity):如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规格(Thread Termination Rule):线程中所有的操作都优先发生于此线程的终止操作。‘’
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象初始化完成先行发生于它的finalize()方法的开始。