Java内存 模型理解

概述

  在正式讲Java内存模型之前,我们先了解一些物理计算机并发问题,然后一点点的引出Java内存模型的由来。

  多任务处理在现在计算机操作系统中几乎是一项必备的功能。这不单是因为计算机计算能力强大,更重要的原因是计算机的计算速度远高于它的的存储和通信子系统速度。所以我们就通过让计算机同时处理多个任务来讲处理器的运算能力得到充分运用。

  除了充分运用计算机的处理能力外,一个服务端同时对多个客户端提供服务则是另一个更具体的并发应用的场景。衡量一个服务性能的高低好坏,每秒事务处理数(TPS)是一个重要指标,它代表着一秒内服务端平均能响应的请求总数,而TPS值和程序的并发能力又有非常密切的关系。对于计算量相同的任务,程勋线程的并发协调性越有条不紊,效率自然就会越高;反之,线程之间频繁的阻塞甚至死锁,将会大大降低程序的并发能力。

  在了解Java并发问题之前我们先了解一下,物理计算机的并发问题,物理机遇到的并发问题和虚拟机中的情况有很多相似的地方,物理机对并发的处理方案对于虚拟机有很大的参考意义。

  前面说过,为了更充分的利用处理器的性能,我们让计算机并发执行多个运算任务,这种因果关系看起来顺理成章。但是他们的其实并没有这么简单,因为绝大多数的运算都不可能只靠处理器,处理器至少要和内存进行交互,如读取运算数据,存储运算结果等,这个I/O操作时很难消除的(无法紧靠寄存器来完成所有的运算任务)。所以现在计算机都会加入一层读写速度尽可能接近处理器运算速度的“高速缓存”,来作为处理器和内存之间的缓冲:将运算需要的数据复制到缓存中,让运算能快速的进行,当运算结束后从缓存同步回内存之中,这样处理器就不用等待缓慢的内存读写了。

  高速缓存很好的解决了处理器和内存的速度矛盾,但是这也为计算机系统带来了更高的复杂度,因为它引起了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,二他们有共享一个主内存。当多个处理器的运算任务逗哦设计到同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生了缓存不一致的问题,那同步回到主内存时以谁的缓存数据为准呢?为了解决缓存一致性问题,需要各个处理器在访问缓存时都遵守一些协议,在读写时根据这些协议来进行操作。而在本文中要讨论的内存模型可以理解为在特定的操作协议下对特定的内存和高速缓存进行的读写访问的过程抽象。不同架构的物理机可以拥有不一样的内存模型,java虚拟机也有自己的内存模型。

  除了增加高速缓存,为了使处理器内部的运算单元能尽量的被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果和顺序执行的结果是一致的。因此,如果哦存在一个计算任务以来另一个计算任务的中间结果,那么气顺序性并不能卡哦哦代码的先后顺序来保证。其实java虚拟机中指令重拍优化也是类似的优化。

为什么要定义Java内存模型

  java虚拟机规范中试图定义一种java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都可能达到一致的内存访问效果。在此之前,C语言/C++直接使用物理硬件和操作系统的内存模型,所以就会出现在一套平台上并发访问正常,但是在另一套平台上却有问题,平台兼容性相对较差。

Java内存模型的目的及实现方式

   JMM的主要目标是定义程序中的各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。此处的变量与java变成中变量有所区别,它包括了实例字段,静态字段和构成数据对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了更好地性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器和缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化。

  JMM规定所有的变量都存贮在主内存(虚拟机内存的一部分)中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的主内存中的变量的副本(注意:一个对像如果10M,是不是会把这个10M的内存复制一份到工作内存呢?显然是不会的,但是这个对像的引用,对像中的某个在线程中访问到的字段是有可能会复制到工作能存中的,但是不会把整个对象复制一份),线程对变量的所有操作(读取,赋值等)都需要在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也是无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

  另外要注意,这里所说的主内存、工作内存和java内存区域中的java堆,栈,方法区等并不是一个层次的内存划分,这两者没有任何关系,如果非要勉强对应的话,主内存主要对应于java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就是直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存有限存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

主内存和工作内存之间的交互

  Java内存模型定义了8种操作来完成关于主内存和工作内存之间具体的交互,这些操作都是原子的,不可分割(long double类型除外)。这8种操作如下所示:

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

    如果要把一个变量从主内存复制到工作内存,那就要按顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,那就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序地执行,而没有保证必须是连续执行,也就是说read和load之间,store和write之间是可以插入其它指令的,如对内存中的变量a,b进行访问时,一种可能出现的顺序是read a, read b, load b, load a。

  除此之外,java内存模型还规定了在执行上述8中基本操作时必须满足以下规则

  1.不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取了但是工作内存不接受,或者从工作内存中发起了回写了但是主内存不接受的情况出现。

  2.不允许一个县城丢弃它的最近的assign操作,即变量在工作内存中改变后必须把该变化同步回主内存。

  3.不允许一个线程无原因的(没有发生过任何assign操作)吧数据从线程的工作内存同步回主内存中。

  4.一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。换就话说,就是对一个变量实施use,store操作之前,必须先执行过了assign和load操作。

  5.一个变量统一时刻只允许一个线程对其进行lock操作,但是lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁

  6.如果对一个变量执行lock操作,那将会情况巩固走内存中次变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  7.如果一个变量事前没有被lock操作锁定,那就不允许对她执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  8.对一个变量执行unlock之前,必须先把变量同步回主内存中(执行store,write操作)。

  通过这8中内存访问操作及其相关的规定,再加上volatile的一些特殊规定,就完全可以确定哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分的繁琐,实践起来很是麻烦,所以java虚拟机提供了一个等效判断原则--先行发现原则

