JVM学习(3)——总结Java内存模型

时间:2023-01-11 21:24:56

俗话说,自己写的代码,6个月后也是别人的代码……复习!复习!复习!涉及到的知识点总结如下:

  • 为什么学习Java的内存模式
  • 缓存一致性问题
  • 什么是内存模型
  • JMM(Java Memory Model)简介
  • volatitle关键字
  • 原子性
  • 可见性
  • 有序性
  • 指令重排
  • 先行发生——happen-before原则
  • 解释执行和编译执行
  • 其他语言(c和c++)也有内存模型么?

 


  为什么需要关注Java内存模型?

    之前有一个我实习的同事(已经工作的)反讽我:学(关注)这个有什么用?   我没有回答,我牢记一句话:大天苍苍兮大地茫茫,人各有志兮何可思量。我只知道并发程序的bug非常难找。它们常常不会在测试中发现,而是直到程序运行在高负荷的情况下或者长期运行之后才发生,但是那时候再修复的代价是很大的,且也非常难于重现和跟踪。故开发,维护人员需要花费比之前更多的努力,去提前保证程序是正确同步的。而这不容易,但是它比前者——调试一个没有正确同步的程序要容易的多。   本文肯定不会,也不可能全面深入的总结完每个Java内存模型的知识点,只是作为熟悉JVM的内存模型,而内部的一些具体的原理和细节,之后开专题总结之。     缓存一致性问题

  众所周知,计算机某个运算的完成不仅仅依靠cpu及其寄存器,还要和内存交互!cpu需要读取内存中的运行数据,存储运算结果到内存中……其中很自然的也是无法避免的就涉及到了I/O操作,而常识告诉我们,I/O操作和cpu的运算速度比起来,简直没得比!前者远远慢于后者(书上说相差几个数量级!),前面JVM学习2也总结了这个情景,人们解决的方案是加缓存——cache(高速缓存),cache的读写速度尽可能的接近cpu运算速度,来作为内存和cpu之间的缓冲!旧的问题解决了,但是引发了新的问题!如果有多个cpu怎么办?

  现代操作系统都是多核心了,如果多个cpu和一块内存进行交互,那么每个cpu都有自己的高速缓存块……咋办?也就是说,多个cpu的运算都访问了同一块内存块的话,可能导致各个cpu的缓存数据不一致!if发生了上述情景,then以哪个cpu的缓存为主呢?为了解决这个问题,人们想到,让各个cpu在访问缓存时都遵循某事先些规定的协议!因为无规矩不成方圆!如图(现在可以回答什么是内存模型了):

JVM学习(3)——总结Java内存模型

  什么是内存模型?

  通俗的说,就是在某些事先规定的访问协议约束下,计算机处理器对内存或者高速缓存的访问过程的一种抽象!这是物理机下的东西,其实对虚拟机来说(JVM),道理是一样的!

 

  什么是Java的内存模型(JMM)?

  教科书这样写的:JVM规范说,Java程序在各个os平台下必须实现一次编译,到处运行的效果!故JVM规范定义了一个模型来屏蔽掉各类硬件和os之间内存访问的差异(比如Java的并发程序必须在不同的os下运行效果是一致的)!这个模型就是Java的内存模型!简称JMM。

  让我通俗的说:Java内存模型定义了把JVM中的变量存储到内存和从内存中读取出变量的访问规则,这里的变量不算Java栈内的局部变量,因为Java栈是线程私有的,不存在共享问题。细节上讲,JVM中有一块主内存不是完全对应物理机主内存的那个概念,这里说的JVM的主内存是JVM的一部分,它主要对应Java堆中的对象实例及其相关信息的存储部分)存储了Java的所有变量。且Java的每一个线程都有一个工作内存对应Java栈),里面存放了JVM主内存中变量的值的拷贝!且Java线程的工作内存和JVM的主内存独立!如图:

JVM学习(3)——总结Java内存模型

  当数据从JVM的主内存复制一份拷贝到Java线程的工作内存存储时,必须出现两个动作:

  1. 由JVM主内存执行的读(read)操作
  2. 由Java线程的工作内存执行相应的load操作

  反过来,当数据从线程的工作内存拷贝到JVM的主内存时,也出现两个操作:

  1. 由Java线程的工作内存执行的存储(store)操作;
  2. 由JVM主内存执行的相应的写(write)操作

  read,load,store,write的操作都是原子的,即执行期间不会被中断!但是各个原子操作之间可能会发生中断对于普通变量,如果一个线程中那份JVM主内存变量值的拷贝更新了,并不能马上反应在其他变量中,因为Java的每个线程都私有一个工作内存,里面存储了该条线程需要用到的JVM主内存中的变量拷贝!(比如实例的字段信息,类型的静态变量,数组,对象……)如图:

JVM学习(3)——总结Java内存模型

