Java之JMM内存模型解析

图文来自wx公众号: @小豆知识     关注免费领取任意一门课程

更多教程请访问:    www.xiaodoulearn.com

一、线程与JVM

1.1 基本概念

程序:是指一组指示电子计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上(静态概念)

进程:是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础(动态概念)

线程:是操作系统能夠進行運算调度的最小單位。大部分情况下,它被包含在进程之中,是进程中的實際運作單位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并行多个线程,每条线程并行执行不同的任务。

1.2 JVM与线程

1.2.1 JVM主要结构

       

图片

可以看出,JVM主要包括四个部分:

第一部分:类加载器(Class Loader)

第二部分:执行引擎(Execution Engine)

第三部分:运行时数据区(Runtime Data Area,也称内存区),内存区又包含:

    (1)方法区(Method Area)

    (2)堆(HEAP)

    (3)Java虚拟机栈(Java VM Stack)

    (4)程序计数器(Program Counter Register,亦简称PC Register)

    (5)本地方法栈(Native Method Stack)

第四部分:本地方法接口(Native Interface)

注意点强调:在运行时数据区,方法区和堆是所有Java线程共享的,而Java虚拟机栈、本地方法栈和程序计数器则是每个线程私有的。

1.2.2 JVM是如何启动的

  

1.编写Java实现类

2.编译成.class文件

3.打包成.jar文件

4. 执行命令,java -jar XXX.jar 来启动程序

5.装载配置,由于安装了jdk,jdk里面就有jvm.cfg

6. 根据配置寻找jvm.dll, (linux为libjvm.so),安装的jdk上面也有

7.这个时候JVM已经初始化完成,就会加载我们的class文件

8. 找到main方法并且运行起来。

 1.2.3 JVM内存区域

Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。

方法区(Method Area):

方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。

JVM堆(Java Heap):

Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

程序计数器(Program Counter Register):

属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

虚拟机栈(Java Virtual Machine Stacks):

属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下:

图片

本地方法栈(Native Method Stacks):

本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。

这里之所以简要说明这部分内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同层次的概念。

1.2.4 Java内存模型   Java memory model   JMM(规范,抽象的模型)

    Java内存模型(即Java Memory Model,简称JMM)是一种抽象的概念,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图

图片

需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

弄清楚主内存和工作内存后,接了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的栈帧中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

三、硬件内存架构与java内存模型

 3.1 硬件内存架构

图片

如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,省去了南北桥并将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

 3.2 Java线程与硬件处理器

了解完硬件的内存架构后,接着了解JVM中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图

图片

每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

3.3 Java内存模型与硬件内存架构的关系

通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)

图片

交叉:数据的不一致

四、Java内存模型的必要性

在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。所以Java内存模型的作用:规范内存数据和工作空间数据的交互

五、并发编程的三个重要特性

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。

5.1 原子性

原子性是指在一次的操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。

说起原子性一般都会用银行转账来进行举例说明,比如从Alex的账号往Tina的账号转入1000元,这个动作将包含两个最基本的操作:从Alex的账号上扣除1000元;给Tina的账号增加1000元。这两个操作必须符合原子性的要求,要么都成功要么都失败,总之不能出现Alex的账号扣除了1000元,但是Tina的账号并未增加1000元或者Alex账号未扣除1000元,Tina的账号反倒增加了1000元的情况。

同样在我们编写代码的过程中,比如一个简单的赋值语句:

Object o = new Object();

引用类型o占用四个字节(32位),假设这样的赋值语句不能够保证原子性的话,那么会导致赋值出现错误的数据。

注意:

volatile关键字不保证数据的原子性,synchronized关键字保证,自JDK1.5版本起,其提供的原子类型变量也可以保证原子性。

两个原子性的操作结合在一起未必还是原子性的

5.2 可见性

可见性是指,当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。

这个问题就是volatile关键字引入中的案例问题。Reader线程会将init_value从主内存缓存到CPU Cache中,也就是从主内存缓存到线程的本地内存中,Writer线程对init_value的修改对Reader线程是不可见的。