volatile的含义和用法

  • volatile的语义

  第一:保证了此变量对所有的线程是可见的,这里的可见性是指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通的变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。

  第二:禁止指令的重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖复制结果的地方都能获取到正确的结果,而不能保证变量复制操作的顺序与程序代码中的执行顺序一致。

  由此我们可以看到volatile变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中也可以存在不一致的情况,但是由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,至于如何解决不一致的情况请看下面的“如何解决缓存一致性问题?”),但是java里面的运算并非原子的操作,导致volatile变量的运算在并发情况下一样是不安全的。

  要使用volatile关键字的应用场景,必须满足以下规则,否则仍然会出现并发问题:

  1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

  2.变量不需要与其他的状态变量共同参与不变约束

  volatile变量从主内存到工作内存,又到主内存的过程:线程A在操作一个被volatile变量修饰的变量x时,会将其值复制到线程A的工作内存副本中,然后修改它的值,在修改完之后会将值强制写回主内存,这是如果查看汇编指令会发现比没有volatile指令修饰的变量多了一个lock#语句,正是因为这个lock执行保证了volatile变量的内存可见性。早期的处理器lock锁的是总线,会阻塞其他cpu的读写操作,导致性能比较低;所以在最近的处理器中,如果访问的内存有高速缓存,那么就是用“高速缓存锁”,确认对高速缓存中的数据进行原子操作,并不会对总线和总线上的相关内存加锁,但是如果访问的内存在高速缓存中不存在,那么就会锁总线。而在某个cpu要把修改的缓存行数据前需要向总线申请独占式访问权,同时通知其他cpu他们相同的缓存行置为无效,只有申请到了独占式访问权,才可以修改缓存行中的数据,在修改完缓存行数据后,其他cpu要想访问想要读取这个缓存行的数据,这个缓存行的数据必须为“共享”状态,而已被修改的数据会立马回写到内存中,这是由于其他的cpu一直在嗅探总线,所以会立马感知到这个数据变化。这里需要说明一下,CPU缓存不仅仅在做内存传输的时候才与总线打交道,每个cpu也会不停的嗅探总线上的数据变化以及其他缓存在干什么,一直在不停的嗅探总线的其他的cpu就会立马知道有cpu对自己缓存中的变量的值进行了修改,前提是如果有这个变量的话。当这些cpu需要对这个变量进行操作时就需要重新去内存中读取。

  • volatile使用场景

  场景一 使用  volatile 变量作为状态标志。在该场景中,应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据( 或者仅仅读取并输出这个状态值。此时使用 volatile变量作为同步机制的好处是一个线程能够 “通知” 另外一个线程某种事件( 例如,网络连接断连之后重新连上)的发生,而这些线程又无须因此而使用锁,从而避免了锁的开销以及相关问题。

  场景二  使用 volatile  保障可见性。在该场景中,多个线程共享一个可变状态变量 ,其中一个线程更新了该变量之后。其他线程在元须加锁的情况下也能够看到该更新。

  场景三 使用 volatile变量替代锁。volatile 关键字并非锁的替代品,但是在一定的条件下它比锁更合适 ( 性能开销小 、代码简单 )。多个线程共享一组可变状态变量的时候,通常我们需要使用锁来保障对这些变量的更新操作的原子性,以避免产生数据不一致问题。利用 volatile 变量写操作具有的原子性 ,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。在这个过程中, volatile 保障了原子性和可见性。从而避免了锁的使用。

  切记volatile并不能保证排他性操作,当一个变量参与计算时仍然需要使用锁,来实现更广范围的原子性操作,所以votile一般适用于直接赋值,而不适用于计算例如:i++。

  volatile实现原理

  volatile详解

如何解决缓存一致性问题

  解决缓存一致性问题,有两种方式:

  1.通过在总线加LOCK#锁的方式

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  2.通过缓存一致性协议

  上面说了,LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,基本思想是:

  所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。

并发安全过程中三原则

  • 原子性

  原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

   一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

   同样地反映到并发编程中会出现什么结果呢?

  举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

1
i = 9;

   假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

  那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

  • 可见性

  可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

   假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

  此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

  这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

  • 有序性

  有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;      //语句2

   上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

  下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

  但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

   这段代码有4个语句,那么可能的一个执行顺序是:

  

  那么可不可能是这个执行顺序呢: 语句2   语句1    语句4   语句3

  不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

  虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

   上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

   从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

  也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

posted @ 2019-05-12 19:44  海棠--依旧  Views(1404)  Comments(0Edit  收藏  举报