【Java内存模型】Java内存模型(JMM)详解以及并发编程的三个重要特性(原子性,可见性,有序性)
目录
1.Java内存模型(Java memory model JMM)
1.1 什么是JMM(JMM的作用)
JMM(java内存模型Java Memory Model)本身是一种抽象的概念,描述的是一组规则或规范。通过这组规范定义了程序中各个变量的访问方式。Java本身的运行是基于虚拟机的,在虚拟机的规范中,Java定义了一种内存模型,来屏蔽掉硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制的。
由于JVM运行程序的实体是线程,而每个线程,JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而Java内存模型规定中的变量都存储在主内存。主内存是共享数据,所有线程都能访问,但线程对变量的操作(读写值)都必须在工作内存中完成。简单说,就是先读取,再操作,再写回。工作内存存放的是主内存中的副本,线程的通信都需要通过主内存来完成。
1.2 JMM的组成
- 主内存:共享的信息,也就是图中的内存区域,它也就对应着JVM内存区域中的堆
- 工作内存:私有信息,也就是图中的工作空间,基本数据类型,直接分配到工作内存,引用的地址存放在工作内存,引用的对象存放在堆中。局部变量,主内存数据的副本都会被存到工作内存。它对应JVM中的Java栈,每一个线程都有一个私有的工作空间,工作空间的空间大小分配和Java栈是一起的,都是根据需要代码需要在编译期间就确定好的,
- 工作方式:
A线程修改私有数据,直接在工作空间修改
B线程修改共享数据,把数据复制到工作空间中去,在工作空间中修改,修改完成以后,刷新内存中的数据
2.硬件内存架构与java内存模型
2.1 硬件架构
像上面这种图就是多核的机器,每一个CPU都对应着一个cache,cache中的数据是对应CPU私有的,内存中的数据是所有CPU共享的。
在说明Java并发特性之前,先简单了解一下物理计算机中的并发问题,这二者有不少相似之处。物理机对并发的处理方案对于虚拟机也有很大的参考意义。
“并发”在计算机领域内,一直是比较头疼。因为并发不仅仅是计算的事情,也是存储的事情。我们在处理并发时,不可能只靠CPU就能完成,也需要与内存交互,比如读取运算数据,存储运算结果等。
但是,由于CPU的处理效率和内存的处理效率差了几个数量级,计算机不得不引入高速缓存作为内存和CPU之间的缓冲,将运算需要使用的数据复制到缓存中,减少I/O瓶颈,加速运算,当运算完成之后,再将数据从缓存同步回内存中,这样能够提升不少处理的效率。
不过,在引入高速缓存的同时,也带来了另外一个问题——缓存一致性。每个处理器都有自己的高速缓存,而他们又共享同一主内存,当多个处理器任务都是涉及到同一块主内存区域时,就会出现缓存数据不一致的问题。
同时,为了解决一致性的问题,高速缓存就需要遵守一些一致性协议(MSI等协议)来规范对数据的读写。
具体示意图,如下:
注:引入物理计算机并发的概念,主要是为了提供一种思路,实际上的实现远比描述的要复杂。
CPU缓存的一致性问题:并发处理的不同步。比如CPU1将共享数据1改成2,如果CPU1修改完了CPU2再去读取就会读取到2,如果CPU1没有修改完CPU2读取到的就是1,有可能造成脏读的问题,这就是并发处理不同步。
解决方案:
- 总线加锁,直接在总线上锁定该资源只能同时被一个CPU使用,这样就解决了CPU数据同步问题。但是这会降低CPU的吞吐量,降低计算机效率
- 缓存上的一致性协议(MESI协议,用来保证Cache一致性)
当CPU在cache中操作数据时,如果该数据是共享变量,数据从cache读到寄存器中,进行新修改,同时将该数据的cache line标志位置为无效,然后更新内存数据。因为修改完数据之后,该数据以前在其他CPU的cache中就失效了,不能再读取了,将标志置为无效是为了让其他CPU直接去内存中读取最新的该数据,然后再更新自己的cache数据,这样就解决了不同步问题。
JMM和硬件内存结构的工作方式很相似,JMM中的工作空间对应的就是cache和寄存器,JMM中的主内存对应的就是硬件中的内存。
2.2 Java线程与硬件处理器
整个流程就是用户指定任务交给线程池,由线程池去分配进程用来执行这些任务,一个任务也就对应着一个线程,每一个Java线程是需要映射到一个真实的操作系统线程,通过操作系统线程完成任务的,将Java线程映射到操作系统线程,对线程的挂起或唤醒,这些操作都需要操作系统线程来完成,这就需要请求OS内核的帮助,需要操作系统由用户态转变成内核态,给每一个线程分配一个内核线程,然后内核线程被交给CPU进行操作。
模型分为主内存和工作内存,所有的变量(局部变量除外,局部变量都是线程私有的,不存在并发问题)都存储在主内存中。每条线程具有自己的工作内存,其中工作内存中保存了线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接操作主内存中的变量。不同线程之间是无法访问对方的工作内存,线程间变量值的传递均需要通过主内存来完成,示意图如下:
2.3 Java内存模型与硬件内存架构的关系
由上面的讲解可知,工作空间和内存都对应着硬件系统的寄存器,cache和内存。这种交叉的关系也就造成了在多线程并发的环境下很容易出现数据不同步的问题。
注:这里提到的主内存和工作内存,实际上和我们常说的Java内存分为堆、栈、方法区等并不是同一层次的划分,二者基本上没有直接联系。如果一定要勉强对应的话,那主内存主要对应于Java堆中的对象实例部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存直接对应于物理硬件的内存,而工作内存可能优先存储于高速缓存中。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从 工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实 现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说, load、store、read和write操作在某些平台上允许有例外。
- ·lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- ·unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
- ·read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
- ·load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。
- ·use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- ·assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- ·store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
- ·write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
2.4 Java内存模型的必要性
Java内存模型(JMM)的作用:规范内存数据和工作空间数据的交互。来解决数据不同步的问题
关于主内存与工作内存之间具体的交互协议,也就是说,一个变量如何从主内存拷贝到工作内存,又是如何从工作内存同步回到主内存的。Java定义了8种操作来实现的,并且虚拟机保证每一种操作都是原子的。
8种操作分别是lock、unlock、read、load、use、assign、store、write.
上图所示,是两组操作,一组是读取,一组是写入。
值得注意的是,Java模型只要求这两个操作必须是顺序执行,但并没有保证是连续执行,这点区别是非常大的。
也就是说,read和load之间、store和write之间是可以插入其他指令的。
接下来,我们关注一下,Java并发中的三个特性,原子性、可见性和有序性
3.并发编程的三个重要特性
-
3.1 原子性
所谓原子性,是指在一次操作或多次操作中,要么所有的操作全部执行,并不会受到人任何元素的干扰而中断,要么所有的操作都不执行,中间不会有任何上下文切换(context switch)。比如:A给B转账100,A账户扣除100,B账户账户收入100,这两个操作必须符合原子性,要么都成功,要么都失败。所以并发编程就需要将应该是原子操作的一系列操作封装成一个原子操作。
-
3.2 可见性
线程只能操作自己工作空间中的数据,对其他线程的工作空间不可见。但是并发编程中一个线程对共享变量进行了修改,另一个线程要能立刻看到被改后的最新值。
线程1对共享变量的修改如果要被线程2及时看到,需要经过2个步骤:
1. 把工作内存1中更新过的共享变量值刷新到主内存中
2. 把主内存中最新的共享变量的值更新打工作内存2中
以上2个步骤,任意一个出现问题,都会导致共享变量无法被其他线程及时看到,无法实现可见性,导致其他线程读取的数据不准确从而产生线程不安全。
例子:
private int i = 0;
private int j = 0;
//线程1 i = 10;
//线程2 j = i;
线程1修改i的值为10时的执行步骤:
1)将10赋给线程1工作内存中的 i 变量;
2)将线程1工作内存中的 i 变量的值赋给主内存中的 i 变量;
当线程2执行j = i时,线程2的执行步骤:
1)将主内存中的 i 变量的值读取到线程2的工作内存中;
2)将主内存中的 j 变量的值读取到线程2的工作内存中;
3)将线程2工作内存中的 i 变量的值赋给线程2工作内存中的 j 变量;
4)将线程2工作内存中的 j 变量的值赋给主内存中的 j 变量;
如果线程1执行完步骤1,线程2开始执行,此时主内存中 i 变量的值仍然为 0,那么线程2获取到的 i 变量的值为 0,而不是 10。
这就是可见性问题,线程1对 i 变量做了修改之后,线程2没有立即看到线程1修改的值。
-
3.3 有序性
程序中的顺序不一定就是执行的顺序,因为系统会对代码进行一次重排序,重排序的作用就是提高效率。但是虽然指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性,所以并发编程就要保证重排序之后的有序性,执行结果不能因为重排序而出错。重排序有三种:
- 编译器优化的重排序(编译期间):编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序(运行期间):现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
例子:
int x = 1;
int y = 2;
int z;
z = x + y;
int m = 0;
这一段代码int m = 0;这个操作在前面和在后面对程序的最终结果不会有影响,但是z=x+y这个操作本身很耗时,所以int m = 0要等一会才能执行到它,这在并发执行的时候显然单位时间内完成的任务比较少,也就降低了整体的效率,所以系统就会重排序将int m = 0放到上面去提前执行。一般重排序的原则就有将执行操作时间少的指令排在前面执行。其实这个就是计算机组成原理中学的CPU流水线作业,能够大大提高系统吞吐量。
举例一:
int i = 0;
int j = 0;
i = 10; //语句1
j = 1; //语句2
语句可能的执行顺序如下:
1)语句1 语句2
2)语句2 语句1
语句1一定在语句2前面执行吗?答案是否定的,这里可能会发生执行重排(Instruction Reorder)。一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序在单线程环境下最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
举例二:
int i = 0; //语句1
int j = 0; //语句2
i = i + 10; //语句3
j = i * i; //语句4
语句可能的执行顺序如下:
1)语句1 语句2 语句3 语句4
2)语句2 语句1 语句3 语句4
3)语句1 语句3 语句2 语句4
语句3是不可能在语句4之后执行的,因为编译器在进行指令重排时会考虑数据的依赖性问题,语句4依赖于语句3,因此语句3一定在语句4之前执行。
接下来我们说一下多线程环境。
举例三:
private boolean flag = false;
private Context context = null;
//线程1
context = loadContext(); //语句1
flag = true; //语句2
//线程2
while(!flag){
Thread.sleep(1000L);
}
dowork(context);
语句可能的执行顺序如下:
1)语句1 语句2
2)语句2 语句1
由于在线程1中语句1、语句2是没有依赖性的,所以可能会出现指令重排。如果发生了指令重排,线程1先执行语句2,这时候线程2开始执行,此时flag值为true,因此线程2继续执行dowrk(context),此时context并没有初始化,因此就会导致程序错误。
因此可以得出结论,指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性.
处理器不会对存在数据依赖的操作进行重排序。这里数据依赖的准确定义是:如果两个操作同时访问一个变量,其中一个操作是写操作,此时这两个操作就构成了数据依赖。
下面就是JMM在保证有序性时所使用的两个原则:
* as-if-seria(单线程遵循):在单线程中,无论如何重排序,程序执行的结果都应该与代码顺序执行的结果一致(java编译器和处理器运行时都会保证在单线程中遵循as-if-serial规则,多线程存在程序交错执行时,则不遵守)
* happens-before(多线程遵循):在发生操作B之前,操作A产生的影响都能***作B观察到,“影响”包括修改了内存***享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个是多线程中程序运行遵守的原则,保证在多线程环境下程序运行结果不会出错,后面有对其的详细讲解。重排序也就是遵守这个原则。
4.JMM对三个特征的保证
一个正确执行的并发程序,必须具备原子性、可见性、有序性。否则就有可能导致程序运行结果不正确,甚至引起死循环。
4.1 JMM与原子性
- x=10 写 原子性
这种写操作有两种,如果是私有数据具有原子性,如果是共享数据没原子性,因为共享对象还需要一次读操作,将数据从内存中读到工作空间,也就失去了原子性。
- y=x 没有原子性
- 把数据X读到工作空间(原子性)
- 把X的值写到Y(原子性)
- i++ 没有原子性
- 读i到工作空间
- +1;
- 刷新结果到内存
- z=z+1 没有原子性
- 读z到工作空间
- +1;
- 刷新结果到内存
由上面的这些例子可知多个原子性的操作合并到一起没有原子性
JMM对原子性的保证方式:
- Synchronized:同步代码块
- JUC中Lock的lock:加锁
注: JUC 就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包, JDK 1.5 开始出现的。
4.2 JMM与可见性
JMM对可见性的保证方式:
- volatile:在JMM模型上实现MESI协议,也就是在变量使用volatile关键字,一个线程将内存中的有volatile关键字标识的变量拿到工作空间进行修改后,就会通知其他线程再访问这个变量的话直接到内存中去找,就不要在自己的工作空间找了,因为数据已经被修改了。
深入来说,是通过加入内存屏障和禁止重排序优化来实现的:
- 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,会将cup数据强制刷新到主内存中去
- 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,强制缓存器中的缓存失效,每次使用都要去主内存中重新获取数据
通俗地讲,volatile变量在每次被访问的时候,都强迫从主内存中读取该变量的值,而当该变量在发生变化时,又会强迫变量讲最新的值刷新到主内存中,这样,任意时刻,不同的线程总能看到该变量的最新值。
- synchronized:使用synchronized加锁来保证可见性,它会保证两条原则:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程加锁时,将清空工作内存***享变量的值,从而使用共享变量时需要从主内存中重新读取最新值(注意:加锁与解锁需要是同一把锁)
- JUC中Lock的lock
4.3 JMM与有序性
JMM对有序性的保证方式:
- volatile:被加了volatile关键字的变量不会被重排序。
对于volatile修饰的变量:
- volatile之前的代码不能调整到它的后面
- volatile之后的代码不能调整到它的前面
- 霸道(位置不变化)volatile实现可见性的硬件基础就是cache line
- synchronized:被它括起来的代码块内部会进行重排序,但是同步代码块整体在所有代码中的顺序不会改变。
对synchronized同步代码块:
- 同步代码块之前的代码不能调到它后面
- 同步代码块之后的代码不能调到它前面
- JMM对有序保证按照Happens-before原则:
- 程序次序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 管程锁定规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。同步块中线程安全。
- volatile变量规则:对一个volatile变量的写操作happens-before对这个变量的读操作。
- 线程启动规则:Thread.start() happens before 所有操作。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
- 线程终止规则:线程中所有操作都happens-before对此线程的终止检测。
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始
- 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
满足任意一个原则,对于读写共享变量来说,就是线程安全。
时间上的先后与happens-before的关系:
- 一个操作时间上先发生于另一个操作“并不代表”一个操作happen—before另一个操作。
- 一个操作happen—before另一个操作“并不代表”一个操作时间上先发生于另一个操作。