Java J.U.C并发包(2)——从JVM的角度来理解JMM

Java内存区域

Java内存区域是真实存在的,是JVM再运行程序期间将自动管理的内存划分为5个不同的区域。Java内存区域划分图如下所示:

这里写图片描述

线程数据私有区域:随着线程产生和消亡,不需要过多考虑内存回收问题,在编译时确定所需内存大小

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

    1. 方法区大小不必是固定的,也不一定是连续的
    2. 方法去可以被垃圾收集,当某一个类不被使用时,JVM将进行垃圾收集
    3. 方法区存放的内容:
      (1)类的全路径名
      (2)类的直接超类的全路径名
      (3)类的类型(是类还是接口)
      (4)类的访问修饰符 :public private等
      (5)类的直接接口全限定名的有序列表
      (6)常量池(字段、方法信息、静态变量、类型引用class)
      类变量:是静态变量,再方法区中有一个静态区,专门用来存储静态变量和静态块

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

栈:在JVM中栈用来存储一些对象的引用,局部变量以及计算过程的中间数据。在方法退出后,这些变量也会被销毁。它的存储比堆快得多,只比CPU里的寄存器慢,在JVM中默认的大小为1M

  • 程序计数器:

此区域时唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。
属于线程私有数据区,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、县城回复等基础功能都需要以来这个计数器来完成。

  • 虚拟机栈:

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

内存模型JMM(Java Memory Mode)

简介

由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:讲计算需要使用到的数据复制到缓存中,让计算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就不需要等待缓慢的内存读写了。

基于告诉缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。再多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享一个内存,如下图所示:多个处理器都涉及到同一块贮存,需要一种协议可以保障数据的一致性,这类协议又:MESI、MESI、MOSI及Dragon Protocol等

这里写图片描述

除此之外,为了使处理器内部的运算单元尽可能被充分利用,处理器可能会对输入代码进行乱序执行(Out of Order Execution)优化,处理器会在计算之后将堆乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java虚拟机的即时编译器也有类似的指令重排序(instruction Recorder)优化。

Java内存模型

内存模型可以理解为在特定的操作写一下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型JJM(Java Memory Mode)。在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问错误。而JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,使得Java程序能够“一次编译,到处运行”

主内存和工作内存

JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(又的地方称其为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问主内存,线程想要对变量进行操作(读取赋值等操作)必须在工作内存中进行。首先将变量从主内存中拷贝到自己的工作内存中,然后在工作内存中对变量进行操作,操作完成后再将变量写回主内存,不可以直接操作主内存中的变量。因为工作内存是每个线程的私有区域,因此不同线程之间无法访问对方线程的工作内存,线程之间的通信需要通过主内存。

这里写图片描述

在某些地方,主内存被描述为堆内存,工作内存被程唯线程栈

  • 主内存
    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存在,不管该实例对象是成员变量还是方法中的局部变量,当然也包括了共享的类信息、常量、静态变量。

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

内存交互操作

JMM模型定义了八种操作来完成从主内存到工作内存中数据的传递

  • (1)lock(锁定):作用于主内存的变量,把一个变量标志位一条线程独占状态
  • (2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • (3)read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • (4)load(载入):作用于工作内存变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • (5)use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  • (6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • (7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write操作。
  • (8)write(写入):早哟用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

JVM中只要求从主内存中复制到工作内存,需要按顺序执行:(read与store);如果从工作内存中同步回主内存,就要按顺序执行:(store与write)。JMM只要求上述两类操作必须按照顺序执行,而没有保证必须是连续执行。即read和store之间,可以插入其他的指令。JMM还规定在执行上述八种基本操作时,必须满足如下规则:

  • (1)不允许read和load、store和write操作之一单独出现
  • (2)不允许一个线程丢弃它最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
  • (3)不允许一个线程无原因的(没有发生过任何assign操作)把数据从工作内存同步到主内存中
  • (4)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量实施use和store之前,必须限制性assign和load操作
  • (5)一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • (6)如果在主内存中对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量的值
  • (7)如果一个变量事先没有被lock操作锁定,则不允许对它进行unlock操作,也不允许去unlock一个被其他进行锁定的变量
  • (8)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

Java中使用一种先行发生(happens before)原则来确定一个内存访问在并发环境下是否安全

JMM的存在的意义

使用多线程产生的一个基本的问题就是:线程间变量的共享

Java中的变量共分为3类:
(1)类变量
(2)实例变量
(3)局部变量(方法里声明的变量)

假如:主存变量中存在一个共享变量x=1,现在有A和B两个两个线程分别对该变量x进行加1操作和读取操作,A和B线程各自的工作内存中存在共享变量副本x。假设现在A线程要进行+1操作,B线程想进行读取X操作,那么B线程读取到的值是A线程更新后的x的值呢还是更新之前x的值呢?答案是:不确定;B可能读到的x的值是A更新前的1,也可能是更新后的2。这样就有可能造成主内存与工作内存间数据存在不一致性,这就是所谓的线程安全问题。

为了解决线程安全问题,JVM执行了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也成为Java内存模型JMM,JMM是围绕着程序执行的原子性、有序性、可见性展开的。

原子性、有序性和可见性

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。

指令重排的分类

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

  • 编译器优化重排(属于编译器重排)
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令并行的重排(属于处理器重排)
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统的重排(属于处理器重排)
    由于处理器使用缓存,这使得load和store操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

编译器重排

例子:
这里写图片描述

程序的执行结果可能会出现x1= 1, x2 = 2的情况,如果编译器对这段程序代码执行重排后,可能出现以下情况

这里写图片描述
重排后可能出现x1 =1, x2 = 2,这说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中就不一定了,由于线程对共享变量的操所都是线程从共享区拷贝数据到各自工作内存,然后操作数据后再写回主内存中,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说不可见,这种工作内存与主内存同步延迟的现象就造成了可见性的问题,另外指令重排和编译器优化也会导致可见性的问题。

有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的。但是对于多线程而言,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

JVM解决线程安全性问题

  • 原子性问题的解决方法:

    • (1)JVM自身提供了对基本数据类型读写操作的原子性
    • (2)对于方法级别或者代码块级别的原子操作:有synchronized关键字或者重入锁(ReentrentLock)保证程序的原子性
  • 工作内存和主内存的同步延迟问题的解决方法

    • 可以利用volatile关键字解决
  • 除了以上的解决方法外,JMM内部还定义了一套hapens-before原则来保证多环境下两个操作间的原子性、可见性和有序性。

happens-before原则

该原则用来判断数据是否存在竞争、线程是否安全,原则如下:【】中的内容为《深入理解Java虚拟机第十二章》

  • (1)程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;【一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。】
  • (2)锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作【无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。】
  • (3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作【如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的】
  • (4)传递规则:如果操作A先行发生于操作B,而B又先行发生于C,则可以得出A先行发生于C
  • (5)**线程启动规则:**Thread对象的start()方法先行发生于此线程的每一个动作
  • (6)线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断
  • (7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • (8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

参考博文

https://blog.csdn.net/u011080472/article/details/51337422
https://blog.csdn.net/javazejian/article/details/72772461
https://www.cnblogs.com/chenssy/p/6393321.html

猜你喜欢

转载自blog.csdn.net/xiaojie_570/article/details/80040224