java内存模型 订阅
Java平台自动集成了线程以及多处理器技术,这种集成程度比Java以前诞生的计算机语言要厉害很多,该语言针对多种异构平台的平台独立性而使用的多线程技术支持也是具有开拓性的一面,有时候在开发Java同步和线程安全要求很严格的程序时,往往容易混淆的一个概念就是内存模型。究竟什么是内存模型?内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节,对象最终是存储在内存里面的,这点没有错,但是编译器、运行库、处理器或者系统缓存可以有特权在变量指定内存位置存储或者取出变量的值。【JMM】(Java Memory Model的缩写)允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员使用了volatile或synchronized明确请求了某些可见性的保证。 展开全文
Java平台自动集成了线程以及多处理器技术,这种集成程度比Java以前诞生的计算机语言要厉害很多,该语言针对多种异构平台的平台独立性而使用的多线程技术支持也是具有开拓性的一面,有时候在开发Java同步和线程安全要求很严格的程序时,往往容易混淆的一个概念就是内存模型。究竟什么是内存模型?内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节,对象最终是存储在内存里面的,这点没有错,但是编译器、运行库、处理器或者系统缓存可以有特权在变量指定内存位置存储或者取出变量的值。【JMM】(Java Memory Model的缩写)允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员使用了volatile或synchronized明确请求了某些可见性的保证。
信息
外文名
Java Memory Model [1]
术    语
JMM
中文名
java内存模型
java内存模型JMM简介
1)JSR133:在Java语言规范里面指出了JMM是一个比较开拓性的尝试,这种尝试视图定义一个一致的、跨平台的内存模型,但是它有一些比较细微而且很重要的缺点。其实Java语言里面比较容易混淆的关键字主要是synchronized和volatile,也因为这样在开发过程中往往开发者会忽略掉这些规则,这也使得编写同步代码比较困难。JSR133本身的目的是为了修复原本JMM的一些缺陷而提出的,其本身的制定目标有以下几个:2)同步、异步【这里仅仅指概念上的理解,不牵涉到计算机底层基础的一些操作】:在系统开发过程,经常会遇到这几个基本概念,不论是网络通讯、对象之间的消息通讯还是Web开发人员常用的Http请求都会遇到这样几个概念,经常有人提到Ajax是异步通讯方式,那么究竟怎样的方式是这样的概念描述呢?同步:同步就是在发出一个功能调用的时候,在没有得到响应之前,该调用就不返回,按照这样的定义,其实大部分程序的执行都是同步调用的,一般情况下,在描述同步和异步操作的时候,主要是指代需要其他部件协作处理或者需要协作响应的一些任务处理。比如有一个线程A,在A执行的过程中,可能需要B提供一些相关的执行数据,当然触发B响应的就是A向B发送一个请求或者说对B进行一个调用操作,如果A在执行该操作的时候是同步的方式,那么A就会停留在这个位置等待B给一个响应消息,在B没有任何响应消息回来的时候,A不能做其他事情,只能等待,那么这样的情况,A的操作就是一个同步的简单说明。异步:异步就是在发出一个功能调用的时候,不需要等待响应,继续进行它该做的事情,一旦得到响应了过后给予一定的处理,但是不影响正常的处理过程的一种方式。比如有一个线程A,在A执行的过程中,同样需要B提供一些相关数据或者操作,当A向B发送一个请求或者对B进行调用操作过后,A不需要继续等待,而是执行A自己应该做的事情,一旦B有了响应过后会通知A,A接受到该异步请求的响应的时候会进行相关的处理,这种情况下A的操作就是一个简单的异步操作。3)可见性、可排序性Java内存模型的两个关键概念:可见性(Visibility)和可排序性(Ordering)开发过多线程程序的程序员都明白,synchronized关键字强制实施一个线程之间的互斥锁(相互排斥),该互斥锁防止每次有多个线程进入一个给定监控器所保护的同步语句块,也就是说在该情况下,执行程序代码所独有的某些内存是独占模式,其他的线程是不能针对它执行过程所独占的内存进行访问的,这种情况称为该内存不可见。但是在该模型的同步模式中,还有另外一个方面:JMM中指出了,JVM在处理该强制实施的时候可以提供一些内存的可见规则,在该规则里面,它确保当存在一个同步块时,缓存被更新,当输入一个同步块时,缓存失效。因此在JVM内部提供给定监控器保护的同步块之中,一个线程所写入的值对于其余所有的执行由同一个监控器保护的同步块线程来说是可见的,这就是一个简单的可见性的描述。这种机器保证编译器不会把指令从一个同步块的内部移到外部,虽然有时候它会把指令由外部移动到内部。JMM在缺省情况下不做这样的保证——只要有多个线程访问相同变量时必须使用同步。简单总结:可见性就是在多核或者多线程运行过程中内存的一种共享模式,在JMM模型里面,通过并发线程修改变量值的时候,必须将线程变量同步回主存过后,其他线程才可能访问到。可排序性提供了内存内部的访问顺序,在不同的程序针对不同的内存块进行访问的时候,其访问不是无序的,比如有一个内存块,A和B需要访问的时候,JMM会提供一定的内存分配策略有序地分配它们使用的内存,而在内存的调用过程也会变得有序地进行,内存的折中性质可以简单理解为有序性。而在Java多线程程序里面,JMM通过Java关键字volatile来保证内存的有序访问。1)简单分析:Java语言规范中提到过,JVM中存在一个主存区(Main Memory或Java Heap Memory),Java中所有变量都是存在主存中的,对于所有线程进行共享,而每个线程又存在自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。而在多核处理器下,大部分数据存储在高速缓存中,如果高速缓存不经过内存的时候,也是不可见的一种表现。在Java程序中,内存本身是比较昂贵的资源,其实不仅仅针对Java应用程序,对操作系统本身而言内存也属于昂贵资源,Java程序在性能开销过程中有几个比较典型的可控制的来源。synchronized和volatile关键字提供的内存中模型的可见性保证程序使用一个特殊的、存储关卡(memory barrier)的指令,来刷新缓存,使缓存无效,刷新硬件的写缓存并且延迟执行的传递过程,无疑该机制会对Java程序的性能产生一定的影响。JMM的最初目的,就是为了能够支持多线程程序设计的,每个线程可以认为是和其他线程不同的CPU上运行,或者对于多处理器的机器而言,该模型需要实现的就是使得每一个线程就像运行在不同的机器、不同的CPU或者本身就不同的线程上一样,这种情况实际上在项目开发中是常见的。对于CPU本身而言,不能直接访问其他CPU的寄存器,模型必须通过某种定义规则来使得线程和线程在工作内存中进行相互调用而实现CPU本身对其他CPU、或者说线程对其他线程的内存中资源的访问,而表现这种规则的运行环境一般为运行该程序的运行宿主环境(操作系统、服务器、分布式系统等),而程序本身表现就依赖于编写该程序的语言特性,这里也就是说用Java编写的应用程序在内存管理中的实现就是遵循其部分原则,也就是前边提及到的JMM定义了Java语言针对内存的一些的相关规则。然而,虽然设计之初是为了能够更好支持多线程,但是该模型的应用和实现当然不局限于多处理器,而在JVM编译器编译Java编写的程序的时候以及运行期执行该程序的时候,对于单CPU的系统而言,这种规则也是有效的,这就是是上边提到的线程和线程之间的内存策略。JMM本身在描述过程没有提过具体的内存地址以及在实现该策略中的实现方法是由JVM的哪一个环节(编译器、处理器、缓存控制器、其他)提供的机制来实现的,甚至针对一个开发非常熟悉的程序员,也不一定能够了解它内部对于类、对象、方法以及相关内容的一些具体可见的物理结构。相反,JMM定义了一个线程与主存之间的抽象关系,其实从上边的图可以知道,每一个线程可以抽象成为一个工作内存(抽象的高速缓存和寄存器),其中存储了Java的一些值,该模型保证了Java里面的属性、方法、字段存在一定的数学特性,按照该特性,该模型存储了对应的一些内容,并且针对这些内容进行了一定的序列化以及存储排序操作,这样使得Java对象在工作内存里面被JVM顺利调用,(当然这是比较抽象的一种解释)既然如此,大多数JMM的规则在实现的时候,必须使得主存和工作内存之间的通信能够得以保证,而且不能违反内存模型本身的结构,这是语言在设计之处必须考虑到的针对内存的一种设计方法。这里需要知道的一点是,这一切的操作在Java语言里面都是依靠Java语言自身来操作的,因为Java针对开发人员而言,内存的管理在不需要手动操作的情况下本身存在内存的管理策略,这也是Java自己进行内存管理的一种优势。[1]原子性(Atomicity):这一点说明了该模型定义的规则针对原子级别的内容存在独立的影响,对于模型设计最初,这些规则需要说明的仅仅是最简单的读取和存储单元写入的的一些操作,这种原子级别的包括——实例、静态变量、数组元素,只是在该规则中不包括方法中的局部变量。[2]可见性(Visibility):在该规则的约束下,定义了一个线程在哪种情况下可以访问另外一个线程或者影响另外一个线程,从JVM的操作上讲包括了从另外一个线程的可见区域读取相关数据以及将数据写入到另外一个线程内。[3]可排序性(Ordering):该规则将会约束任何一个违背了规则调用的线程在操作过程中的一些顺序,排序问题主要围绕了读取、写入和赋值语句有关的序列。如果在该模型内部使用了一致的同步性的时候,这些属性中的每一个属性都遵循比较简单的原则:和所有同步的内存块一样,每个同步块之内的任何变化都具备了原子性以及可见性,和其他同步方法以及同步块遵循同样一致的原则,而且在这样的一个模型内,每个同步块不能使用同一个锁,在整个程序的调用过程是按照编写的程序指定指令运行的。即使某一个同步块内的处理可能会失效,但是该问题不会影响到其他线程的同步问题,也不会引起连环失效。简单讲:当程序运行的时候使用了一致的同步性的时候,每个同步块有一个独立的空间以及独立的同步控制器和锁机制,然后对外按照JVM的执行指令进行数据的读写操作。这种情况使得使用内存的过程变得非常严谨!如果不使用同步或者说使用同步不一致(这里可以理解为异步,但不一定是异步操作),该程序执行的答案就会变得极其复杂。而且在这样的情况下,该内存模型处理的结果比起大多数程序员所期望的结果而言就变得十分脆弱,甚至比起JVM提供的实现都脆弱很多。因为这样所以出现了Java针对该内存操作的最简单的语言规范来进行一定的习惯限制,排除该情况发生的做法在于:JVM线程必须依靠自身来维持对象的可见性以及对象自身应该提供相对应的操作而实现整个内存操作的三个特性,而不是仅仅依靠特定的修改对象状态的线程来完成如此复杂的一个流程。[4]三个特性的解析(针对JMM内部):原子性(Atomicity):访问存储单元内的任何类型的字段的值以及对其更新操作的时候,除开long类型和double类型,其他类型的字段是必须要保证其原子性的,这些字段也包括为对象服务的引用。此外,该原子性规则扩展可以延伸到基于long和double的另外两种类型:volatile long和volatile double(volatile为java关键字),没有被volatile声明的long类型以及double类型的字段值虽然不保证其JMM中的原子性,但是是被允许的。针对non-long/non-double的字段在表达式中使用的时候,JMM的原子性有这样一种规则:如果你获得或者初始化该值或某一些值的时候,这些值是由其他线程写入,而且不是从两个或者多个线程产生的数据在同一时间戳混合写入的时候,该字段的原子性在JVM内部是必须得到保证的。也就是说JMM在定义JVM原子性的时候,只要在该规则不违反的条件下,JVM本身不去理睬该数据的值是来自于什么线程,因为这样使得Java语言在并行运算的设计的过程中针对多线程的原子性设计变得极其简单,而且即使开发人员没有考虑到最终的程序也没有太大的影响。再次解释一下:这里的原子性指的是原子级别的操作,比如最小的一块内存的读写操作,可以理解为Java语言最终编译过后最接近内存的最底层的操作单元,这种读写操作的数据单元不是变量的值,而是本机码,也就是前边在讲《Java基础知识》中提到的由运行器解释的时候生成的Native Code。可见性(Visibility):当一个线程需要修改另外线程的可见单元的时候必须遵循以下原则:注意:如果在同一个线程里面通过方法调用去传一个对象的引用是绝对不会出现上边提及到的可见性问题的。JMM保证所有上边的规定以及关于内存可见性特性的描述——一个特殊的更新、一个特定字段的修改都是某个线程针对其他线程的一个“可见性”的概念,最终它发生的场所在内存模型中Java线程和线程之间,至于这个发生时间可以是一个任意长的时间,但是最终会发生,也就是说,Java内存模型中的可见性的特性主要是针对线程和线程之间使用内存的一种规则和约定,该约定由JMM定义。不仅仅如此,该模型还允许不同步的情况下可见性特性。比如针对一个线程提供一个对象或者字段访问域的原始值进行操作,而针对另外一个线程提供一个对象或者字段刷新过后的值进行操作。同样也有可能针对一个线程读取一个原始的值以及引用对象的对象内容,针对另外一个线程读取一个刷新过后的值或者刷新过后的引用。尽管如此,上边的可见性特性分析的一些特征在跨线程操作的时候是有可能失败的,而且不能够避免这些故障发生。这是一个不争的事实,使用同步多线程的代码并不能绝对保证线程安全的行为,只是允许某种规则对其操作进行一定的限制,但是在最新的JVM实现以及最新的Java平台中,即使是多个处理器,通过一些工具进行可见性的测试发现其实是很少发生故障的。跨线程共享CPU的共享缓存的使用,其缺陷就在于影响了编译器的优化操作,这也体现了强有力的缓存一致性使得硬件的价值有所提升,因为它们之间的关系在线程与线程之间的复杂度变得更高。这种方式使得可见度的自由测试显得更加不切实际,因为这些错误的发生极为罕见,或者说在平台上我们开发过程中根本碰不到。在并行程开发中,不使用同步导致失败的原因也不仅仅是对可见度的不良把握导致的,导致其程序失败的原因是多方面的,包括缓存一致性、内存一致性问题等。可排序性(Ordering):可排序规则在线程与线程之间主要有下边两点:【*:如何理解这里“间谍”的意思,可以这样理解,排序规则在本线程里面遵循了第一条法则,但是对其他线程而言,某个线程自身的排序特性可能使得它不定地访问执行线程的可见域,而使得该线程对本身在执行的线程产生一定的影响。举个例子,A线程需要做三件事情分别是A1、A2、A3,而B是另外一个线程具有操作B1、B2,如果把参考定位到B线程,那么对A线程而言,B的操作B1、B2有可能随时会访问到A的可见区域,比如A有一个可见区域a,A1就是把a修改称为1,但是B线程在A线程调用了A1过后,却访问了a并且使用B1或者B2操作使得a发生了改变,变成了2,那么当A按照排序性进行A2操作读取到a的值的时候,读取到的是2而不是1,这样就使得程序最初设计的时候A线程的初衷发生了改变,就是排序被打乱了,那么B线程对A线程而言,其身份就是“间谍”,而且需要注意到一点,B线程的这些操作不会和A之间存在等待关系,那么B线程的这些操作就是异步操作,所以针对执行线程A而言,B的身份就是“非同步方法中的‘间谍’。】同样的,这仅仅是一个最低限度的保障性质,在任何给定的程序或者平台,开发中有可能发现更加严格的排序,但是开发人员在设计程序的时候不能依赖这种排序,如果依赖它们会发现测试难度会成指数级递增,而且在复合规定的时候会因为不同的特性使得JVM的实现因为不符合设计初衷而失败。注意:第一点在JLS(Java Language Specification)的所有讨论中也是被采用的,例如算数表达式一般情况都是从上到下、从左到右的顺序,但是这一点需要理解的是,从其他操作线程的角度看来这一点又具有不确定性,对线程内部而言,其内存模型本身是存在排序性的。【*:这里讨论的排序是最底层的内存里面执行的时候的NativeCode的排序,不是说按照顺序执行的Java代码具有的有序性质,本文主要分析的是JVM的内存模型,所以希望读者明白这里指代的讨论单元是内存区。】JMM最初设计的时候存在一定的缺陷,这种缺陷虽然现有的JVM平台已经修复,但是这里不得不提及,也是为了读者更加了解JMM的设计思路,这一个小节的概念可能会牵涉到很多更加深入的知识,如果读者不能读懂没有关系先看了文章后边的章节再返回来看也可以。1)问题1:不可变对象不是不可变的学过Java的朋友都应该知道Java中的不可变对象,这一点在本文最后讲解String类的时候也会提及,而JMM最初设计的时候,这个问题一直都存在,就是:不可变对象似乎可以改变它们的值(这种对象的不可变指通过使用final关键字来得到保证),(Publis Service Reminder:让一个对象的所有字段都为final并不一定使得这个对象不可变——所有类型还必须是原始类型而不能是对象的引用。而不可变对象被认为不要求同步的。但是,因为在将内存写方面的更改从一个线程传播到另外一个线程的时候存在潜在的延迟,这样就使得有可能存在一种竞态条件,即允许一个线程首先看到不可变对象的一个值,一段时间之后看到的是一个不同的值。这种情况以前怎么发生的呢?在JDK 1.4中的String实现里,这儿基本有三个重要的决定性字段:对字符数组的引用、长度和描述字符串的开始数组的偏移量。String就是以这样的方式在JDK 1.4中实现的,而不是只有字符数组,因此字符数组可以在多个String和StringBuffer对象之间共享,而不需要在每次创建一个String的时候都拷贝到一个新的字符数组里。假设有下边的代码:String s1 = "/usr/tmp";String s2 = s1.substring(4); // "/tmp"这种情况下,字符串s2将具有大小为4的长度和偏移量,但是它将和s1共享“/usr/tmp”里面的同一字符数组,在String构造函数运行之前,Object的构造函数将用它们默认的值初始化所有的字段,包括决定性的长度和偏移字段。当String构造函数运行的时候,字符串长度和偏移量被设置成所需要的值。但是在旧的内存模型中,因为缺乏同步,有可能另一个线程会临时地看到偏移量字段具有初始默认值0,而后又看到正确的值4,结果是s2的值从“/usr”变成了“/tmp”,这并不是我们真正的初衷,这个问题就是原始JMM的第一个缺陷所在,因为在原始JMM模型里面这是合理而且合法的,JDK 1.4以下的版本都允许这样做。2)问题2:重新排序的易失性和非易失性存储另一个主要领域是与volatile字段的内存操作重新排序有关,这个领域中现有的JMM引起了一些比较混乱的结果。现有的JMM表明易失性的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者绕过处理器特定的缓存,这使得多个线程一般能看见一个给定变量最新的值。可是,结果是这种volatile定义并没有最初想象中那样如愿以偿,并且导致了volatile的重大混乱。为了在缺乏同步的情况下提供较好的性能,编译器、运行时和缓存通常是允许进行内存的重新排序操作的,只要当前执行的线程分辨不出它们的区别。(这就是within-thread as-if-serial semantics[线程内似乎是串行]的解释)但是,易失性的读和写是完全跨线程安排的,编译器或缓存不能在彼此之间重新排序易失性的读和写。遗憾的是,通过参考普通变量的读写,JMM允许易失性的读和写被重排序,这样以为着开发人员不能使用易失性标志作为操作已经完成的标志。比如:Map configOptions;char[] configText;volatile boolean initialized = false;// 线程1configOptions = new HashMap();configText = readConfigFile(filename);processConfigOptions(configText,configOptions);initialized = true;// 线程2while(!initialized)sleep();这里的思想是使用易失性变量initialized担任守卫来表明一套别的操作已经完成了,这是一个很好的思想,但是不能在JMM下工作,因为旧的JMM允许非易失性的写(比如写到configOptions字段,以及写到由configOptions引用Map的字段中)与易失性的写一起重新排序,因此另外一个线程可能会看到initialized为true,但是对于configOptions字段或它所引用的对象还没有一个一致的或者说当前的针对内存的视图变量,volatile的旧语义只承诺在读和写的变量的可见性,而不承诺其他变量,虽然这种方法更加有效的实现,但是结果会和我们设计之初大相径庭。
收起全文
精华内容
下载资源
问答
  • Java 内存模型

    2018-07-23 16:45:07
    深入理解 java 内存模型java 程序员的必修课,看看原汁原味正宗的内存模型
  • 主要介绍了Java内存模型与JVM运行时数据区的区别详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
  • Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main ...
  • Java 内存模型的抽象 4 重排序 6 处理器重排序与内存屏障指令 7 happens-before 10 重排序 13 数据依赖性 13 as-if-serial 语义 13 程序顺序规则 15 重排序对多线程的影响 15 顺序一致性 19 数据竞争与顺序...
  • java内存模型逻辑抽象图 java 内存模型 lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态 unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程...
  • 深入理解Java内存模型 pdf 超清版
  • 讲一讲什么是Java内存模型 Java内存模型虽说是一个老生常谈的问题 ,也是大厂面试中绕不过的,甚至初级面试也会问到。但是真正要理解起来,还是相当困难,主要这个东西看不见,摸不着。 这是一个比较开放的题目,...
  • 在说Java内存模型之前,我们先说一下Java的内存结构,也就是运行时的数据区域:Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为几个不同的数据区域,这些区域都有各自的用途、创建时间、销毁时间
  • Java内存模型

    2016-07-21 14:06:03
    Java内存模型
  • java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰java程序员,本文试图揭开java内存模型神秘的面纱。本文大致分三部分:重排序与顺序一致性;三个同步原语(lock,volatile,final)的内存语义,重...
  • java内存模型

    千次阅读 多人点赞 2018-11-09 13:09:55
    java内存模型 下图就是java内存模型,但是一般讨论的时候不会画这个图,一般画的是java内存模型抽象结构图(在下文)。Thread Stack就是java内存模型抽象结构图中的本地内存,Heap就是java内存模型抽象结构图中的主...

    java内存模型

    下图就是java内存模型,但是一般讨论的时候不会画这个图,一般画的是java内存模型抽象结构图(在下文)。Thread Stack就是java内存模型抽象结构图中的本地内存,Heap就是java内存模型抽象结构图中的主内存。接下来介绍下图中两个线程内存分配的概念。

    java里的堆是运行时的数据区,堆是由垃圾回收来负责的,堆的优势是可以动态的分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的。java的垃圾收集器会自动收走不再使用的数据。缺点是,由于在运行时动态分配内存,因此存取速度相对慢一些。

    栈的优势是存取速度比heap要快,仅次于计算机里的寄存器。Stack的数据是可以共享的。但是它的缺点是Stack中的数据大小和生存期必须是确定的。缺乏灵活性。Stack中主要存放基本类型的变量。比如int,short,long,float,double,byte,char等。

    java内存模型要求调用栈和本地变量存放在线程栈上。对象存放在堆上。一个本地变量它也可能是指向对象的引用,引用的这个本地变量是存在线程栈上,但是对象本身是存放在堆上,比如图中的Local variable 1 是本地变量存放在线程栈上,Object 1 是对象存放在堆上。一个对象可能包含方法图中methodOne()和methodTwo(),可能包含本地变量Local variable 1和Local variable 2,这些本地变量是存放在线程栈上,即使这些方法所属的对象存放在堆上,一个对象的成员变量可能会随着这个对象自身存放在堆上,不管这个成员变量是基本类型还是引用类型。静态成员变量跟着类的定义一起存放在堆上,存放在堆上的对象可以被所持有对这个对象引用的线程访问,比如图中Thread Stack中存放了对Object 3 的引用,那么是可以访问Object3的。如果两个线程同时调用同一个对象的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

    计算机架构的简单图示

    首先介绍cpu,现在计算机通常用两个或多个cpu,其中一些cpu还有多核,从这一点我们可以看出,在一个有两个或者多个cpu的计算机上,同时运行多个线程是非常有可能的,而且每个cpu在某一个时刻,运行一个线程是肯定没有问题的。这意味着如果java程序是多线程的,在java程序中每个cpu上一个线程是可能同时并发执行的。

    介绍完cpu,然后是cpu寄存器(CPU Registers),每个cpu都包含一系列的寄存器,它们是cpu内存的基础,cpu在寄存器上执行操作的速度远大于在主存上执行的速度,这是因为cpu访问寄存器的速度远大于主存。

    高速缓存Cache,由于计算机的存储设备与处理器的运算速度之间有几个数量级的差距。所以现在计算机系统都不得不加入一层读写速度都尽可能接近处理器运算速度的高级缓存来作为内存与处理器之间的缓冲,将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后,在从缓存同步到内存中。这样处理器就无需等待缓慢的内存读写。CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还是要慢一点。每个CPU可能有一个CPU的缓存层,一个CPU还有多层缓存。在某一时刻一个或多个缓存行可能被读到缓存,一个或多个缓存行可能被刷新回主存。同一时间点可能有很多操作在里面。

    CPU内存,所有的cpu都可以访问主存,主存通常比cpu的缓存大的多。

    运作原理

    通常情况下,当一个cpu需要读取主存的时候它会将主存的部分读取到cpu缓存中。它甚至会将缓存的部分内容读到内部寄存器里,然后在寄存器中执行操作,当cpu需要将结果回写到主存的时候,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

    通过图可以看出java内存模型与硬件架构之间存在一些差异,硬件内存架构它没有区分线程栈和堆,对于硬件而言所有的线程栈和堆都分布在主内存里,部分cpu栈和堆可能出现cpu缓存中和cpu内部的寄存器里面。

    java内存模型抽象结构图

    为了屏蔽各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果,Java虚拟机规范中定义了Java内存模型。

    java内存模型(Java Memory Model, JMM)是一种规范,它规范了java虚拟机与计算机内存是如何协同工作的。它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值。以及在必须时如何同步的访问共享变量 。

    java内存模型一般指的就是下图。

    线程之间的共享变量存储在主内存里面,每个线程都有一个私有的本地内存,本地内存是java内存模型的抽象概念,它并不是真实存在的。它涵盖了缓存,写缓存区,寄存器以及其他硬件和编译器的优化。本地内存存储了该线程以读或写共享变量拷贝的副本。比如线程A要是用共享变量的副本它首先要拷贝到本地内存A。从更低的层次来说主内存就是硬件内存,是为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。

    Java内存模型中的线程的工作内存是cpu的寄存器和高速缓存的抽象描述。而JVM静态存储模型就是jvm内存模型它只是对内存的物理划分而已,它只局限于内存,而且只局限于jvm内存。线程之间通信必须要通过主内存(主内存其实是堆内存)。

    java内存模型-同步的八种操作

    lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

    unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

    read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。

    load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

    use(使用):作用于工作内存的变量,把工作内存中的一个变量值转递给执行引擎。

    assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量。

    store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

    write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

    java内存模型-同步规则

    1、如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

    2、不允许read和load、store和write操作之一单独出现

    3、不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

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

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

    6、一个变量在同一时刻只允许一条线程对其进行locd操作,但lock操作可以被同一条线程重复执行多次,多次执行load后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

    7、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行Load或assign操作初始化变量的值。

    8、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

    9、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

    java内存模型-同步操作与规则图示

     

    展开全文
  • Java内存模型JMM浅析

    2020-12-22 16:43:27
     并发编程有多种风格,除了CSP(通信顺序进程)、Actor等模型外,大家熟悉的应该是基于线程和锁的共享内存模型了。在多线程编程中,需要注意三类并发问题:  1、原子性  2、可见性  3、重排序  原子性涉及到...
  • 主要介绍了java内存模型(JMM)及happens-before原理,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
  • Java内存模型的历史变迁 发表于2015-05-20 11:00|?1970次阅读| 来源CSDN|?2?条评论| 作者程晓明 特别策划 Java Java20周年 程序员电子刊 摘要本文通过介绍Java的新/旧内存模型来展示Java技术的历史变迁 本文通过介绍...
  • 深入理解 Java 内存模型,由程晓明编著,深入理解java内存模型JMM
  • 深入理解Java内存模型一基础 并发编程模型的分类 在并发编程中我们需要处理两个关键问题线程之间如何通信及线程之间如何同步这里的线程是指并发执行的活动实体通信是指线程之间以何种机制来交换信息在命令式编程中...
  • 全面理解Java内存模型(JMM)及volatile关键字

    万次阅读 多人点赞 2017-06-12 11:25:05
    【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权) ... 出自【zejian的博客】...深入理解Java类型信息(Class对象)与反射机制 深入理解Java枚举类型(enum) 深入理解Java注解类型(@Annotation) 深...

    【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权)
    http://blog.csdn.net/javazejian/article/details/72772461
    出自【zejian的博客】

    关联文章:

    深入理解Java类型信息(Class对象)与反射机制

    深入理解Java枚举类型(enum)

    深入理解Java注解类型(@Annotation)

    深入理解Java类加载器(ClassLoader)

    深入理解Java并发之synchronized实现原理

    Java并发编程-无锁CAS与Unsafe类及其并发包Atomic

    深入理解Java内存模型(JMM)及volatile关键字

    剖析基于并发AQS的重入锁(ReetrantLock)及其Condition实现原理

    剖析基于并发AQS的共享锁的实现(基于信号量Semaphore)

    并发之阻塞队列LinkedBlockingQueue与ArrayBlockingQueue

    本篇主要结合博主个人对Java内存模型的理解以及相关书籍内容的分析作为前提,对JMM进行较为全面的分析,本篇的写作思路是先阐明Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系,在弄明白它们间的关系后,进一步分析Java内存模型作用以及一些必要的实现手段,以下是本篇主要内容(如有错误,欢迎留言,谢谢!)

    理解Java内存区域与Java内存模型

    Java内存区域

    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内存区域的划分,毕竟这两种划分是属于不同层次的概念。

    Java内存模型概述

    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内存模型

    硬件内存架构

    正如上图所示,经过简化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缓存,然后再把数据刷新到主内存中。

    Java线程与硬件处理器

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

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

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

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

    JMM存在的必要性

    在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。如下图,主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。

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

    Java内存模型的承诺

    这里我们先来了解几个概念,即原子性?可见性?有序性?最后再阐明JMM是如何保证这3个特性。

    原子性

    原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量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)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

    其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题

    编译器重排

    下面我们简单看一个编译器重排的例子:

    线程 1             线程 2
    1: x2 = a ;      3: x1 = b ;
    2: b = 1;         4: a = 2 ;

    两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,似乎不太可能出现x1 = 1 和x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况

    线程 1              线程 2
    2: b = 1;          4: a = 2 ; 
    1:x2 = a ;        3: x1 = b ;

    这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。

    处理器指令重排

    先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下

    • 取指 IF
    • 译码和取寄存器操作数 ID
    • 执行或者有效地址计算 EX
    • 存储器访问 MEM
    • 写回 WB

    CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

    从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的

    a = b + c ;
    d = e + f ;

    下面通过汇编指令展示了上述代码在CPU执行的处理过程

    • LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
    • LW R2,c 表示把c的值加载到寄存器R2中
    • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
    • SW 表示 store 即将 R3寄存器的值保持到变量a中
    • LW R4,e 表示把e的值加载到寄存器R4中
    • LW R5,f 表示把f的值加载到寄存器R5中
    • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
    • SW d,R6 表示将R6寄存器的值保持到变量d中

    上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,eLW R5,f 移动到前面执行,毕竟LW R4,eLW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:

    正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下

    class MixedOrder{
        int a = 0;
        boolean flag = false;
        public void writer(){
            a = 1;
            flag = true;
        }
    
        public void read(){
            if(flag){
                int i = a + 1;
            }
        }
    }

    如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

     线程A                    线程B
     writer:                 read:
     1:flag = true;           1:flag = true;
     2:a = 1;                 2: a = 0 ; //误读
                              3: i = 1 ;

    由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完,此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操作,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。

    可见性

    理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

    有序性

    有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

    JMM提供的解决方案

    在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性,关于synchronized的详解,看博主另外一篇文章( 深入理解Java并发之synchronized实现原理)。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

    理解JMM中的happens-before 原则

    倘若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下

    • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

    • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

    • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

    • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

    • 传递性 A先于B ,B先于C 那么A必然先于C

    • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

    • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

    • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

    上述8条原则无需手动添加任何同步手段(synchronized|volatile)即可达到效果,下面我们结合前面的案例演示这8条原则如何判断线程是否安全,如下:

    class MixedOrder{
        int a = 0;
        boolean flag = false;
        public void writer(){
            a = 1;
            flag = true;
        }
    
        public void read(){
            if(flag){
                int i = a + 1;
            }
        }
    }

    同样的道理,存在两条线程A和B,线程A调用实例对象的writer()方法,而线程B调用实例对象的read()方法,线程A先启动而线程B后启动,那么线程B读取到的i值是多少呢?现在依据8条原则,由于存在两条线程同时调用,因此程序次序原则不合适。writer()方法和read()方法都没有使用同步手段,锁规则也不合适。没有使用volatile关键字,volatile变量原则不适应。线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。线程A和线程B的启动时间虽然有先后,但线程B执行结果却是不确定,也是说上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。修复这个问题的方式很简单,要么给writer()方法和read()方法添加同步手段,如synchronized或者给变量flag添加volatile关键字,确保线程A修改的值对线程B总是可见。

    volatile内存语义

    volatile在并发编程中很常见,但也容易被滥用,现在我们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用

    • 保证被volatile修饰的共享gong’x变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。

    • 禁止指令重排序优化。

    volatile的可见性

    关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下

    public class VolatileVisibility {
        public static volatile int i =0;
    
        public static void increase(){
            i++;
        }
    }

    正如上述代码所示,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

    public class VolatileVisibility {
        public static int i =0;
    
        public synchronized static void increase(){
            i++;
        }
    }

    现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下

    public class VolatileSafe {
    
        volatile boolean close;
    
        public void close(){
            close=true;
        }
    
        public void doWork(){
            while (!close){
                System.out.println("safe....");
            }
        }
    }

    由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。

    volatile禁止重排优化

    volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
    内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:

    /**
     * Created by zejian on 2017/6/11.
     * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创]
     */
    public class DoubleCheckLock {
    
        private static DoubleCheckLock instance;
    
        private DoubleCheckLock(){}
    
        public static DoubleCheckLock getInstance(){
    
            //第一次检测
            if (instance==null){
                //同步
                synchronized (DoubleCheckLock.class){
                    if (instance == null){
                        //多线程环境下可能会出现问题的地方
                        instance = new DoubleCheckLock();
                    }
                }
            }
            return instance;
        }
    }

    上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

    memory = allocate(); //1.分配对象内存空间
    instance(memory);    //2.初始化对象
    instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

    由于步骤1和步骤2间可能会重排序,如下:

    memory = allocate(); //1.分配对象内存空间
    instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
    instance(memory);    //2.初始化对象

    由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

      //禁止指令重排优化
      private volatile static DoubleCheckLock instance;

    ok~,到此相信我们对Java内存模型和volatile应该都有了比较全面的认识,总而言之,我们应该清楚知道,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

    如有错误,欢迎留言,谢谢!

    参考资料:
    http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
    http://blog.csdn.net/iter_zc/article/details/41843595
    http://ifeve.com/wp-content/uploads/2014/03/JSR133%E4%B8%AD%E6%96%87%E7%89%881.pdf

    《深入理解JVM虚拟机》
    《Java高并发程序设计》

    展开全文
  • 深入理解JAVA内存模型.pdf (高清版) 1.基础 2.重排序 3.顺序一致性 4.voltile 5.锁 6.FInal 7.总结。。。等等
  • 深入Java 内存模型

    2018-08-15 14:57:57
    深入Java 内存模型本书介绍了,如何深入学习了解JAVA 内存模型!更好的了解java 虚拟机!
  • 深度剖析java内存模型

    2019-06-03 11:35:21
    深度剖析java内存模型深度剖析java内存模型深度剖析java内存模型
  • 目录 一、JAVA内存结构 1.1 JVM启动流程: 1.2 JVM基本结构 ...2.3 java内存模型对并发提供的保障:原子性、可见性。有序性 2.4 先行发生原则 2.5 volatile型变量 三、Java对象模型 3.1 oop-klass...

    目录

    一、JAVA内存结构

    1.1 JVM启动流程:

    1.2 JVM基本结构

    1.2.1基本结构图

    1.2.2 Java中的内存分配

    二、Java内存模型

    2.1 主内存和工作内存

    2.2 内存间交互操作

    2.3 java内存模型对并发提供的保障:原子性、可见性。有序性

    2.4 先行发生原则

    2.5 volatile型变量

    三、Java对象模型

    3.1 oop-klass model

    3.2 Klass体系

    3.3 InstanceClass

    3.4 内存存储

    四、总结


    Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点。而且很多概念的名称看起来又那么相似,很多人会傻傻分不清楚。比如本文我们要讨论的Java内存结构、Java内存模型和Java对象模型,这就是三个截然不同的概念,但是很多人容易弄混。

    可以这样说,很多高级开发甚至都搞不不清楚JVM内存结构、Java内存模型和Java对象模型这三者的概念及其间的区别。甚至我见过有些面试官自己也搞的不是太清楚。不信的话,你去网上搜索Java内存模型,还会有很多文章的内容其实介绍的是JVM内存结构。


    一、JAVA内存结构

    Java内存结构是每个java程序员必须掌握理解的,这是Java的核心基础。由于Java程序是交由JVM执行的,所以我们在谈Java内存区域划分的时候事实上是指JVM内存区域划分。

    1.1 JVM启动流程:

    JVM启动时,是由java命令/javaw命令来启动的。

    1.2 JVM基本结构

    1.2.1基本结构图

    1、类加载子系统:负责从文件系统或者网络加载Class信息,加载的信息存放在一块称之方法区的内存空间。
    2、方法区:就是存放类的信息、常量信息、常量池信息、包括字符串常量和数字常量等,还存放静态代码
    3、Java堆:在Java虚拟机启动的时候建立Java堆,它是Java程序最主要的内存工作区域,几乎所有的对象实例都存放到
    Java堆中,还有定义的数组也存放在堆里面堆空间是所有线程共享
    4、直接内存:JavaNio库允许Java程序直接内存,从而提高性能,通常直接内存速度会优于Java堆。读写频繁的场合可能会考虑使用。
    5、Java栈:存放基本数据类型,局部变量每个虚拟机线程都有一个私有栈,一个线程的Java栈在线程创建的时候被创建,Java栈保存着局部变量、方法参数、同事Java的方法调用、
    返回值等。
    (解释:线程私有)
    6、本地方法栈,(Java语言调用外部语言(C语言),方法使用native(native关键字在CAS底层代码中也有用到))最大不同为本地方法栈用于本地方法调用。Java虚拟机允许Java直接调用本地方法(通过使用C语言写)
    8、PC(Program Couneter)寄存器也是每个线程私有的空间, Java虚拟机会为每个线程创建PC寄存器,在任意时刻,一个Java线程总是在执行一个方法,这个方法称为当前方法,如果当前方法不是本地方法,PC寄存器总会执行当前正在被执行的指令,
    如果是本地方法,则PC寄存器值为Underfined,寄存器存放如果当前执行环境指针、程序技术器、操作栈指针、计算的变量指针等信息。
    9、虚拟机核心的组件就是执行引擎,它负责执行虚拟机的字节码,一般户先进行编译成机器码后执行。

    1.2.2 Java中的内存分配

    Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。

    具体划分为如下5个内存空间:(非常重要)

    • :存放局部变量
    • :存放所有new出来的东西
    • 方法区:被虚拟机加载的类信息、常量、静态常量等。
    • 程序计数器(和系统相关)
    • 本地方法栈

    1、程序计数器:

    每个线程拥有一个PC寄存器

    在线程创建时创建

    指向下一条指令的地址

    执行本地方法时,PC的值为undefined

    2、方法区: 

    保存装载的类信息

      类型的常量池

      字段,方法信息

      方法字节码

    通常和永久区(Perm)关联在一起

    3、堆内存:

    和程序开发密切相关

    应用系统对象都保存在Java堆中

    所有线程共享Java堆

    对分代GC来说,堆也是分代的

    GC管理的主要区域

    现在的GC基本都采用分代收集算法,如果是分代的,那么堆也是分代的。如果堆是分代的,那堆空间应该是下面这个样子:

     

    上图是堆的基本结构,在之后的文章中再进行详解。

    4、栈内存:

    • 线程私有,生命周期和线程相同
    • 栈由一系列帧组成(因此Java栈也叫做帧栈)
    • 帧保存一个方法的局部变量、操作数栈、常量池指针
    • 每一次方法调用创建一个帧,并压栈

    解释:

    Java虚拟机栈描述的是Java方法执行的内存模型:每个方法被调用的时候都会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

    在Java虚拟机规范中,对这个区域规定了两种异常情况:

    • 如果线程请求的栈深度太深,超出了虚拟机所允许的深度,就会出现StackOverFlowError(比如无限递归。因为每一层栈帧都占用一定空间,而 Xss 规定了栈的最大空间,超出这个值就会报错)
    • 虚拟机栈可以动态扩展,如果扩展到无法申请足够的内存空间,会出现OOM

    Java栈之局部变量表:包含参数和局部变量

        局部变量表存放了基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配。

    例如,我写出下面这段代码:

    public class StackDemo {
        
        //静态方法
        public static int runStatic(int i, long l, float f, Object o, byte b) {
            return 0;
        }
    
        //实例方法
        public int runInstance(char c, short s, boolean b) {
            return 0;
        }
    
    }

    上方代码中,静态方法有6个形参,实例方法有3个形参。其对应的局部变量表如下:

    上方表格中,静态方法和实例方法对应的局部变量表基本类似。但有以下区别:实例方法的表中,第一个位置存放的是当前对象的引用。

    Java栈之函数调用组成栈帧

    方法每次被调用的时候都会创建一个栈帧,例如下面这个方法:

    public static int runStatic(int i,long l,float  f,Object o ,byte b){
           return runStatic(i,l,f,o,b);
    }

    当它每次被调用的时候,都会创建一个帧,方法调用结束后,帧出栈。如下图所示:

    Java栈之操作数栈

    Java没有寄存器,所有参数传递都是使用操作数栈

    例如下面这段代码:

     public static int add(int a,int b){
            int c=0;
            c=a+b;
            return c;
        }

    压栈的步骤如下:

      0:   iconst_0 // 0压栈

      1:   istore_2 // 弹出int,存放于局部变量2

      2:   iload_0  // 把局部变量0压栈

      3:   iload_1 // 局部变量1压栈

      4:   iadd      //弹出2个变量,求和,结果压栈

      5:   istore_2 //弹出结果,放于局部变量2

      6:   iload_2  //局部变量2压栈

      7:   ireturn   //返回

    如果计算100+98的值,那么操作数栈的变化如下图所示:

    Java栈之栈上分配:

    小对象(一般几十个bytes),在没有逃逸的情况下,可以直接分配在栈上

    直接分配在栈上,可以自动回收,减轻GC压力

    大对象或者逃逸对象无法栈上分配

    栈、堆、方法区交互:

    记住了:jvm内存结构 = java内存结构

    二、Java内存模型

    JMM目标:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节(这里变量指代的是实例字段、静态字段和构成数组对象的元素)

    学习JMM可以和内存模型(处理器、高速缓存器、主内存空间)对等起来:内存模型是为了解决处理器和内存之间的速度差异问题,JMM是为了解决线程之间的信息共享,数据一致性问题

    类比:主内存对应硬件系统中的内存部分,工作空间对应高速缓存

    2.1 主内存和工作内存

        (1)所有变量均存储在主内存(虚拟机内存的一部分)

        (2)每个线程都对应着一个工作线程,主内存中的变量都会复制一份到每个线程的自己的工作空间,线程对变量的操作都在自己的工作内存中,操作完成后再将变量更新至主内存;

        (3)其他线程再通过主内存来获取更新后的变量信息,即线程之间的交流通过主内存来传递

    Note:JMM的空间划分和JVM的内存划分不一样,非要对应的话,关系如下:

        (1)JMM的主内存对应JVM中的堆内存对象实例数据部分

        (2)JMM的工作内存对应JVM中栈中部分区域

    每一个线程有一个工作内存。工作内存和主存独立。工作内存存放主存中变量的值的拷贝。

    当数据从主内存复制到工作存储时,必须出现两个动作:第一,由主内存执行的读(read)操作;第二,由工作内存执行的相应的load操作;

    当数据从工作内存拷贝到主内存时,也出现两个操作:第一个,由工作内存执行的存储(store)操作;第二,由主内存执行的相应的写(write)操作。

    每一个操作都是原子的,即执行期间不会被中断

    对于普通变量,一个线程中更新的值,不能马上反应在其他变量中。如果需要在其他线程中立即可见,需要使用volatile关键字作为标识。

    指令重排:

    指令重排:破坏了线程间的有序性:

    指令重排:保证有序性的方法:

    指令重排的基本原则:

    程序顺序原则:一个线程内保证语义的串行性

    volatile规则:volatile变量的写,先发生于读

    锁规则:解锁(unlock)必然发生在随后的加锁(lock)前

    传递性:A先于B,B先于C 那么A必然先于C

    线程的start方法先于它的每一个动作

    线程的所有操作先于线程的终结(Thread.join())

    线程的中断(interrupt())先于被中断线程的代码

    对象的构造函数执行结束先于finalize()方法

    2.2 内存间交互操作

        JMM定义了8种操作(原子操作),虚拟实现时保证这8中操作均为原子操作,以下为8中操作的介绍以及执行顺序:

        (1)lock(锁定):作用于主内存的变量,把一个变量标志为一个线程占有状态(锁定)

        (2)unlock(解锁):作用于主内存的变量,把一个变量从一个线程的锁定状态解除,以便其他线程锁定

        (3)read(读取):作用于主内存的变量,将变量从主内存读取到线程的工作空间,以便后续load操作使用

        (4)load(载入):作用于工作空间的变量,将load操作从主内存得到的变量放入工作内存变量副本中

        (5)use(使用):作用于工作空间的变量,将工作空间中的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将执行这个操作。

        (6)assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋给工作空间的变量

        (7)store(存储):作用于工作内存的变量,把工作空间的一个变量传到主内存,以便后续write操作使用

        (8)write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存的变量中
    工作示意图:

    Note:这些操作之间是存在一些冲突的,需保持顺序执行

    有关操作的一些规定:

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

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

    (3)不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。即不能对变量没做任何操作却无原因的同步回主内存

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

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

    (6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值

    (7)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

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

    Note:以上可以完全确定Java程序中哪些内存访问操作在并发下是安全的。

    2.3 java内存模型对并发提供的保障:原子性、可见性。有序性

    (1)原子性:

        Java内存模型直接保证得原子性操作包括read、load、use、assign、store和write这六个。可以认为基本数据类型(long和double除外,64字节在32位上需要两部操作)的变量操作是具有原子性的,而lock和unlock对应在高层次的字节码指令monitorenter和monitorexit来隐式的使用底层的这两个操作,高层次的这两个字节码指令在Java中就是同步块,Synchronized关键字,因此在synchronized块之间的操作也具备原子性。

    (2)可见性

        可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

        除了volatile外,Synchronized和final也提供了可见性,

    Synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得

    Final的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了半”的对象),那在其他线程就能看见final字段的值

    (3)有序性

        Java程序在本线程所有操作是有序的(线程表现为串行的),在一个线程中看另一个线程是无序的(指令重排和工作内存与主内存同步延迟现象),可以用Synchronized和volatile来保证线程操作的有序性

    Volatile本身就包含禁止指令重排序的语义

    Synchronized是因为:一个变量在同一时刻只能被一个线程lock,即串行化操作保证有序

    2.4 先行发生原则

        先行发生原则是java内存模型中定义的两项操作之间的偏序关系,这个原则作为依据来判断是否存在线程安全和竞争问题,如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。以下为8个具体原则:

        (1)程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

        (2)传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

        (3)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

        (4)管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。

        (5)volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。

        (6)线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作

        (7)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行

        (8)线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

    2.5 volatile型变量

        Volatile是java虚拟机提供的最轻量级的同步机制,它具有可见性和有序性,但不保证原子性,在大多数场景下,volatile总开销仍然比锁要低

    volatile是强制从主内存(公共堆)中取得变量的值,而不是从线程的私有堆栈中取得变量的值。如下图所示
     

    volatile保证了变量的新值能立即同步到主内存,以及每次使用之前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点

    (1)volatile可以保证变量对所有线程的可见性,即一条线程修改了变量的值,新值对于其他线程来说是可以立即得知的。volatile变量在各个线程的工作内存中不存在一致性问题(即时存在,由于每次使用之前都得刷新,执行引擎看不到不一致的情况,所以认为是 不存在一致性问题)volatile实际上就使用到了内存屏障技术来保证其变量的修改对其他CPU立即可见。

    (2)volatile禁止指令重排,保证有序性

    *Volatile不能保证的原子性操作有以下两条:

    (1)对变量的写入操作不依赖于该变量的当前值(比如a=0;a=a+1的操作,整个流程为a初始化为0,将a的值在0的基础之上加1,然后赋值给a本身,很明显依赖了当前值),或者确保只有单一线程修改变量。

    (2)该变量不会与其他状态变量纳入不变性条件中,(当变量本身是不可变时,volatile能保证安全访问,比如双重判断的单例模式。但一旦其他状态变量参杂进来的时候,并发情况就无法预知,正确性也无法保障)

    三、Java对象模型

    在内存中,一个Java对象包含三部分:对象头、实例数据和对齐填充。而对象头中又包含锁状态标志、线程持有的锁等标志。

    3.1 oop-klass model

    OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。

    oop体系:

    //定义了oops共同基类
    typedef class   oopDesc*                            oop;
    //表示一个Java类型实例
    typedef class   instanceOopDesc*            instanceOop;
    //表示一个Java方法
    typedef class   methodOopDesc*                    methodOop;
    //表示一个Java方法中的不变信息
    typedef class   constMethodOopDesc*            constMethodOop;
    //记录性能信息的数据结构
    typedef class   methodDataOopDesc*            methodDataOop;
    //定义了数组OOPS的抽象基类
    typedef class   arrayOopDesc*                    arrayOop;
    //表示持有一个OOPS数组
    typedef class   objArrayOopDesc*            objArrayOop;
    //表示容纳基本类型的数组
    typedef class   typeArrayOopDesc*            typeArrayOop;
    //表示在Class文件中描述的常量池
    typedef class   constantPoolOopDesc*            constantPoolOop;
    //常量池告诉缓存
    typedef class   constantPoolCacheOopDesc*   constantPoolCacheOop;
    //描述一个与Java类对等的C++类
    typedef class   klassOopDesc*                    klassOop;
    //表示对象头
    typedef class   markOopDesc*                    markOop;

    如上面代码所示, oops模块包含多个子模块, 每个子模块对应一个类型, 每一个类型的oop都代表一个在JVM内部使用的特定对象的类型。其中有一个变量oop的类型oopDesc是oops模块的共同基类型。而oopDesc类型又包含instanceOopDesc (类实例)、arrayOopDesc (数组)等子类类型。其中instanceOopDesc 中主要包含以下几部分数据:markOop _mark和union _metadata 以及一些不同类型的 field。

    在java程序运行过程中, 每创建一个新的java对象, 在JVM内部就会相应的创建一个对应类型的oop对象来表示该java对象。而在HotSpot虚拟机中, 对象在内存中包含三块区域: 对象头、实例数据和对齐填充。其中对象头包含两部分内容:_mark和_metadata,而实例数据则保存在oopDesc中定义的各种field中。

    _mark:

    _mark这一部分用于存储对象自身的运行时数据, 如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等, 这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit, 官方称它为 "Mark Word"。对象需要存储的运行时数据很多, 其实已经超出了32位和64位Bitmap结构所能记录的限度, 但是对象头信息是与对象自身定义的数据无关的额外存储成本, 考虑到虚拟机的空间效率, Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息, 它会根据对象的状态复用自己的存储空间。  

    _metadata:

    _metadata这一部分是类型指针, 即对象指向它的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针, 换句话说查找对象的元数据信息并不一定要经过对象本身, 其取决于虚拟机实现的对象访问方式。目前主流的访问方式有使用句柄和直接指针两种, 两者方式的不同这里先暂不做介绍。另外, 如果对象是一个Java数组, 那么在对象头中还必须有一块用于记录数组长度的数据, 因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小, 但是从数组的元数据中却无法确定数组的大小。  

    3.2 Klass体系

    //klassOop的一部分,用来描述语言层的类型
    class  Klass;
    //在虚拟机层面描述一个Java类
    class   instanceKlass;
    //专有instantKlass,表示java.lang.Class的Klass
    class     instanceMirrorKlass;
    //专有instantKlass,表示java.lang.ref.Reference的子类的Klass
    class     instanceRefKlass;
    //表示methodOop的Klass
    class   methodKlass;
    //表示constMethodOop的Klass
    class   constMethodKlass;
    //表示methodDataOop的Klass
    class   methodDataKlass;
    //作为klass链的端点,klassKlass的Klass就是它自身
    class   klassKlass;
    //表示instanceKlass的Klass
    class     instanceKlassKlass;
    //表示arrayKlass的Klass
    class     arrayKlassKlass;
    //表示objArrayKlass的Klass
    class       objArrayKlassKlass;
    //表示typeArrayKlass的Klass
    class       typeArrayKlassKlass;
    //表示array类型的抽象基类
    class   arrayKlass;
    //表示objArrayOop的Klass
    class     objArrayKlass;
    //表示typeArrayOop的Klass
    class     typeArrayKlass;
    //表示constantPoolOop的Klass
    class   constantPoolKlass;
    //表示constantPoolCacheOop的Klass
    class   constantPoolCacheKlass;

    和oopDesc是其他oop类型的父类一样,Klass类是其他klass类型的父类。

    Klass向JVM提供两个功能:

    • 实现语言层面的Java类(在Klass基类中已经实现)
    • 实现Java对象的分发功能(由Klass的子类提供虚函数实现)

    HotSpot JVM的设计者因为不想让每一个对象中都含有一个虚函数表, 所以设计了oop-klass模型, 将对象一分为二, 分为klass和oop。其中oop主要用于表示对象的实例数据, 所以不含有任何虚函数。而klass为了实现虚函数多态, 所以提供了虚函数表。所以,关于Java的多态,其实也有c++虚函数的影子在。

    3.3 InstanceClass

    VM在运行时,需要一种用来标识Java内部类型的机制。在HotSpot中的解决方案是:为每一个已加载的Java类创建一个InstanceClass对象,用来在JVM层表示Java类。

    InstanceClass内部结构:

    //类拥有的方法列表
    objArrayOop     _methods;
    //描述方法顺序
    typeArrayOop    _method_ordering;
    //实现的接口
    objArrayOop     _local_interfaces;
    //继承的接口
    objArrayOop     _transitive_interfaces;
    //域
    typeArrayOop    _fields;
    //常量
    constantPoolOop _constants;
    //类加载器
    oop             _class_loader;
    //protected域
    oop             _protection_domain;
        ....

    在JVM中,对象在内存中的基本存在形式就是oop。那么,对象所属的类,在JVM中也是一种对象,因此它们实际上也会被组织成一种oop,即klassOop。同样的,对于klassOop,也有对应的一个klass来描述,它就是klassKlass,也是klass的一个子类。klassKlass作为oop的klass链的端点, 它的klass就是它自身。

    3.4 内存存储

    我们首先来看看下面这段代码的存储结构。

    class Model
    {
        public static int a = 1;
        public int b;
    
        public Model(int b) {
            this.b = b;
        }
    }
    
    public static void main(String[] args) {
        int c = 10;
        Model modelA = new Model(2);
        Model modelB = new Model(3);
    }

    存储结构如下:

    由此我们能得出结论: 对象的实例(instantOopDesc)保存在堆上,对象的元数据(instantKlass)保存在方法区,对象的引用保存在栈上。

    在JVM加载java类的时候, JVM会给这个类创建一个instanceKlass并保存在方法区, 用来在JVM层表示该java类。当我们使用new关键字创建一个对象时, JVM会创建一个instanceOopDesc对象, 这个对象包含了对象头和元数据两部分信息。对象头中有一些运行时数据, 其中就包括和多线程有关的锁的信息。而元数据维护的则是指向对象所属的类的InstanceKlass的指针。

    四、总结

    我们再来区分下JVM内存结构、 Java内存模型 以及 Java对象模型 三个概念。
    JVM内存结构,和Java虚拟机的运行时区域有关。
    Java内存模型,和Java的并发编程有关。
    Java对象模型,和Java对象在虚拟机中的表现形式有关。

    关于这三部分内容,本文并未分别展开,因为涉及到的知识点实在太多,如果读者感兴趣,可以自行学习。

    最后,这三个概念非常重要,一定要严格区分开,千万不要在面试中出现答非所为的情况。

     

     

     

    展开全文
  • 深入理解java内存模型

    2018-10-10 13:26:00
    理解java内存模型的一本不错的书。
  • 阿里巴巴专家讲座——java内存模型与并发技术。 主要内容: 学习java并发理论基础:Java Memory Model 学习java并发技术基础:理解同步是如何工作 分析程序什么时候需要同步 几个典型的并发设计策略
  • 一篇关于java新旧内存模型的文章---文章摘自互联网,感兴趣的可以读一读。
  • Java多线程之Java内存模型

    千次阅读 多人点赞 2019-05-29 22:46:46
    在介绍Java内存模型之前,我们先介绍一下计算机硬件的内存模型,因为JVM的并发和物理机器的并发很相似,甚至JVM并发操作中很多设计都是因为计算机系统的设计引发的。 硬件的内存模型 大家都知道计算机系统处理任务...

    在介绍Java内存模型之前,我们先介绍一下计算机硬件的内存模型,因为JVM的并发和物理机器的并发很相似,甚至JVM并发操作中很多设计都是因为计算机系统的设计引发的。

    硬件的内存模型

    大家都知道计算机系统处理任务主要是靠处理器(CPU)来进行运算的,而运算中又会涉及到数据,数据在哪呢,数据自然是存储在计算机内存中,所以处理器在运算过程中不可避免的会涉及到与内存的读写交互,比如读取运算所需的数据,存储运算得到的数据结果等。而处理器的运算速度相比物理内存的读写速度要快得多,所以会出现处理器要等待内存数据读写结束后才能进行下一步的运算,因此为了提高计算机的运算速度,现在的计算机系统为处理器添加了一层读写速度尽量接近处理器的高速缓存来缓解内存与处理器之间的性能差异。这样在处理任务时将运算需要的数据复制到缓存中,运算结束后再将数据从缓存中同步写回到内存,这样处理器在运算时就不需要等待内存数据读写结束了。

    处理器、高速缓存、内存之间的交互关系图如下:

    如上图所示,在多处理器系统中因为每个处理器都有自己的高速缓存,所以这就引发了一个新的问题,如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,那这个时候从高速缓存写回主内存的数据以谁为准呢?这就是引入高速缓存引发的新问题,我们称之为:缓存一致性。

    为了解决缓存一致性的问题,现代计算机系统需要各个处理器读写缓存时遵循一些协议(MSI、MESI、MOSI、Synapse、Firefly、DragonProtocal,这些都是缓存协议),按照协议来进行读写访问缓存。

    既然这里说的是“硬件的内存模型”,那什么是内存模型呢?

    内存模型可以理解为在特定的操作协议下,对特定的内存和高速缓存进行读写访问的抽象。不同的物理机器,可能有着不同的“内存模型”。

    除了为处理器增加高速缓存之外,处理器还会对输入的代码程序进行乱序执行优化,保证该乱序执行之后的结果和顺序执行的结果一致。举个例子:

    int a = 1;
    int b = 2;
    int c = a + b;
    

    上面的这段代码将第一行和第二行调换顺序对最终的结果没有任何的影响。而处理器在实际运算过程中为了优化性能,也会对代码的执行顺序进行类似的调换(保证结果不变的前提下),这种执行顺序的调换称之为指令重排序,而JVM中也存在类似的指令重排序优化功能。至于为什么指令重排序会优化性能,它是如何优化性能的,这就涉及到汇编指令的知识,我也不懂汇编指令,这里就不介绍了,有兴趣的可以自己去了解了解。

    Java内存模型

    前面说过不同的物理机器,可能有着不同的“内存模型”,而Java虚拟机中定义的内存模型可以屏蔽不同的硬件内存模型,这样就可以保证Java程序在各个平台都能达到一致的内存访问效果,也就是常说的一次编写到处运行,因为内存模型为我们屏蔽掉了不同硬件平台之间的差异。

    主内存和工作内存

    Java内存模型中规定所有变量都存储在主内存(虚拟机内存的一部分)中,主要对应Java的堆内存。这里提到的变量实际上是指共享变量,存在线程间竞争的变量,如:实例变量、静态变量和构成数组对象的元素,而局部变量和方法参数因为是线程私有的,所以不存在线程间共享和竞争关系,所以也就不在前面提到的变量范围内。

    每个线程有着自己独有的工作内存,工作内存中保存了被该线程使用到的变量,这些变量来自主内存变量的副本拷贝。线程对变量的所有读写操作都必须在工作内存中进行,不能直接读写主内存中的变量。而不同线程间的工作内存也是独立的,一个线程无法访问其他线程的工作内存中的变量。

    线程工作时,把需要的变量从主内存中拷贝到自己的工作内存,线程运行结束之后再将自己工作内存中的变量写回到主内存中,而多个线程间对变量的交互只能通过主内存来间接实现。具体的线程、工作内存、主内存的交互关系图如下:

    通过上面的图和前面的介绍,我们就很容易明白我们平常所说的多线程编程时遇到数据状态不一致的问题是怎么产生的。例如:线程1和线程2都需要操作主内存中的共享变量A,当线程1已经在工作内存中修改了共享变量A副本的值但是还没有写回主内存,这时线程2拷贝了主内存中共享变量A到自己的工作内存中,紧接着线程1将自己工作内存中修改过的共享变量A的副本写回到了主内存,很明显线程2加载的共享变量A是之前的旧状态的数据,这样就产生了数据状态不一致的问题。

    Java内存模型和硬件内存模型的关系

    大家看前面的Java内存模型交互图和硬件内存模型交互图可以发现两种内存模型其实是很相似的,实际上Java程序在运行过程中,最终还是会映射到具体的硬件处理器内核上,但java内存模型和硬件的内存模型并不完全一致。

    对于硬件内存来说只有寄存器、高速缓存、主内存的概念,并没有工作内存(线程私有数据区)和主内存(JVM堆内存)之分,它们只是java内存模型的一种抽象概念并不是实际存在的,因此java内存模型对内存的划分对硬件内存并没有任何影响。

    在java内存模型中,无论是工作内存还是主内存,它们都有可能存储到硬件的主内存、高速缓存或者是寄存器中,所以java内存模型和硬件内存模型是是一种抽象概念和真实物理硬件的交叉关系。关系图如下:

    内存交互

    前面说到工作内存与主内存会进行数据读写交互,这个读写交互具体实现细节则是由Java内存模型来控制的,Java内存模型为主内存和工作内存间的变量拷贝及同步写回定义了具体的实现协议,该协议主要由8种操作来完成,不同虚拟机在实现时必须保证每一个基本数据类型的操作都是原子性不可再分的(long,double类型的变量在某些平台可以例外,虽然在JVM规范中没有强制要求long,double类型具有原子性,但是规范建议各JVM实现成具有原子性的,实际上市面上的JVM也基本都实现了原子性),具体8种操作如下:

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

    线程、工作内存、主内存对应这8种操作的交互关系图如下:

    按照上面的8种内存交互操作,如果要把一个变量从主内存复制到工作内存,就需要顺序的执行read和load操作,而如果要把一个变量从工作内存同步回主内存,则需要顺序执行store和write操作,这里说的是顺序执行,而不是连续执行,这也就意味着两个操作之间可以插入其他操作,例如对主内存中的变量1和变量2访问时,一种可能的顺序是read 1, read 2, load 2, load 1。

    除此之外,Java内存模型对这8中操作还存在着其他的约束:

    • 只允许read和load、store和write这两对操作成对出现。
    • 不允许线程丢弃它的最近的assign操作,即变量在工作内存中改变之后,必须同步回写到主内存。
    • 不允许线程把没有经过assign操作的变量,同步回写到主内存。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中使用未经初始化的变量,即对一个变量进行use、store操作之前,必须先执行过load、assign操作。
    • 一个变量在同一时刻只能被一条线程执行lock操作,一旦lock成功,可以被同一线程重复lock多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁。
    • 对一个变量执行lock操作,将会清空工作内存中该变量的值,所以在执行引擎使用这个变量前,需要重新执行load或assign操作对其进行初始化。
    • 对一个变量执行unlock操作之前,必须先把该变量同步回主内存(执行store、write操作)。
    • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程lock的变量。

    Java内存模型的3个特征

    Java内存模型其实一直是围绕着并发过程中的如何处理原子性、可见性和有序性这三个特征建立的。

    原子性(Atomicity)

    什么是原子性呢,原子性是指一个操作不可中断,不可分割,在多线程中就是指一旦一个线程开始执行某个操作,就不能被其他线程干扰。

    Java内存模型直接用来保证原子性变量的操作包括use、read、load、assign、store、write,我们大致可以认为Java基本数据类型的访问都是原子性的(long,double除外,前面已经介绍过了),如果用户要操作一个更大的范围保证原子性,Java内存模型还提供了lock和unlock来满足这种需求,但是这两种操作没有直接开放给用户,而是提供了两个更高层次的字节码指令:monitorenter 和 moniterexit,这两个指令对应到Java代码中就是synchronized关键字,所以synchronized代码块之间的操作具有原子性。

    可见性(Visibility)

    可见性是指当一个线程修改了变量之后,其他线程能立刻得知这个修改。

    Java内存模型通过将变量修改后将新值同步写回主内存,在读取前从主内存刷新变量值,所以JVM内存模型是通过主内存作为传递介质来实现可见性的。无论是普通变量还是volatile修饰的变量都是这样的,唯一的区别就是volatile变量在被修改之后会立刻写回主内存,而在读取时都会重新去主内存读取最新的值,而普通变量则在被修改后会先存储在工作内存,之后再从工作内存写回主内存,而读的时候则是从工作内存中读取该变量的副本拷贝。

    除了volatile可以实现可见性之外,synchronized和final关键字也能实现可见性。synchronized同步块的可见性是因为对一个变量执行unlock操作之前,必须将变量的改动写回主内存来(store、write两个操作)实现的。而final字段则是因为一旦final字段初始化完成,其他线程就可以访问final字段的值,而且final字段初始化完成之后就不再可变。

    有序性(Ordering)

    前面说过处理器在执行运算的时候,会对程序代码进行乱序执行优化,也叫做重排序优化。同样的,在JVM中也存在指令重排序优化,这种优化在单线程中是不会存在问题的,但如果这种优化出现在多线程环境中,就可能会出现多线程安全的问题,因为线程1的指令优化可能影响线程2中某个状态。

    Java提供了volatile和synchronized关键字来保证线程间操作的有序性。volatile是因为其本身的禁止指令重排序语义来实现的,而synchronized则是由“同一个变量在同一时刻只能有一个线程对其进行lock操作”这条规则来实现的,这也就是synchronized代码块对同一个锁只能串行进入的原因。

    上面介绍了Java内存模型的3中特性,我们可以发现synchronized可以说是万能的,它能实现Java多线程中的这3大特性,所以这也早就了很多人在遇到多线程并发操作事都是直接使用synchronized完成,但使用synchronized内置锁会阻塞需要而又没有获取该内置锁的线程,而Java中的线程与操作系统中的原生线程是一一对应的,所以当synchronized内置锁导致某个线程阻塞后,会导致系统从用户态切换到内核态执行阻塞操作,这个操作是非常耗时的。

    关于Java内存模型就暂时介绍到这里,接下来的一篇文章会接着介绍更加轻量级的同步实现:volatile关键字,同时还会介绍volatile实现中涉及到的内存屏障。


    下面是我的个人公众号,欢迎关注交流

    展开全文
  • JVM内存结构和Java内存模型别再傻傻分不清了

    万次阅读 多人点赞 2020-03-04 20:53:30
    JVM内存结构和Java内存模型都是面试的热点问题,名字看感觉都差不多,网上有些博客也都把这两个概念混着用,实际上他们之间差别还是挺大的。 通俗点说,JVM内存结构是与JVM的内部存储结构相关,而Java内存模型是与多...
  • 深入理解Java内存模型
  • Java内存模型(Java Memory Model)  Java内存模型(JMM),不同于Java运行时数据区,JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 336,752
精华内容 134,700
关键字:

java内存模型

java 订阅