5.3 有序性

有序性是指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。

int x = 10;int y =0;x++;y = 20;

这段代码定义了两个int类型的变量x和y,对x进行自增操作,对y进行赋值操作,从编写程序的角度来看上面的代码肯定是顺序执行下来的,但是在JVM真正地运行这段代码的时候未必会是这样的顺序,比如y=20语句有可能会在x++语句的前面得到执行,这种情况就是我们通常所说的指令重排序(Instruction Recorder)。

5.3.1 指令重排

一般来说,处理器为了提高程序的运行效率,可能会对输入的代码指令做一定的优化,它不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序来进行,但是它会保证程序的最终运算结果是编码时所期望的那样,比如上文中的x++与y=20不管它们的执行顺序如何,执行完上面的四行代码之后得到的结果肯定都是x=11,y=20。

当然对指令的重排序要严格遵守指令之间的数据依赖关系,并不是可以任意进行重排序的,比如下面的代码片段:

int x = 10;int y = 0;x++;y=x+1;

对于这段代码有可能它的执行顺序就是代码本身的顺序,有可能发生了重排序导致int y=0优先于int x=10执行,但是绝对不可能出现y=x+1优先于x++执行的执行情况,如果一个指令x在执行的过程中需要用到指令y的执行结果,那么处理器会保证指令y在指令x之前执行,这就好比y=x+1执行之前肯定要先执行x++一样。

在单线程情况下,无论怎样的重排序最终都会保证程序的执行结果和代码顺序执行的结果是完全一致的,但是在多线程的情况下,如果有序性得不到保证,那么很有可能就会出现非常大的问题:

private boolean initialized = false;private Context context;public Context load(){  if(!initialized){    context=loadContext();    initialized = true;  }  return context;}

这段代码使用boolean变量initialized来控制context是否已经被加载过了,在单线程下无论怎样的重排序,最终返回给使用者的context都是可用的。

多线程的情况下发生了重排序,比如context=loadContext()的执行被重排序到了initialized=true的后面,那么这将是灾难性的了。比如第一个线程首先判断到initialized=false,因此准备执行context的加载,但是它在执行loadContext()方法之前二话不说先将initialized置为true然后再执行loadContext()方法,那么如果另外一个线程也执行load方法,发现此时initialized已经为true了,则直接返回一个还未被加载成功的context,那么在程序的运行过程中势必会出现错误。

六、 JMM(Java的内存模型)如何保证三大特性

Java的内存模型规定了所有的变量都是存在于主内存(RAM)当中的,而每个线程都有自己的工作内存或者本地内存(这一点很像CPU的Cache),线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接对主内存进行操作,并且每一个线程都不能访问其他线程的工作内存或者本地内存。

比如在某个线程中对变量i的赋值操作i=1,该线程必须在本地内存中对i进行修改之后才能将其写入主内存之中。

6.1 JMM与原子性

在Java语言中,对基本数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值的操作也是原子性的,因此诸如此类的操作是不可被中断的,要么执行,要么不执行。

下面就举几个例子:

x=10;赋值操作

x=10的操作是原子性的,执行线程首先会将x=10写入工作内存中,然后再将其写入主内存(有可能在往主内存进行数值刷新的过程中其他线程也在对其进行刷新操作,比如另外一个线程将其写为11,但是最终的结果肯定要么是10,要么是11,不可能出现其他情况,单就赋值语句这一点而言其是原子性的)。

y=x;赋值操作

这条操作语句是非原子性的,因为它包含如下两个重要的步骤。

执行线程从主内存中读取x的值,然后将其存入当前线程的工作内存之中(如果x已经存在于执行线程的工作内存中,则直接获取)。

在执行线程的工作内存中修改y的值为x,然后将y的值写入主内存之中。

虽然前两步都是原子类型的操作,但是合在一起就不是原子操作了。

y++;自增操作

这条操作语句是非原子性的,因为它包含三个重要的步骤:

执行线程从主内存中读取y的值(如果y已经存在于执行线程的工作内存中,则直接获取),然后将其存入当前线程的工作内存之中。

执行线程工作内存中为y执行加1操作。

将y的值写入主内存。

z=z+1;加1操作(与自增操作等价)

执行线程从主内存中读取z的值(如果z已经存在于执行线程的工作内存中,则直接获取),然后将其存入当前线程的工作内存之中。

在执行线程工作内存中为z执行加1操作。

将z的值写入主内存。

我们可以发现只有第一种操作即赋值操作具备原子性,其余的均不具备原子性,由此我们可以得出以下几个结论:

多个原子性的操作在一起就不再是原子性操作了。

简单的读取与赋值操作是原子性的,将一个变量赋给另外一个变量的操作不是原子性的。

Java内存模型(JMM)只保证了基本读取和赋值的原子性操作,其他的均不保证,如果想要使得某些代码片段具备原子性,需要使用关键字synchronized,或者JUC中的lock。如果想要使得int等类型自增操作具备原子性,可以使用JUC包下的原子封装类型java.util.concurrent.atomic.*

volatile关键字不具备保证原子性的语义。

6.2 JMM与可见性

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。

这里就存在一个问题:由于是什么时候最新的值会被刷新至主内存中是不确定的,所以就不无法保证数据的一致性。

java提供了三种方式保证可见性:

使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。

通过synchronized关键字能够保证可见性,synchronized关键字能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还会确保在锁释放之前,会将对变量的修改刷新到主内存当中。

通过JUC提供的显式锁Lock也能够保证可见性,Lock的lock方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放(Lock的unlock方法)之前会将对变量的修改刷新到主内存当中。

6.3 JMM与有序性

在Java的内存模型中,允许编译器和处理器对指令进行重排序,在单线程的情况下,重排序并不会引起什么问题,但是在多线程的情况下,重排序会影响到程序的正确运行,Java提供了三种保证有序性的方式

使用volatile关键字来保证有序性。

使用synchronized关键字来保证有序性。

使用显式锁Lock来保证有序性。

后两者采用了同步的机制,同步代码在执行的时候与在单线程情况下一样自然能够保证顺序性(最终结果的顺序性)。

6.3.1 Happens-before原则

此外,Java的内存模型具备一些天生的有序性规则,不需要任何同步手段就能够保证有序性,这个规则被称为Happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就无法保证有序性,也就是说虚拟机或者处理器可以随意对它们进行重排序处理。

那么什么是Happens-before原则呢?

程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生于编写在前面的操作之后。程序按照编写的顺序来执行,但是虚拟机还是可能会对程序代码的指令进行重排序,只要确保在一个线程内最终的结果和代码顺序执行的结果一致即可。

锁定规则:无论是在单线程还是在多线程的环境下,如果同一个锁是锁定状态,那么必须先对其执行释放操作之后才能继续进行lock操作。

volatile变量规则:对一个变量的写操作要早于对这个变量之后的读操作。如果一个变量使用volatile关键字修饰,一个线程对它进行读操作,一个线程对它进行写操作,那么写入操作肯定要先行发生于读操作

传递规则:如果操作A先于操作B,而操作B又先于操作C,则可以得出操作A肯定要先于操作C,这一点说明了happens-before原则具备传递性。

·线程启动规则:Thread对象的start()方法先行发生于对该线程的任何动作,这也是我们在第一部分中讲过的,只有start之后线程才能真正运行,否则Thread也只是一个对象而已。

线程中断规则:对线程执行interrupt()方法肯定要优先于捕获到中断信号,这句话的意思是指如果线程收到了中断信号,那么在此之前势必要有interrupt()。

线程的终结规则:线程中所有的操作都要先行发生于线程的终止检测,通俗地讲,线程的任务执行、逻辑单元执行肯定要发生于线程死亡之前。

对象的终结规则:一个对象初始化的完成先行发生于finalize()方法之前,这个更没什么好说的了,先有生后有死。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值