A,B两条线程直接读or写的都是线程的工作内存!而A、B使用的数据从各自的工作内存传递到同一块JVM主内存的这个过程是有时差的,或者说是有隔离的!通俗的说他们之间看不见!也就是之前说的一个线程中的变量被修改了,是无法立即让其他线程看见的!如果需要在其他线程中立即可见,需要使用 volatile 关键字。现在引出volatile关键字:

 


 

  volatile 关键字是干嘛的?举例说明。

  前面说了,各个线程之间的变量更新,如果想让其他线程立即可见,那么需要使用它,故volatile字段是用于线程间通讯的特殊字段。每次读volatile字段都会看到其它线程写入该字段的最新值!也就是说,一旦一个共享变量(成员、静态)被volatile修饰,那么就意味着:a线程修改了该变量的值,则这个新的值对其他线程来说,是立即可见的!先看一个例子:

  这段代码会完全运行正确么?即一定会中断么?

 

JVM学习(3)——总结Java内存模型JVM学习(3)——总结Java内存模型
//线程A
boolean stop = false;

while(!stop){
doSomething();
}

//=========
//线程B
stop = true;
View Code

 

  有些人在写程序时,如果需要中断线程,可能都会采用这种办法。但是这样做是有bug的!虽然这个可能性很小,但是只要一旦bug发生,后果很严重!前面已经说了,Java的每个线程在运行过程中都有自己的工作内存,且Java的并发模型采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明,这也是为什么如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,则很可能会遇到各种奇怪的并发问题的原因。针对本题的A、B线程,如果他们之间通信,画成图是这样的:

JVM学习(3)——总结Java内存模型

那么线程A和B需要通信的时候,第一步A线程会将本地工作内存中的stop变量的值刷新到JVM主内存中,主内存的stop变量=false,第二步,线程B再去主内存中读取stop的拷贝,临时存储在B,此时B中工作内存的stop也为false了。当线程B更改了stop变量的值为true之后,同样也需要做类似线程A那样的工作……但是此时此刻,恰恰B还没来得及把更新之后的stop写入主存当中(前面说了各个原子操作之间可以中断),就转去做其他事情了,那么线程A由于不知道线程B对stop变量的更改,因此还会一直循环下去。这就是死循环的潜在bug!

  从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的工作内存之间的交互,来为java程序员提供内存可见性保证。但是它们之间不是立即可见的

  如果stop使用了volatile修饰,会使得:

  • B线程更新stop值为true,会强制将修改后的值立即写入JVM主内存,不许原子操作之间中断。
  • 线程B修改stop时,也会让线程A的工作内存中的stop缓存行失效!因为A线程的工作内存中JVM主内存的stop的拷贝值缓存行无效了,所以A线程再次读取stop的值会去JVM主内存读取

这样A得到的就是最新的正确的stop值——true。程序完美的实现了中断。很多人还认为,volatile这么好,它比锁的性能好多了!其实这不是绝对的,很片面,只能说volatile比重量级的锁(Java中线程是映射到操作系统的原生线程上的,如果要唤醒或者是阻塞一条线程需要操作系统的帮忙,这就需要从用户态转换到核心态,而状态转换需要相当长的时间……所以说syncronized关键字是java中比较重量级的操作)性能好,而且valatile万万不能代替锁,因为它不是线程安全的,既volatile修饰符无法保证对变量的任何操作都是原子的!鉴于主要涉及了Java的并发编程,之后再开专题总结)。

  

  什么是原子性?

  在Java中,对基本数据类型的变量的操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。看例子:

JVM学习(3)——总结Java内存模型JVM学习(3)——总结Java内存模型
1 int x = 10;         //语句1
2 y = x; //语句2
3 x++; //语句3
4 x = x + 1; //语句4
View Code

  这几个语句哪个是原子操作?

 

  其实只有语句1是原子性操作,其他三个语句都不是原子性操作。语句1是直接将数值10赋值给x,也就是说线程执行这个语句会直接将数值10写入到工作内存中。线程执行语句2实际上包含2个操作,它先要去主内存读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

  不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

 

  何时使用volatile关键字?

     通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。比如boolean类型的标记变量。

  前面只是大概总结了下Java的内存模式和volatile关键字,不是很深入,留待后续并发专题补充。下面接着看几个之前和之后会遇到的概念:

  

  到底什么是可见性?如何保证?

  大白话就是一个线程修改了变量,其他线程可以立即能够知道。保证可见性可以使用之前提到的volatile关键字(强制立即写入主内存,使得其他线程共享变量缓存行失效),还有重量级锁synchronized (也就是线程间的同步,unlock之前,写变量值回主存,看作顺序执行的),最后就是常量——final修饰的(一旦初始化完成,其他线程就可见)。其实这里忍不住还是补充下,关键字volatile 的语义除了保证不同线程对共享变量操作的可见性,还能禁止进行指令重排序!也就是保证有序性。这样又引出一个问题:     什么是有序性和重排序?   还是大白话,在本线程内,所有的操作看起来都是有序的,但是在本线程之外(其他线程)观察,这些操作都是无序的。涉及到了:
  • 指令重排(破坏线程间的有序性)
  • 之前说的工作内存和主内存同步延时(也就是线程A先后更新两个变量m和n,但是由于线程工作内存和JVM主内存之间的同步延时,线程B可能还没完全同步线程A更新的两个变量,可能先看到了n……对于B来说,它看A的操作就是无序的,顺序无法保证)。

 

  谈谈对指令重排的理解

  要知道,编译器和处理器会尽可能的让程序的执行性能更优越!为此,他们会对一些指令做一些优化性的顺序调整!比如有这样一个可重排语句: JVM学习(3)——总结Java内存模型JVM学习(3)——总结Java内存模型
