JAVA内存模型
这篇日志主要是来记录我在学习java内存模型的时候,需要了解的知识点。关于java内存模型这部分内容网上也有很详细的资料,这篇主要是做一个知识的梳理,总结。
什么是java内存模型?
在学习java内存模型的时候,我去网上找了很多资料,我发现大部分的文章,讲述的java内存模型都是这样子的
主要是就是在介绍关于堆、栈啊,方法区,程序计数器之类的,这里需要明确一点,以上的模型图,是在描述java虚拟机的内存结构,和我们要分析的内存模型并不相同!
为什么要了解java内存模型?
对于并发编程,我们需要解决的主要问题就是,线程之间数据如何同步问题。在共享内存的并发模型里,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通讯。
想要把java内存模型描述清楚我觉得是个比较庞大的工程,所以先从java内存模型当中我们常提到的几个概念入手
指令重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分为以下三种类型
1、编译器优化的重排序--编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序
2、指令级并行的重排序--如果不存在数据依赖,处理器可以改变语句对应机器执行的语句顺序
3、内存系统的重排序--由于处理器使用缓存和读/写缓存,这使得加载和存储操作看上去像是在乱序执行
在执行程序时,java内存模型确保在不同的编译器和不同的处理器平台上,来插入内存屏障来禁止特定的编译器重排序和处理器重排序,从而为上层提供内存一致性的条件。
happens-before
JDK1.5开始,java使用JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM当中,如果一个操作执行的结果需要多另一个操作可见,那么这两个操作之间必须存在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
注意:两个操作之间具有happens-before关系,并不意味着前一个操作一定要在后一个操作后面执行,只需要前一个操作的结果对后一个操作的结果可见
as-if-serial语义
as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
如果数据之间存在依赖性,并不会进行重排序,因为这个重排序会影响执行结果。
基于以上一些概念,我们来看下java当中提供了哪些操作,是可以帮助程序员把代码的并发请求发送到编译器
volatile关键字
volatile主要是为了进行线程之间的通讯,对于共享变量,一个线程对变量的修改,在另外一个线程读取这个变量时,拿到的是最新修改的结果数据。volatile的实现是通过在编译器生成字节码时,在指令序列当中添加内存屏障,来禁止特定类型的指令重排序。
JMM内存屏障的插入策略
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入语一个StoreLoad屏障
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障
因此volatile修饰的变量在进行读写操作时,其前面的变量的相关操作一定是早于volatile变量的操作的,其后面的操作一定是晚于volatile变量的操作,volatile变量的读/写 阻止了指令重排序
final域的内存语义
对于final域,编译器和处理器要遵守两个重排序原则
1、在构造函数内对一个final变量的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作不能重排序
2、初次读一个包含final变量的的对象的引用,与随后初次读这个final对象,这两个操作之间不能重排序
关于这两条举个例子
public class A{
final int i;
int j;
A a;
public A(int i, int j){
this.i = i;
this.j = j;
}
public void getInstance(){
a = new A();
}
public void get(){
A a = a;
int x = a.i;
int y = a.j;
}
}
这个类当中,i是final类型的,对于第一条语义的含义,getInstance方法内是对引用类型A的创建,和a引用指向堆内存,对A变量的创建一定是晚于构造方法当中的对i的赋值,也就是说,对final变量的赋值一定是在A这个引用类型变量之前;第二条语义,我们可以看get方法,其实就是A a = a; 和 int x = a.i ;这两条语句之前一定是先读A a = a; 再去执行 int x = a.i ;
关于final变量写的重排序
也就是说JMM禁止编译器把final变量的写重排序到构造函数之外,具体实现就是,编译器会在final变量的写之后,构造函数return之前插入一个StoreStore屏障,这个屏障会禁止处理器把final变量的写重排序到构造函数之外。
读final变量的重排序
在一个线程当中,初次读对象引用A和初次读对象A所包含的final变量i, JMM禁止这两个操作重排序,编译器会在读final变量的操作的前面插入一个LoadLoad屏障。
上面的变量final是基础类型,如果是final变量是引用类型会怎么样
public class B{
final int[] a;
static B b;
public B(){ //构造函数
a = new int[1]; // 1
a[0] = 1; //2
}
public static void writer(){ //写线程A
b = new B(); //3
}
public static void writer2(){ //写线程B
b.a[0]=2; //4
}
public static void reader(){ //读线程C
if(b != null) //5
int i = b.a[0]; // 6
}
}
对于上面的示例
步骤1,是对final变量的写入,步骤2 是对final变量引用的对象的成员进行赋值,步骤3 是把对象的引用赋值给某个引用变量,基于前面的例子我们了解到1 和 3 不能重排序的,同时2 和 3 也不能重排序
线程C是可以看到线程A在构造函数当中对final变量对象的成员变量的赋值,所以线程C是可以看到数组下标0的值为1,而线程B对数组元素的写入对线程C并不保证可见。
synchronized的内存语义
synchronized关键字所修饰的方法或者代码块,都提供了线程安全的条件,原因是某一时刻只能有一个线程持有对象的锁,除了可以实现线程之间互斥访问的功能,synchronized也提供了线程在同步代码块之间写入的操作,对后面访问代码块的线程而言是可见的。在一个线程退出monitor时,会把本地缓存当中的数据刷新到主内存中去,在进入monitor监视器之前会使缓存当中的数据失效,使得变量从主内存中从新加载数据。
参考:《java并发编程的艺术》
上一篇: java volatile关键字
下一篇: Java的内存模型(JMM)