目录
一、Java内存模型的基础
本文参考自Java并发编程艺术
Java的内存模型在编程中可以让我们更好的理解程序的执行情况,因此很有必有了解Java的内存模式。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
1.1 Java内存模型(JMM)的抽象结构
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。JMM抽象图如下
简要分析下:
如果线程A和线程B进行通信,那么线程A会将本地内存A里面的共享变量刷新到主存中,然后线程B通过主存获取刷新后新值;以主存为中介,实现A B两个线程的间接通信。
也就是说必须经历如下两个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
示意图如下:
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
1.2 源代码到指令序列的重排序
程序在执行时,为了提高性能,编译器和处理器会对指令重排序,重排序主要有以下三类:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
二、重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。也就是说,在机器底层执行程序指令的时候,未必是按照我们的程序代码顺序执行的,只有不影响数据的依赖性,指令级别是可以重排序后执行,单线程情况下,并不存在这个影响,多线程情况,则会存在
2.1 数据依赖性
两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
主要有以下三种类型:
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
2.2 重排序对多线程的影响
先看看如下代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}
如果在单线程情况下,并没有什么特别之处;现在我们考虑多线程情况下:以两个线程A和B为例;A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
未必能看到;由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。重排序后的结果可能如下(只是其中一种可能情况):
操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
三、顺序一致性
3.1 顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
因此在程序员眼里,顺序一致性内存模型如下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。
为了更好的理解,接下来我们举例分析下:
假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是:A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是:B1→B2→B3。
假设这两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如下所示(其中一种可能效果)
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图(其中一种):
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
因此,JMM对正确同步的多线程程序的内存一致性做了如下保证。
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
3.2 同步程序的顺序一致性效果
有了前面的知识铺垫后,现在我们讨论在JMM同步程序的顺序一致性问题;先看下面的代码:
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() { // 获取锁
a = 1;
flag = true;
} // 释放锁
public synchronized void reader() { // 获取锁
if (flag) {
int i = a;
} // 释放锁
}
}
顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
来看看该程序在两个内存模型中的执行时序对比:
从这里我们可以看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器做出优化。
三、volatile的内存语义
当声明共享变量为volatile后,对这个变量的读/写将会很特别。下面将介绍volatile的内存语义及volatile内存语义的实现。
3.1 volatile的特性
对volatile变量的读写,实质就是使用同一个锁对这些 单个 读写操作做了同步;直接上代码举例分析
class VolatileFeaturesExample {
volatile long vl = 0L; // 使用volatile声明64位的long型变量
public void set(long l) {
vl = l; // 单个volatile变量的写
}
public void getAndIncrement () {
vl++; // 复合(多个)volatile变量的读/写,即既读又写,是两次操作
}
public long get() {
return vl; // 单个volatile变量的读
}
}
如果有多个线程调用了上面程序的三个方法,那么的等价于如下程序:
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通变量
public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
vl = l;
}
//该方法没有使用synchronized 是因为这里面有对vl的读和写,不是仅仅对vl操作一次读或者写
public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
// 如果在此时cpu被其他线程调度,完成vl变量的操作,那么就会出现多线程同步问题
// 这也就是volatile复合操作不具有原子性的本质原因
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}
public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
return vl;
}
}
可以这么理解,对volatile变量的单次操作就相当于加了锁(不是严格意义上的锁,底层使用内存屏障实现的)进行的。对该变量的操作就具有原子性;如果是复合(多个)操作,那么在底层会调用锁机制,分解成单次操作,重复多次完成;因此volatile变量的多次操作整体上并不具有原子性,单个操作才具有原子性。
那什么时候才能用volatile关键字呢?
如果写入变量值不依赖变量当前值,那么就可以用 volatile,举个例子:比如 vl++ ,是获取-计算-写入三步操作,也就是依赖当前值的,所以不能靠volatile 解决问题,但是只对vl进行一次读或者写,那么就可以使用volatile,这也就是为什么通常所说的,volatile 能保证内存可见性,但是不能保证原子性,严格来说,应该是单次具有原子性,复合不具有原子性(下述第二点)。
简而言之,volatile变量自身具有下列特性:
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
3.2 volatile写-读的内存语义
volatile写的内存语义
:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存即(实时与主存同步)。
volatile读的内存语义
:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量(实时读取最新值)。
另外,volatile变量在底层对于内存语义的实现,采取的是保守的内存屏障技术,防止编译器重排序造成的同步不一致性问题。
四、final域的内存语义
4.1 final域的重排序规则
对于final域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
什么意思呢?我们举个例子分析下:
public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public FinalExample () { // 构造函数
i = 1; // 写普通域
j = 2; // 写final域
}
public static void writer () { // 写线程A执行
obj = new FinalExample ();
}
public static void reader () { // 读线程B执行
FinalExample object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读final域
}
}
这里假设一个线程A执行writer()方法,随后另一个线程B执行reader()方法。下面我们通过这两个线程的交互来说明这两个规则。
4.2 写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
现在让我们分析writer()方法。writer()方法只包含一行代码:finalExample=new FinalExample()。这行代码包含两个步骤,如下。
- 构造一个FinalExample类型的对象。
- 把这个对象的引用赋值给引用变量obj。
假设线程B读对象引用与读对象的成员域之间没有重排序,那么我们考虑如下一种情况的执行顺序:
在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。也就是i说,当B线程执行FinalExample object = obj语句时,obj.i并没有在A线程中赋值,此时读取obj.i的值,则是未被初始化之前的值。
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值1还没有写入普通域i)。
4.3 读final域的重排序规则
读final域的重排序与写类似,主要规则如下:
- 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。
- 编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。
也就是说,对对象的final的读,该对象的final一定是初始化后的值。
4.4 注意
final域为一个引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
五、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。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
这只是happens-before规则,并不意味着Java平台的具体实现必须按照该规则,可以是参考该规则,在这规则的基础上,做出一些灵活性的改动。
六、总结
内存模型:
顺序一致性模型是一个理论参考模型。JMM以该模型为基础,做出一些改变,以适应处理器和编译器的优化,提升程序的执行性能。同时JMM是一个言语级的内存模型,处理器内存模型是硬件级别的内存模型。JMM的内存可见性保证:
· 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
· 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
· 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
总之,只要多线程程序是正确同步的,JMM保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。
另外读者觉得以下这篇文章介绍内存模型介绍得十分全面,也对JVM得内存模式做了区别,比较推荐
连接地址https://zhuanlan.zhihu.com/p/29881777