a=1;
b
=2;
View Code

先给a赋值,和先给b赋值,其实没什么区别,效果是一样的,这样的代码就是可重排代码,编译器会针对上下文对指令做顺序调整,哪个顺序好,就用哪个,所以实际上两句话怎么个执行顺序,是不一定的。

  有可重排就自然会有不可重排,首先要知道Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够保证有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。反之遵循了happen-before原则,JVM就无法对指令进行重排序(看起来的)。这样又引出了一个新问题:

 

  什么是先行发生原则happens-before?

  下面就来具体介绍下happens-before(先行发生原则,这里的先行和时间上先行是两码事;):

  • 程序次序规则在一个线程内,书写在前面的操作先行发生于书写在后面的操作,就像刚刚说的,一段代码的执行在单个线程中看起来是有序的,程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作,这是一条比较重要的规则。就是说如果一个线程先去写一个volatile变量,然后另一个线程去读取,那么写入操作肯定会先行发生于读操作。
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C,实际上就是体现happens-before原则具备传递性。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,Thread.join()。
  • 对象终结规则:一个对象的初始化完成(构造器执行结束)先行发生于他的finalize()方法的开始

  前4条规则是比较重要的,后4条规则都是常识。

  比如像如下这样的线程内的串行语义()是不可重排语句:

  • 写后读   
a = 1;
b
= a;// 写一个变量之后,再读这个变量
  • 写后写  
a = 1;
a
= 2; // 写一个变量之后,再写这个变量。
  • 读后写  
a = b;
b
= 1; // 读一个变量之后,再写这个变量。

以上语句不可重排,单线程的程序看起来执行的顺序是按照代码顺序执行的,这句话要正确理解:JVM实际上还是可能会对程序代码不存在数据依赖性的指令进行指令重排序,虽然进行重排序,但是最终执行的结果是与单线程的程序顺序执行的结果一致的。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。对于多线程环境,编译器不考虑多线程间的语义。看一个例子:

JVM学习(3)——总结Java内存模型JVM学习(3)——总结Java内存模型
 1 class OrderExample {
2 private int a = 0;
3
4 private boolean flag = false;
5
6 public void writer() {
7 a = 1;
8 flag = true;
9 }
10
11 public void reader() {
12 if (flag) {
13 int i = a + 1;
14 }
15 }
16 }
View Code

让线程A首先执行writer()方法,接着让线程B线程执行reader()方法,线程B如果看到了flag,那么就可能会立即进入if语句,但是在int i=a+1处不一定能看到a已经被赋值为1,因为在writer中,两句话顺序可能打乱!有可能对于B线程,它看A是无序的!编译器无法保证有序性。因为A完全可以先执行flag=true,再执行a=1,不影响结果!如图:

JVM学习(3)——总结Java内存模型

  也就是说多线程之间无法保证指令的有序性!先行发生原则的程序次序有序性原则是针对单线程的。也就是说,如果是一个线程去先后执行这两个方法,完全是ok的!符合happens-before原则的第一条——程序次序有序性,故不存在指令重排问题。

  如何解决呢?还是套用先行发生原则,看第二条锁定原则,我们可以使用同步锁:

JVM学习(3)——总结Java内存模型JVM学习(3)——总结Java内存模型
class OrderExample {
private int a = 0;

private boolean flag = false;

public synchronized void writer() {
a
= 1;
flag
= true;
}

public synchronized void reader() {
if (flag) {
int i = a + 1;
}
}
}
View Code

因为写、读都加锁了,他们之间本质是串行的,即使线程A占有写锁期间,JVM对写做了指令重排也没关系,因为此时锁被A拿了,B线程无法执行读操作,直到A线程把写操作执行完毕,释放了该锁,B线程才能拿到这同一个对象锁,而此时,a肯定是1,flag也必然是true了。此时必然是有序的。通俗的说,同步后,即使做了重排,因为互斥的缘故,reader 线程看writer线程也是顺序执行的。

JVM学习(3)——总结Java内存模型 

 

  其他语言(c和c++)也有内存模型么?

  大部分其他的语言,像C和C++,都没有被设计成直接支持多线程。这些语言对于发生在编译器和处理器平台架构的重排序行为的保护机制会严重的依赖于程序中所使用的线程库(例如pthreads),编译器,以及代码所运行的平台所提供的保障。


 

  最后补充下一个问题:Java的字节码两种运行方式——解释执行和编译执行

  • 解释运行:解释执行以解释方式运行字节码,解释执行的意思是:读一句执行一句。
  • 编译运行(JIT):将字节码编译成机器码,直接执行机器码,是在运行时编译(不是代码写完了编译的),编译后性能有数量级的提升(能差10倍以上)