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的旧语义只承诺在读和写的变量的可见性,而不承诺其他变量,虽然这种方法更加有效的实现,但是结果会和我们设计之初大相径庭。
收起全文
精华内容
下载资源
问答
  • 2021-12-02 17:13:40

    Java内存结构、内存模型、对象模型这几个概念经常会被混为一谈。理论知识容易忘写下来帮助记忆,方便以后查看。

    1、Java内存结构

    JVM运行时内存区域
    Java内存结构其实说的是JVM在运行时内存区域的划分,是根据Java虚拟机规范来实现的。说的是JVM中划分出的各块内存区域分别用来干什么。如上图分为了5大块:方法区,虚拟机栈(有的叫方法栈也有叫线程栈的)、本地方法栈、堆、程序计数器(也有叫寄存器的)。其中方法区和堆是线程共享的内存区域,而虚拟机栈、本地方法栈和程序计数器是线程私有的,或者说是线程隔离的。

    1.1 程序计数器:

    它是当前线程执行字节码的指示器,通过改变它的值来选取下一条要执行的Java字节码指令。Java代码中的循环、跳转、异常处理、线程获得时间片后的恢复都需要依赖程序计数器。程序计数器所占内存很小,也是唯一一块不会出现内存溢出异常的内存区域。

    1.2 虚拟机栈:

    虚拟机栈是线程私有的,每一个线程都会分配一个虚拟机栈,它描述的是Java方法执行的内存模型,每个方法执行时都会同步创建一个栈帧,用于存储方法运行过程中需要的数据,指令,返回地址等信息。

    1.3 本地方法栈

    本地方法栈和虚拟机栈描述类似,区别只在于虚拟机栈描述的是Java方法,而本地方法栈描述的是Native方法(c++语言)。

    1.4 方法区

    它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区只是Java虚拟机规范中的一种理论思想,在jdk1.7之前这部分内容被存放于永久代中,在jdk1.8之后用元空间来代替。jdk的做法是理论的实现方式。永久代和元空间都可以通过jvm参数来调整其大小,不同的是元空间使用的是直接内存。

    1.5 堆

    堆是这几块内存区域中最大的一块,存放的是Java中几乎所有的对象实例。几乎所有对象也就是说不是全部,从jdk1.7之后开启了逃逸分析,感兴趣的可以查一查,它说的是当方法中的一个对象,未被外部引用并且未被方法返回,也就是说这个对象只在这个方法内有用,那么这个对象可以直接在栈上分配内存。

    2、Java内存模型(JMM)

    JMM
    JMM可以理解为Java模拟了cpu的硬件架构所规定的一种规范。由于cpu处理速度非常块比内存的读写速度要快很多,为了不妥协内存的读写速度,cpu引入了高速缓存,cpu要想修改主内存的值必须先将主内存中的数据拷贝到高速缓存中进行读取,然后再回写入主内存。
    从上节内容可知,Java堆和方法区域是多个线程共享的数据区域。也就是说,多个线程可以操作保存在堆或者方法区中的同一数据。
    JMM和多线程相关,它定义了一个线程对共享变量的写入对另一个线程是可见的。Java的多线程间是通过共享内存进行通信的,在通信过程中就会存在一系列如可见性、原子性、顺序性等问题。而JMM就是围绕多线程通信以及其相关的一系列特性而建立的模型。JMM定义了一系列语法集,这些语法集映射到Java中就是volatilesynchronized等关键字。

    更多相关内容
  • Java 内存模型

    2018-07-23 16:45:07
    深入理解 java 内存模型java 程序员的必修课,看看原汁原味正宗的内存模型
  • 讲一讲什么是Java内存模型 Java内存模型虽说是一个老生常谈的问题 ,也是大厂面试中绕不过的,甚至初级面试也会问到。但是真正要理解起来,还是相当困难,主要这个东西看不见,摸不着。 这是一个比较开放的题目,...
  • 在说Java内存模型之前,我们先说一下Java的内存结构,也就是运行时的数据区域:Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为几个不同的数据区域,这些区域都有各自的用途、创建时间、销毁时间
  • Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main ...
  • 主要介绍了Java内存模型与JVM运行时数据区的区别详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
  • 深入理解Java内存模型 pdf 超清版
  • Java内存模型

    千次阅读 2021-11-16 18:12:44
    ​ 在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会...

    Java内存模型

    ​ 在前面阅读了Thread类的源码,总结了线程的创建、停止、生命周期,以及多线程编程的异常处理,那么接下来就要总结多线程编程下的安全问题。

    ​ 事实上,并不是所用的情况用多线程就一定比单线程快。多线程如果使用不当,不仅会带来严重的安全性问题,也会造成性能问题。

    ​ 在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会访问或者修改其他线程正在使用的变量造成严重的后果。那么这些问题是怎么造成的呢?

    Java内存模型

    ​ Java内存模型,全称Java Memory Model,JMM,目的是为了屏蔽掉各种硬件与操作系统的内存访问差异,让Java程序在各种平台下都能达到一致的访问效果。它是Java虚拟机定义的一组规范,目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

    ​ Java内存模型定义了所有的变量都存储在主内存中,每条线程也有自己的工作内存,线程的工作内存中,保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都在工作内存中进行,而不能直接读写主内存的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存完成。

    CPU有多级缓存,Java做为高级语言,屏蔽了CPU cache的底层细节,用JMM定义了一套读写内存的规范:主内存与工作内存就是JMM抽象出来的概念

    线程、主内存、工作内存三者的交互关系如下图所示:

    在这里插入图片描述

    内存之间的交互

    ​ 那么主内存与工作内存直接具体是怎么交互的呢,一个变量是怎么从主内存拷贝到工作内存的?

    主内存与工作内存交互的八种操作

    ​ Java内存模型中,定义了以下这8种操作,并规定了虚拟机实现这八种操作时,每一种操作都是原子的、不可再分的

    • lock

    ​ 作用于主内存的变量,它把一个变量标识为一条线程独占的状态

    • unlock

    ​ 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

    • read

    ​ 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后续的load使用

    • load

    ​ 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

    • use

    ​ 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令就会执行这个操作

    • assign

    ​ 作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

    • store

    ​ 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便后序的write操作使用

    • write

    ​ 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中

    ​ 个人理解,这八个指令其实就是保证了变量从主内存到工作内存的读取/写入,从工作内存到虚拟机使用的读取/写入,每个步骤的原子性。但是在read与load、store和write之间,Java内存模型只要求它们必须按顺序执行,却不要求是连续执行。也就是说读取主内存的变量a、b时,很有可能的顺序是:read a、read b、load b、load a。

    八种操作需要满足的规则

    ​ 此外,Java内存模型还规定了在上述八种操作时,必须满足下面的规则:

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

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

    • ​ 不允许一个线程无原因(没发生过assign操作)把数据从线程的工作内存中同步回主内存中

    • ​ 一个新的变量只能在主内存中诞生,不允许工作内存中直接使用一个未被初始化的变量(也就是说,在变量use、store之前必须经过assign与load操作)

    • ​ 一个变量在同一时刻只允许一条线程对其进行lock操作,但是lock操作可以被同一条线程执行多次(lock多少次也必须unlock多少次,才能解锁)

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

    • ​ 如果一个变量事先没有被lock操作锁定,那么久不允许对它执行unlock操作

    • ​ 对一个变量执行unlock操作之前,必须把这个遍历同步回主内存中

    ​ 上面的描述可能有点绕。

    ​ 实际上,它们都是为了在并发过程中如何处理原子性、可见性、有序性这三个特征来建立的。

    ​ 那么原子性、可见性、有序性这三个指的是什么呢?

    ​ 我们先看下并发编程中非常出名的的两种栗子:read-modify-write和check-and-act。

    read-modify-write

    ​ 大名鼎鼎的read-modify-write问题:(两个线程同时对主内存的一个变量进行读-改-写问题)

    测试代码:

    @NotThreadSafe
    public class NotSafeThreadTest implements Runnable {
        public static NotSafeThreadTest instance = new NotSafeThreadTest();
        int i = 0;
    
        public static void main(String[] args) throws InterruptedException {
            Thread thread1 = new Thread(instance);
            Thread thread2 = new Thread(instance);
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println(instance.i);
        }
    
        @Override
        public void run() {
            for (int j = 0; j < 1000; j++) {
                i++;
            }
        }
    }
    

    测试结果:

    多试几次,你会发现每次的测试结果都不一样哈。这里就随便截了几次:

    在这里插入图片描述

    在这里插入图片描述

    结果分析:

    ​ 为什么会出现这种结果呢?

    这就涉及到可见性、一致性这两个问题:

    可见性

    在这里插入图片描述

    ​ 我们现在有线程1,线程2两个线程同时对主内存中的变量i进行修改。假设它们非常正好的同时执行完了i++,想要把各自工作内存中的i=1写回主内存,因为没有同步手段的保护,这个时候线程1先把i改成1,然后线程2过来,也是把i改成了1。

    ​ 而我们期望的则是线程2可以在修改时,可以看到线程1把主内存中的i改成了1,然后再重新进行计算,得出i=2。

    ​ 事实上这就是可见性造成的问题。

    可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

    Happens-Before原则

    ​ Happens-Before原则指的是前面一个操作的结果对后续操作是可见的。

    ​ Java内存模型下的Happens-Before:

    程序次序规则:

    ​ 在一个线程内,按照程序代码顺序,写在前面的操作先行发生在后面的操作

    管程锁定:

    ​ 一个unlock操作先行发生与后面同一个锁的lock操作

    volatile变量规则:

    ​ 对一个volatile变量的写操作先行发生于后面对这个变量的读操作

    线程启动规则:

    ​ Thread对象的start方法先行发生于此线程的每一个动作

    线程终止规则:

    ​ 线程中的所有操作都先行发生于此线程的终止检测

    线程中断规则

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

    对象终结规则:

    ​ 一个对象的初始化完成先行发生于它的finalize方法的开始

    传递性:

    ​ 如果操作A先行发生于B,操作B先行发生于C,那么就可以得出操作A先行发生于C

    ​ 这些规则有些抽象,但是它其实是用来约束编译器的优化行为的。允许编译器优化,但是要求编译器优化后需要遵守这些Happends-Before规则,所以我们才可以利用各种同步手段来保证并发编程下,我们代码的安全性与正确性。

    保证可见性的措施

    ​ 根据Happends-Before,我们能看出volatile关键字、synchronize关键字、Thread.join、Thread.start、Lock、并发集合都能保证一定的可见性。

    原子性

    我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性

    ​ 关于我们程序中的i++,看起来我们只写了一行代码。那么对应到虚拟机与操作系统做了什么呢,我们反编译看一下:

    i++的虚拟机字节码指令

    测试代码:

        public static void main(String[] args) {
            int i=0;
            i++;
        }
    

    反编译后的class:

    https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokevirtual 可以在oracle官网查看字节码指令含义

    public class com.designpattern.demo.thread.safe.IncreasingClass {
      public com.designpattern.demo.thread.safe.IncreasingClass();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public static void main(java.lang.String[]);
        Code:
           0: iconst_0				//常量0加载到操作数栈
           1: istore_1				//将栈顶的0存储到局部变量表(后1表示存入第几个本地变量表,此处第2个,从0开始)
           2: iinc          1, 1	//变量i直接在局部变量表的slot上进行运算,并不影响操作数栈已经存在的值
           5: return
    }
    
    i++的操作系统执行指令

    CPU至少执行了三条指令:

    • 将变量i从主内存加载到CPU寄存器

    • 在寄存器中执行+1

    • 将结果写入主内存

    ​ CPU执行这几行命令的时候,并不像我们想的那样会一口气执行完。实际上,如果我们没有加任何同步措施,操作系统可以在CPU执行完这三条的任意一条指令后做任务切换。

    任务切换与时间片

    ​ 我们知道IO的速度是远远小于CPU的运算速度的。为了提高CPU使用率,操作系统允许某个进程执行一小段时间,比如50ms,过了50ms操作系统就会重新选择一个进程来执行,而这50ms就称为时间片。

    ​ 在一个时间片内,如果一个进程进行一个非常耗时的I/O操作,这个时候进程就可以把自己标记为休眠状态,并让出CPU的使用权,这样就可以让CPU在等待的时间去做别的事情。等文件读进内存,操作系统就会把这个休眠的进程唤醒,唤醒之后的进程就有机会重新获得CPU的使用权。

    ​ 那么可以看出,在没加任何同步措施的情况下,任务切换其实也是并发编程问题的原因之一。

    ​ CPU保证的原子操作是在CPU的指令级别(也就是说上面的八种操作每种是原子的),而不是我们的Java语句。所以我们需要解决的问题就是在使用Java开发的过程中,合理的利用同步手段保证并发编程下的程序安全。

    小扩展:

    Q:原子性操作+原子性操作=原子性操作吗?

    A:并不,比如取钱是原子操作,但是两次取钱中间你完全可以做别的事情把钱花掉呀。

    Q:long和double的特殊性

    A:long和double是64位的,它们有特殊的规定:允许虚拟机将没被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。但是目前商用Java虚拟机并不会出现当多个线程共享一个没有声明volatile的long或者double类型的变量,某个线程读取到一个修改一半的情况,目前各种平台下的虚拟机都会选择将64位数据的读写操作作为原子操作。

    check-and-act

    ​ 这个就是所谓的先检查,在执行某个操作,最著名的就是单例模式的double check:

    @NotThreadSafe
    public class DoubleCheckNotSafeSingleton {
        private static DoubleCheckNotSafeSingleton singleton;
    
        private DoubleCheckNotSafeSingleton() {
        }
    
        public static DoubleCheckNotSafeSingleton newInstance() {
            if (singleton == null) {
                //1
                synchronized (DoubleCheckNotSafeSingleton.class) {
                    //2
                    if (singleton == null) {
    
                        singleton = new DoubleCheckNotSafeSingleton();
                    }
                }
            }
            return singleton;//3
        }
    
    }
    

    ​ 这个单例模式看上去加了锁保证了安全也加了双重检查,那么实际上真的是安全的吗?

    ​ 问题就出现在singleton = new DoubleCheckNotSafeSingleton();这一行上:

    public class com.designpattern.demo.thread.safe.DoubleCheckNotSafeSingleton {
      public static com.designpattern.demo.thread.safe.DoubleCheckNotSafeSingleton newInstance();
        Code:
           0: getstatic     #2                  // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton;
           3: ifnonnull     37
           6: ldc           #3                  // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton
           8: dup
           9: astore_0
          10: monitorenter
          11: getstatic     #2                  // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton;
          14: ifnonnull     27
          17: new           #3                  // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton
          20: dup
          21: invokespecial #4                  // Method "<init>":()V
          24: putstatic     #2                  // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton;
          27: aload_0
          28: monitorexit
          29: goto          37
          32: astore_1
          33: aload_0
          34: monitorexit
          35: aload_1
          36: athrow
          37: getstatic     #2                  // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton;
          40: areturn
        Exception table:
           from    to  target type
              11    29    32   any
              32    35    32   any
    }
    

    可以看到这一行对应到虚拟机字节码指令是:

    	  17: new           #3                  // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton
          20: dup
          21: invokespecial #4                  // Method "<init>":()V
          24: putstatic     #2                  // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton;
          27: aload_0
    

    实际上,我们以为的new操作是:

    1. ​ 分配一块内存M
    2. ​ 在内存M上初始化DoubleCheckNotSafeSingleton对象
    3. ​ 将M的地址赋值给singleton变量

    但是实际上JVM的编译器为了优化性能,有时候会改变程序中语句的执行顺序,(但并不会影响程序获得正确的结果)比如:

    	    int x=0;
            int y=0;
            x=x+5;
    

    很有可能会优化成:

        int x=0;
        x=x+5;
        int y=0;
    

    那么上面的new操作很有可能会优化成:

    1. ​ 分配一块内存M

    2. ​ 将M的地址赋值给singleton变量

    3. ​ 在内存M上初始化DoubleCheckNotSafeSingleton对象

    ​ 那这时,假设线程A先执行到了newInstance方法,执行完了将M的地址赋值给singleton变量指令后,恰巧切换到了线程B,线程B执行newInstance的第一个判断就会发现singleton!=null,会直接返回,但是这个时候的singleton还没有初始化,如果这个时候访问singleton的成员变量就会出发空指针异常。

    有序性

    有序性指的是程序按照代码的先后顺序执行

    可见性、有序性、原子性的总结:

    ​ 经过上面的描述,可以看出可见性其实是因为CPU的多级缓存造成的,原子性则是线程切换带来的,而编译优化则是有序性的原因。而多级缓存、线程切换、编译优化的目的也是为了提高程序的性能。这其实就说明了技术在解决一个问题的同时必然会带来另外一个问题,我们要根据实际情况与需求去做取舍。

    ​ 在实际开发中,Java提供了volatile与synchronized两个关键字来保证线程之间操作的有序性,大部分都并发场景加锁也确实都能保证安全。但是并不是所有的场景都需要加锁,否则操作系统、JVM为了提升性能做的优化也就没了意义。锁也有很多种,可以根据需求选择出最适合自己业务的锁,而除了加锁,有的时候也有不加锁也能保证安全性的更好的方案。一切都要根据需求而定。

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

    2021-10-07 11:44:31
    java内存模型.pdf
  • 1. JVM内存模型 Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。 首先对于一个进程来说,它包含多个线程,每个线程都有其独立的...

    1. JVM内存模型

    Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。
    在这里插入图片描述
    首先对于一个进程来说,它包含多个线程,每个线程都有其独立的内存区域,包括:虚拟机栈,本地方法栈和程序计数器。

    程序计数器:线程私有

    记录当前线程所执行到的字节码的行号。
    每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存区域。
    它是唯一没有OutOfMemoryError情况的内存区域。
    它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。

    程序计数器在哪些地方用到了?
    字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复登基础功能都需要依赖这个计数器来完成。

    Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。

    Java虚拟机栈:线程私有

    描述的是 Java ⽅法执⾏的内存模型,每个Java方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息。

    每个Java方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

    Java 虚拟机栈也是线程私有的,每个线程都有各⾃的Java虚拟机栈,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。

    局部变量表
    局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

    这些数据类型在局部变量表中的存储空间是以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。

    局部变量表所需的内存空间在编译期间完成分配。当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

    Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    StackOverFlowError:若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最⼤深度的时候,就抛出StackOverFlowError异常。(典型的场景有:递归调用和死循环)
    OutOfMemoryError:若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且当线程请求栈时内存⽤完了,⽆法再动态扩展了,此时抛出OutOfMemoryError异常。

    在栈上分配对象
    大多数对象都在堆上分配内存空间,但是由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致对象在堆上分配不是那么绝对了。

    本地方法栈:线程私有

    本地方法栈和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息。

    ⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。

    看完了线程私有的内存区域,在来看看线程共享的内存区域:

    堆:线程共享

    Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。

    此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。

    堆分区
    Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。

    从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代。
    新生代再细致⼀点有:Eden空间、From Survivor、To Survivor空间等。
    进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。
    在这里插入图片描述
    上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

    方法区:线程共享

    它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

    ⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。永久代是对方法区的一种实现方式。方法区是一种定义,永久代是一种实现。

    运⾏时常量池
    运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)

    既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。

    JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池。字符串常量池1.7以后也是放在堆中
    在这里插入图片描述

    JDK1.7和1.8 内存模型的区别

    JDK1.7JVM内存模型:
    线程私有:Java虚拟机栈、本地方法栈、程序计数器
    线程共享:方法区、堆
    在这里插入图片描述
    JDK1.8JVM内存模型:

    JDK1.8与1.7最大的区别是1.8将永久代(方法区)取消,取而代之的是元空间。

    JDK1.7方法区是由永久代实现的,JDK1.8方法区是由元空间实现的,元空间属于本地内存,所以元空间的大小受本地内存的限制。
    在这里插入图片描述
    直接内存
    直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。

    2. Java内存模型(JMM)

    Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

    主内存:主要对应Java堆中的对象实例数据部分。(寄存器,高速缓存)
    工作内存:对应于虚拟机栈中的部分区域。(硬件的内存)

    在这里插入图片描述在这里插入图片描述

    3 . Java对象的创建过程

    类加载检查: 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的类参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。(详细的类加载机制会新出一篇博客)

    分配内存: 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
    在这里插入图片描述
    初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。

    设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

    执⾏ init ⽅法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。

    [注意]:执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

    展开全文
  • 读深入理解JAVA内存模型整理的思维导图 本人记忆差,整理这个思维导图,相当于较认真的看了一遍,整个思维导图,基本上就是对原文的拷贝。 有了层级关系。和本人自己的一些理解。由于思维导图弄出来的图片大于100M了...
  • Java 内存模型的抽象 4 重排序 6 处理器重排序与内存屏障指令 7 happens-before 10 重排序 13 数据依赖性 13 as-if-serial 语义 13 程序顺序规则 15 重排序对多线程的影响 15 顺序一致性 19 数据竞争与顺序...
  • 深入理解 Java 内存模型,由程晓明编著,深入理解java内存模型JMM
  • 深度剖析java内存模型

    2019-06-03 11:35:21
    深度剖析java内存模型深度剖析java内存模型深度剖析java内存模型
  • 深入Java 内存模型

    2018-08-15 14:57:57
    深入Java 内存模型本书介绍了,如何深入学习了解JAVA 内存模型!更好的了解java 虚拟机!
  • 【深入Java虚拟机】Java内存模型探讨一.pdf
  • Java内存模型分析与其在编程中的应用.pdf
  • jvm和jmm的模型图,注意在浏览器中搜索draw.io进行文件的打开。
  • 深入理解java内存模型

    2018-10-10 13:26:00
    理解java内存模型的一本不错的书。
  • 阿里巴巴专家讲座——java内存模型与并发技术。 主要内容: 学习java并发理论基础:Java Memory Model 学习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新旧内存模型的文章---文章摘自互联网,感兴趣的可以读一读。
  • Java内存模型JMM详解

    2020-08-28 18:04:37
    主要介绍了Java内存模型JMM详解,涉及volatile和监视器锁,final字段,内存屏障等相关内容,具有一定参考价值,需要的朋友可以了解下。
  • Java内存模型(Java Memory Model)  Java内存模型(JMM),不同于Java运行时数据区,JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了...
  • Java内存模型知识详解

    2020-08-18 17:03:49
    主要介绍了Java内存模型知识详解,文中通过对内存访问时的交互关系图解介绍的十分详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 373,475
精华内容 149,390
关键字:

java内存模型

java 订阅
友情链接: inviteInfoNet.rar