浅析Java内存模型
概述Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量是线程共享的,存在竞争问题的。Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等),都必须在工作内存中进行,而不能直接读写主内存中的变量。(注:这里说的工作内存或
概述
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量是线程共享的,存在竞争问题的。
Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等),都必须在工作内存中进行,而不能直接读写主内存中的变量。(注:这里说的工作内存或本地内存都是虚拟出来的,实质上包括了寄存器、缓存或中间的存储器)
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,三者关系的交互如图所示:
如何交互
那么内存之间是如何交互的呢?JMM定义了8中操作来完成,虚拟机保证每一种操作都是原子性的。
Lock:作用于主内存的变量,它把一个变量标识为线程独占状态
Unlock:作用于主内存的变量,将一个处于锁定状态下的变量释放出来,释放后的变量才可以被其他线程加锁。
Read:作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存。
Load:作用于工作内存的变量,将read操作得到的变量的值放入工作内存中的变量副本中
Use:作用于工作内存的变量,将工作内存中变量的值传递给执行引擎,当虚拟机需要使用时会执行这个操作。
Assign:作用于工作内存的变量,将一个执行引擎接收到的值赋给工作内存中的变量,当虚拟机遇到给变量赋值的字节码时会执行此操作。
Store:将工作内存中的一个变量值传送到主内存中
Write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
重排序
从上面可以大致看出来线程之间通信的流程,但是,在执行程序时,编译器和处理器往往会对指令进行重排序,也就是不一定按照程序写的执行。这往往是为了提高性能,提高并发度。但是对于多线程程序来说,这往往会造成程序执行的结果不一致,所以我们就得需要通过synchronized,volatile等方式进行同步。
内存屏障
由于有重排序,为了保证内存的可见性,java编译器在生成的指令序列中会插入内存屏障指令来禁止特定类型的处理器重排序,JMM把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。 StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
Happens-Before
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这两个操作可以是一个线程之间,也可以是两个线程之间。
Happens-before规则如下:
1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2、监视器锁规则:一个监视器的解锁,happens-before于随后这个监视器的加锁。
3、volatile规则:对于volatile域的写,happens-before于任意后续对这个volatile的读。
4、传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
Happens-before并不是说,两个操作代码上执行时间的先后顺序,而是保证一个操作的结果对另一个操作可见,保证执行结果是顺序的。
数据依赖性
如果两个操作访问同一个变量,且其中两个操作有一个是写操作,此时这两个操作之间就存在数据依赖性。
数据依赖分为三种类型:
写后读:
a=1; b=a;
写后写:
a=1; a=1;
读后写:
b=a; a=1;
所谓的数据依赖性就是指当发生重排序的时候,其结果会发生改变,所以编译器和处理器在重排序的时候,不会改变存在数据依赖性的两个操作的执行顺序
As-if-serial语义:
不管怎么重排序,(单线程)程序的执行结果不能被改变。所以为了遵循这种语义,编译器和处理器不会对存在数据依赖性的操作进行重排序。
控制依赖性:
if(flag) //---1
int i= a*a; //-----2
操作1和操作2之间存在控制依赖,所以在多线程的程序中,编译器和处理器会启动猜测执行
处理器可以提前计算a*a的结果,然后放到一个重排序的缓冲的硬件缓存中,当操作1的条件为真时,将计算结果写入到变量i 中。所以我们会发现在这里对两个操作做了重排序,所以破坏了多线程程序的语义。
但是对于单线程而言,重排序存在控制依赖的操作,不会改变执行结果,但是在多线程中,重排序存在控制依赖的操作,可能会改变执行结果。
顺序一致性:
当程序未使用同步时,就会出现数据竞争,所以会造成结果的改变。
当使用了同步以后,这便是一个没有数据竞争的程序。如果程序是正确使用同步的,那么执行的程序将会具有顺序一致性:即程序的执行结果与在顺序一致性模型中执行的完全相同。
所谓顺序一致性模型是一个理论的参考模型,具有两大特性:
一个线程中的所有操作都必须按照程序的顺序来执行;
无论程序是否同步,所有线程都只能看到一个单一执行顺序,在顺序一致性模型中,每个操作都是原子性的执行而且必须立即对其他线程可见。
JMM不保证对64位的long和double型的变量(没有volatile修饰)读写具有原子性,而内存一致性模型保证对所有的读写操作都具有原子性。
因为在一些32位的处理器上,如果对64位的long和double读写具有原子性,那么需要很大的开销,所以java不强求必须对这两种具有原子性。JVM在这些处理器上运行时,会将一个64位的long/double写操作分成两个32位的写操作来执行,此时对其就无法保证原子性,所以有可能造成读取的时候读了一半数的错误。
volatile
volatile可是看成是弱一级的Synchronized,换句话说就是给volatile变量单个的读写操作,使用同一个锁对这些单个的操作进行了同步。
我们来看一下volatile的效果:- class VolatileFeaturesExample {
- //使用volatile声明64位的long型变量
- volatile long vl = 0L;
- 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;
- }
- public void getAndIncrement () { //普通方法调用
- long temp = get(); //调用已同步的读方法
- temp += 1L; //普通写操作
- set(temp); //调用已同步的写方法
- }
- public synchronized long get() {
- //对单个的普通变量的读用同一个锁同步
- return vl;
- }
- }
我们可以看出来,对Volatile的单个读写操作,与对一个普通变量的读写操作使用同一个锁来同步,之间的效果是一样的。
我们发现及时64位的long和double,只要是volatile,那么该变量的读写就是原子性的,而volatile++这时复合操作,所以不具有原子性。
volatile的性质(可见性和原子性)
可见性:读取一个volatile变量的时候,总是可以看到其他线程对这个变量的最后写入,也就是说每次读取的都是最新值。
原子性:对任意单个volatile变量操作具有原子性
我们可以从可见性得到,volatile可以建立一定意义上的happens-before关系,因为其写优先于读。
执行流程
当线程A写一个volatile变量的时候,JMM会把该变量本地内存中的值刷新到主内存,因此本地内存中的值和主内存中的是一致的。
当线程B读取一个volatile,JMM会将该线程的本地内存置为无效,直接从主内存中读取,这样读取到的值就是刚刚写入的
也就是说线程B在读取volatile变量的时候,线程A之前所有对此变量的操作都对线程B可见
换个角度,我们可以这么说:
线程A在写入一个volatile变量,实际上是对下一个将要读取这个volatile变量的线程B发出了消息,而线程B读取这个volatile变量就是接收了线程A发出的消息。
所以线程A写volatile,线程B读volatile,可以看成线程A通过主内存向线程B发送了消息。
如何实现
我们知道重排序,那么是否会对volatile变量重排序呢,JMM限制了这种变量的重排序规则:
是否能重排序 | 第二个操作 | ||
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
从上表中我们可以看出:
如果第二个操作是volatile的写,无论第一个操作是什么,都不会被编译器或处理器重排序。确保volatile之前的写操作不会被排到其后面。
如果第一个操作是volatile的读,无论第二个操作是什么,都不会被重排序。
如果第一个操作是volatile写,而第二个是volatile读,不能重排序。
volatile为了实现这个规则,编译器在生成字节码的时候,给字节码指令前后都添加了内存屏障,禁止特定类型的处理器重排序。
规则如下:
给每个volatile的写操作前面添加了StoreStore屏障
===》禁止之前的普通写操作与volatile写操作进行重排序
给每个volatile的写操作后面添加了StoreLoad屏障
===》禁止后面的普通读/写操作与volatile写操作重排序
给每个volatile的读操作后面添加了LoadLoad屏障
===》防止后面的普通读操作和volatile读操作的进行重排序
给每个volatile的读操作后面添加了LoadStore屏障
===》防止后面的写操作与volatile读操作进行重排序
在实际情况下可以省略一些屏障,而且屏障的设立和处理器也有很大关系,像X86仅仅会有StoreLoad屏障,因为它只允许写-读操作的重排序。
锁
上文说到了锁可以保证强大的互斥性和同步性
锁同样和volatile一样,也可以建立happens-before关系
比如说线程A只有释放锁了以后,线程B可以获得锁,因此线程释放锁之前对共享变量的修改,在线程B获得锁之后都是可见的。
内存语义
线程A释放锁的时候,JMM会把线程对应的本地内存中的共享变量刷新到主内存中。
当线程B获取锁时,JMM会把线程对应的本地内存置为无效,直接从主内存中读取共享变量。
类似于volatile,锁也有自己的语义,线程A释放锁的时候,其实就是给将要获取这个锁的线程发出了消息
线程B获得锁也就是接收了线程A所发送的消息
这个过程其实就是两个线程通过主内存进行通信
锁的实现
通常是通过ReentrantLock 中的lock方法实现,这个锁一般分为公平锁和非公平锁,默认为非公平锁。
公平锁在释放锁的最后写volatile变量state,在获取锁的时候首先读取volatile变量,所以根据volatile的happens-before规则,释放锁的线程写入的volatile对获取锁的线程可见。
非公平锁会调用compareAndSetState(CAS),这个方法最终调到本地方法上,一些内在的规定使得CAS同时具有volatile读和volatile写的内存语义。
公平锁和非公平锁:
公平锁和非公平锁在释放时,最后都要写一个volatile变量state
公平锁在获取时首先读取这个volatile
而非公平锁在获取时,首先会用CAS更新这个volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
final
对于final域的读写,编译器和处理器需要遵循两个重排序规则:
1、在构造函数中对final域的写入,与随后将此被构对象的引用赋给其他引用,两者之间不能重排序。
2、初次读取一个包含final域的引用和随后读取这个final域,两者之间不能重排序
写final域的重排序规则禁止把final域的写重排序到构造函数之外
编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障,这个屏障禁止final域的写重排序到构造函数外面,
这样可以保证其他任何线程,在引用对象时,对象的final域已经正确初始化(但是普通域有可能没有被初始化)。
编译器会在读final域操作的前面插入一个loadLoad屏障。
这样可以确保,在读取一个对象的final域之前,一定会先读取包含这个final域的对象引用,如果该引用不为null,说明引用对象的final域已经被正确初始化过了
如果final域是一个引用类型,那么会增加一个约束:
在构造函数内对final引用对象的成员域的写入,和在构造函数外把这个被构造对象的引用赋值给另一个引用变量,两者之间不能重排序。
写final域的重排序规则可以确保,在引用变量对其他线程可见之前,该引用变量指向的对象已经在构造函数中被正确初始化过了但是还要有一个保证就是:在构造函数返回之前,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能再构造函数中溢出。
- public class FinalReferenceEscapeExample {
- final int i;
- static FinalReferenceEscapeExample obj;
- public FinalReferenceEscapeExample () {
- i = 1; //1写final域
- obj = this; //2 this引用在此“逸出”
- }
- public static void writer() {
- new FinalReferenceEscapeExample ();
- }
- public static void reader {
- if (obj != null) { //3
- int temp = obj.i; //4
- }
- }
- }
线程A执行write方法,线程B执行read方法。
操作2使得对象在未完成构造前就对线程B可见,所以这有可能使得操作2和操作1重排序,然后线程B就无法正确读取到final域的值
final的实现
上面已经说过,再来总结一下:
写final域的重排序规则要求编译器在 final域的写之后,构造函数return之前插入StroeStore屏障,
读final域的重排序规则要求编译器在读final域的操作前面插入一个loadLoad屏障
总结
JMM把happens-before要求禁止的重排序分成了两种:
对于会改变程序执行结果的重排序,JMM要求编译器和处理器会禁止这种排序
对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(可以重排序)
也可以说,JMM遵循一个原则:
只要不改变程序(单线程程序和多线程正确同步的程序)的执行结果,编译器和处理器怎么优化都可以。
参考
更多推荐
所有评论(0)