java内存模型的JMM简介
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java内存模型的JMM简介相关的知识,希望对你有一定的参考价值。
参考技术A 1)JSR133:
在Java语言规范里面指出了JMM是一个比较开拓性的尝试,这种尝试视图定义一个一致的、跨平台的内存模型,但是它有一些比较细微而且很重要的缺点。其实Java语言里面比较容易混淆的关键字主要是synchronized和volatile,也因为这样在开发过程中往往开发者会忽略掉这些规则,这也使得编写同步代码比较困难。
JSR133本身的目的是为了修复原本JMM的一些缺陷而提出的,其本身的制定目标有以下几个: 保留目JVM的安全保证,以进行类型的安全检查: 提供(out-of-thin-air safety)无中生有安全性,这样“正确同步的”应该被正式而且直观地定义 程序员要有信心开发多线程程序,当然没有其他办法使得并发程序变得很容易开发,但是该规范的发布主要目标是为了减轻程序员理解内存模型中的一些细节负担 提供大范围的流行硬件体系结构上的高性能JVM实现,现在的处理器在它们的内存模型上有着很大的不同,JMM应该能够适合于实际的尽可能多的体系结构而不以性能为代价,这也是Java跨平台型设计的基础 提供一个同步的习惯用法,以允许发布一个对象使他不用同步就可见,这种情况又称为初始化安全(initialization safety)的新的安全保证 对现有代码应该只有最小限度的影响 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):
当一个线程需要修改另外线程的可见单元的时候必须遵循以下原则: 一个写入线程释放的同步锁和紧随其后进行读取的读线程的同步锁是同一个 从本质上讲,释放锁操作强迫它的隶属线程【释放锁的线程】从工作内存中的写入缓存里面刷新(专业上讲这里不应该是刷新,可以理解为提供)数据(flush操作),然后获取锁操作使得另外一个线程【获得锁的线程】直接读取前一个线程可访问域(也就是可见区域)的字段的值。因为该锁内部提供了一个同步方法或者同步块,该同步内容具有线程排他性,这样就使得上边两个操作只能针对单一线程在同步内容内部进行操作,这样就使得所有操作该内容的单一线程具有该同步内容(加锁的同步方法或者同步块)内的线程排他性,这种情况的交替也可以理解为具有“短暂记忆效应”。 这里需要理解的是同步的双重含义:使用锁机制允许基于高层同步协议进行处理操作,这是最基本的同步;同时系统内存(很多时候这里是指基于机器指令的底层存储关卡memory barrier,前边提到过)在处理同步的时候能够跨线程操作,使得线程和线程之间的数据是同步的。这样的机制也折射出一点,并行编程相对于顺序编程而言,更加类似于分布式编程。后一种同步可以作为JMM机制中的方法在一个线程中运行的效果展示,注意这里不是多个线程运行的效果展示,因为它反应了该线程愿意发送或者接受的双重操作,并且使得它自己的可见区域可以提供给其他线程运行或者更新,从这个角度来看,使用锁和消息传递可以视为相互之间的变量同步,因为相对其他线程而言,它的操作针对其他线程也是对等的。 一旦某个字段被申明为volatile,在任何一个写入线程在工作内存中刷新缓存的之前需要进行进一步的内存操作,也就是说针对这样的字段进行立即刷新,可以理解为这种volatile不会出现一般变量的缓存操作,而读取线程每次必须根据前一个线程的可见域里面重新读取该变量的值,而不是直接读取。 当某个线程第一次去访问某个对象的域的时候,它要么初始化该对象的值,要么从其他写入线程可见域里面去读取该对象的值;这里结合上边理解,在满足某种条件下,该线程对某对象域的值的读取是直接读取,有些时候却需要重新读取。 这里需要小心一点的是,在并发编程里面,不好的一个实践就是使用一个合法引用去引用不完全构造的对象,这种情况在从其他写入线程可见域里面进行数据读取的时候发生频率比较高。从编程角度上讲,在构造函数里面开启一个新的线程是有一定的风险的,特别是该类是属于一个可子类化的类的时候。Thread.start由调用线程启动,然后由获得该启动的线程释放锁具有相同的“短暂记忆效应”,如果一个实现了Runnable接口的超类在子类构造子执行之前调用了Thread(this).start()方法,那么就可能使得该对象在线程方法run执行之前并没有被完全初始化,这样就使得一个指向该对象的合法引用去引用了不完全构造的一个对象。同样的,如果创建一个新的线程T并且启动该线程,然后再使用线程T来创建对象X,这种情况就不能保证X对象里面所有的属性针对线程T都是可见的除非是在所有针对X对象的引用中进行同步处理,或者最好的方法是在T线程启动之前创建对象X。 若一个线程终止,所有的变量值都必须从工作内存中刷到主存,比如,如果一个同步线程因为另一个使用Thread.join方法的线程而终止,那么该线程的可见域针对那个线程而言其发生的改变以及产生的一些影响是需要保证可知道的。 注意:如果在同一个线程里面通过方法调用去传一个对象的引用是绝对不会出现上边提及到的可见性问题的。JMM保证所有上边的规定以及关于内存可见性特性的描述——一个特殊的更新、一个特定字段的修改都是某个线程针对其他线程的一个“可见性”的概念,最终它发生的场所在内存模型中Java线程和线程之间,至于这个发生时间可以是一个任意长的时间,但是最终会发生,也就是说,Java内存模型中的可见性的特性主要是针对线程和线程之间使用内存的一种规则和约定,该约定由JMM定义。
不仅仅如此,该模型还允许不同步的情况下可见性特性。比如针对一个线程提供一个对象或者字段访问域的原始值进行操作,而针对另外一个线程提供一个对象或者字段刷新过后的值进行操作。同样也有可能针对一个线程读取一个原始的值以及引用对象的对象内容,针对另外一个线程读取一个刷新过后的值或者刷新过后的引用。
尽管如此,上边的可见性特性分析的一些特征在跨线程操作的时候是有可能失败的,而且不能够避免这些故障发生。这是一个不争的事实,使用同步多线程的代码并不能绝对保证线程安全的行为,只是允许某种规则对其操作进行一定的限制,但是在最新的JVM实现以及最新的Java平台中,即使是多个处理器,通过一些工具进行可见性的测试发现其实是很少发生故障的。跨线程共享CPU的共享缓存的使用,其缺陷就在于影响了编译器的优化操作,这也体现了强有力的缓存一致性使得硬件的价值有所提升,因为它们之间的关系在线程与线程之间的复杂度变得更高。这种方式使得可见度的自由测试显得更加不切实际,因为这些错误的发生极为罕见,或者说在平台上我们开发过程中根本碰不到。在并行程开发中,不使用同步导致失败的原因也不仅仅是对可见度的不良把握导致的,导致其程序失败的原因是多方面的,包括缓存一致性、内存一致性问题等。
可排序性(Ordering):
可排序规则在线程与线程之间主要有下边两点: 从操作线程的角度看来,如果所有的指令执行都是按照普通顺序进行,那么对于一个顺序运行的程序而言,可排序性也是顺序的 从其他操作线程的角度看来,排序性如同在这个线程中运行在非同步方法中的一个“间谍”,所以任何事情都有可能发生。唯一有用的限制是同步方法和同步块的相对排序,就像操作volatile字段一样,总是保留下来使用 【*:如何理解这里“间谍”的意思,可以这样理解,排序规则在本线程里面遵循了第一条法则,但是对其他线程而言,某个线程自身的排序特性可能使得它不定地访问执行线程的可见域,而使得该线程对本身在执行的线程产生一定的影响。举个例子,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;
// 线程1
configOptions = new HashMap();
configText = readConfigFile(filename);
processConfigOptions(configText,configOptions);
initialized = true;
// 线程2
while(!initialized)
sleep();
这里的思想是使用易失性变量initialized担任守卫来表明一套别的操作已经完成了,这是一个很好的思想,但是不能在JMM下工作,因为旧的JMM允许非易失性的写(比如写到configOptions字段,以及写到由configOptions引用Map的字段中)与易失性的写一起重新排序,因此另外一个线程可能会看到initialized为true,但是对于configOptions字段或它所引用的对象还没有一个一致的或者说当前的针对内存的视图变量,volatile的旧语义只承诺在读和写的变量的可见性,而不承诺其他变量,虽然这种方法更加有效的实现,但是结果会和我们设计之初大相径庭。
死磕 java同步系列之JMM(Java Memory Model)
简介
Java内存模型是在硬件内存模型上的更高层的抽象,它屏蔽了各种硬件和操作系统访问的差异性,保证了Java程序在各种平台下对内存的访问都能达到一致的效果。
硬件内存模型
在正式讲解Java的内存模型之前,我们有必要先了解一下硬件层面的一些东西。
在现代计算机的硬件体系中,CPU的运算速度是非常快的,远远高于它从存储介质读取数据的速度,这里的存储介质有很多,比如磁盘、光盘、网卡、内存等,这些存储介质有一个很明显的特点——距离CPU越近的存储介质往往越小越贵越快,距离CPU越远的存储介质往往越大越便宜越慢。
所以,在程序运行的过程中,CPU大部分时间都浪费在了磁盘IO、网络通讯、数据库访问上,如果不想让CPU在那里白白等待,我们就必须想办法去把CPU的运算能力压榨出来,否则就会造成很大的浪费,而让CPU同时去处理多项任务则是最容易想到的,也是被证明非常有效的压榨手段,这也就是我们常说的“并发执行”。
但是,让CPU并发地执行多项任务并不是那么容易实现的事,因为所有的运算都不可能只依靠CPU的计算就能完成,往往还需要跟内存进行交互,如读取运算数据、存储运算结果等。
前面我们也说过了,CPU与内存的交互往往是很慢的,所以这就要求我们要想办法在CPU和内存之间建立一种连接,使它们达到一种平衡,让运算能快速地进行,而这种连接就是我们常说的“高速缓存”。
高速缓存的速度是非常接近CPU的,但是它的引入又带来了新的问题,现代的CPU往往是有多个核心的,每个核心都有自己的缓存,而多个核心之间是不存在时间片的竞争的,它们可以并行地执行,那么,怎么保证这些缓存与主内存中的数据的一致性就成为了一个难题。
为了解决缓存一致性的问题,多个核心在访问缓存时要遵循一些协议,在读写操作时根据协议来操作,这些协议有MSI、MESI、MOSI等,它们定义了何时应该访问缓存中的数据、何时应该让缓存失效、何时应该访问主内存中的数据等基本原则。
而随着CPU能力的不断提升,一层缓存就无法满足要求了,就逐渐衍生出了多级缓存。
按照数据读取顺序和CPU的紧密程度,CPU的缓存可以分为一级缓存(L1)、二级缓存(L2)、三级缓存(L3),每一级缓存存储的数据都是下一级的一部分。
这三种缓存的技术难度和制作成本是相对递减的,容量也是相对递增的。
所以,在有了多级缓存后,程序的运行就变成了:
当CPU要读取一个数据的时候,先从一级缓存中查找,如果没找到再从二级缓存中查找,如果没找到再从三级缓存中查找,如果没找到再从主内存中查找,然后再把找到的数据依次加载到多级缓存中,下次再使用相关的数据直接从缓存中查找即可。
而加载到缓存中的数据也不是说用到哪个就加载哪个,而是加载内存中连续的数据,一般来说是加载连续的64个字节,因此,如果访问一个 long 类型的数组时,当数组中的一个值被加载到缓存中时,另外 7 个元素也会被加载到缓存中,这就是“缓存行”的概念。
缓存行虽然能极大地提高程序运行的效率,但是在多线程对共享变量的访问过程中又带来了新的问题,也就是非常著名的“伪共享”。
关于伪共享的问题,我们这里就不展开讲了,有兴趣的可以看彤哥之前发布的【杂谈 什么是伪共享(false sharing)?】章节的相关内容。
除此之外,为了使CPU中的运算单元能够充分地被利用,CPU可能会对输入的代码进行乱序执行优化,然后在计算之后再将乱序执行的结果进行重组,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句计算的先后顺序与代码的输入顺序一致,因此,如果一个计算任务依赖于另一个计算任务的结果,那么其顺序性并不能靠代码的先后顺序来保证。
与CPU的乱序执行优化类似,java虚拟机的即时编译器也有类似的指令重排序优化。
为了解决上面提到的多个缓存读写一致性以及乱序排序优化的问题,这就有了内存模型,它定义了共享内存系统中多线程读写操作行为的规范。
Java内存模型
Java内存模型(Java Memory Model,JMM)是在硬件内存模型基础上更高层的抽象,它屏蔽了各种硬件和操作系统对内存访问的差异性,从而实现让Java程序在各种平台下都能达到一致的并发效果。
Java内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。这里所说的变量包括实例字段、静态字段,但不包括局部变量和方法参数,因为它们是线程私有的,它们不会被共享,自然不存在竞争问题。
为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码的执行顺序等这类权利。
Java内存模型规定了所有的变量都存储在主内存中,这里的主内存跟介绍硬件时所用的名字一样,两者可以类比,但此处仅指虚拟机中内存的一部分。
除了主内存,每条线程还有自己的工作内存,此处可与CPU的高速缓存进行类比。工作内存中保存着该线程使用到的变量的主内存副本的拷贝,线程对变量的操作都必须在工作内存中进行,包括读取和赋值等,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成。
线程、工作内存、主内存三者的关系如下图所示:
注意,这里所说的主内存、工作内存跟Java虚拟机内存区域划分中的堆、栈是不同层次的内存划分,如果两者一定要勉强对应起来,主内存主要对应于堆中对象的实例部分,而工作内存主要对应与虚拟机栈中的部分区域。
从更低层次来说,主内存主要对应于硬件内存部分,工作内存主要对应于CPU的高速缓存和寄存器部分,但也不是绝对的,主内存也可能存在于高速缓存和寄存器中,工作内存也可能存在于硬件内存中。
内存间的交互操作
关于主内存与工作内存之间具体的交互协议,Java内存模型定义了以下8种具体的操作来完成:
(1)lock,锁定,作用于主内存的变量,它把主内存中的变量标识为一条线程独占状态;
(2)unlock,解锁,作用于主内存的变量,它把锁定的变量释放出来,释放出来的变量才可以被其它线程锁定;
(3)read,读取,作用于主内存的变量,它把一个变量从主内存传输到工作内存中,以便后续的load操作使用;
(4)load,载入,作用于工作内存的变量,它把read操作从主内存得到的变量放入工作内存的变量副本中;
(5)use,使用,作用于工作内存的变量,它把工作内存中的一个变量传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
(6)assign,赋值,作用于工作内存的变量,它把一个从执行引擎接收到的变量赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时使用这个操作;
(7)store,存储,作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便后续的write操作使用;
(8)write,写入,作用于主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存的变量中;
如果要把一个变量从主内存复制到工作内存,那就要按顺序地执行read和load操作,同样地,如果要把一个变量从工作内存同步回主内存,就要按顺序地执行store和write操作。注意,这里只说明了要按顺序,并没有说一定要连续,也就是说可以在read与load之间、store与write之间插入其它操作。比如,对主内存中的变量a和b的访问,可以按照以下顺序执行:
read a -> read b -> load b -> load a。
另外,Java内存模型还定义了执行上述8种操作的基本规则:
(1)不允许read和load、store和write操作之一单独出现,即不允许出现从主内存读取了而工作内存不接受,或者从工作内存回写了但主内存不接受的情况出现;
(2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存变化了必须把该变化同步回主内存;
(3)不允许一个线程无原因地(即未发生过assign操作)把一个变量从工作内存同步回主内存;
(4)一个新的变量必须在主内存中诞生,不允许工作内存中直接使用一个未被初始化(load或assign)过的变量,换句话说就是对一个变量的use和store操作之前必须执行过load和assign操作;
(5)一个变量同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。
(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;
(7)如果一个变量没有被lock操作锁定,则不允许对其执行unlock操作,也不允许unlock一个其它线程锁定的变量;
(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作;
注意,这里的lock和unlock是实现synchronized的基础,Java并没有把lock和unlock操作直接开放给用户使用,但是却提供了两个更高层次的指令来隐式地使用这两个操作,即moniterenter和moniterexit。
原子性、可见性、有序性
Java内存模型就是为了解决多线程环境下共享变量的一致性问题,那么一致性包含哪些内容呢?
一致性主要包含三大特性:原子性、可见性、有序性,下面我们就来看看Java内存模型是怎么实现这三大特性的。
(1)原子性
原子性是指一段操作一旦开始就会一直运行到底,中间不会被其它线程打断,这段操作可以是一个操作,也可以是多个操作。
由Java内存模型来直接保证的原子性操作包括read、load、user、assign、store、write这两个操作,我们可以大致认为基本类型变量的读写是具备原子性的。
如果应用需要一个更大范围的原子性,Java内存模型还提供了lock和unlock这两个操作来满足这种需求,尽管不能直接使用这两个操作,但我们可以使用它们更具体的实现synchronized来实现。
因此,synchronized块之间的操作也是原子性的。
(2)可见性
可见性是指当一个线程修改了共享变量的值,其它线程能立即感知到这种变化。
Java内存模型是通过在变更修改后同步回主内存,在变量读取前从主内存刷新变量值来实现的,它是依赖主内存的,无论是普通变量还是volatile变量都是如此。
普通变量与volatile变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新。因此我们可以说volatile变量保证了多线程环境下变量的可见性,但普通变量不能保证这一点。
除了volatile之外,还有两个关键字也可以保证可见性,它们是synchronized和final。
synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作”这条规则获取的。
final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了。
(3)有序性
Java程序中天然的有序性可以总结为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。
前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。
Java中提供了volatile和synchronized两个关键字来保证有序性。
volatile天然就具有有序性,因为其禁止重排序。
synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。
先行发生原则(Happens-Before)
如果Java内存模型的有序性都只依靠volatile和synchronized来完成,那么有一些操作就会变得很啰嗦,但是我们在编写Java并发代码时并没有感受到,这是因为Java语言天然定义了一个“先行发生”原则,这个原则非常重要,依靠这个原则我们可以很容易地判断在并发环境下两个操作是否可能存在竞争冲突问题。
先行发生,是指操作A先行发生于操作B,那么操作A产生的影响能够被操作B感知到,这种影响包括修改了共享内存中变量的值、发送了消息、调用了方法等。
下面我们看看Java内存模型定义的先行发生原则有哪些:
(1)程序次序原则
在一个线程内,按照程序书写的顺序执行,书写在前面的操作先行发生于书写在后面的操作,准确地讲是控制流顺序而不是代码顺序,因为要考虑分支、循环等情况。
(2)监视器锁定原则
一个unlock操作先行发生于后面对同一个锁的lock操作。
(3)volatile原则
对一个volatile变量的写操作先行发生于后面对该变量的读操作。
(4)线程启动原则
对线程的start()操作先行发生于线程内的任何操作。
(5)线程终止原则
线程中的所有操作先行发生于检测到线程终止,可以通过Thread.join()、Thread.isAlive()的返回值检测线程是否已经终止。
(6)线程中断原则
对线程的interrupt()的调用先行发生于线程的代码中检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。
(7)对象终结原则
一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始。
(8)传递性原则
如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
这里说的“先行发生”与“时间上的先发生”没有必然的关系。
比如,下面的代码:
int a = 0;
// 操作A:线程1对进行赋值操作
a = 1;
// 操作B:线程2获取a的值
int b = a;
如果线程1在时间顺序上先对a进行赋值,然后线程2再获取a的值,这能说明操作A先行发生于操作B吗?
显然不能,因为线程2可能读取的还是其工作内存中的值,或者说线程1并没有把a的值刷新回主内存呢,这时候线程2读取到的值可能还是0。
所以,“时间上的先发生”不一定“先行发生”。
再看一个例子:
// 同一个线程中
int i = 1;
int j = 2;
根据第一条程序次序原则,int i = 1;
先行发生于int j = 2;
,但是由于处理器优化,可能导致int j = 2;
先执行,但是这并不影响先行发生原则的正确性,因为我们在这个线程中并不会感知到这点。
所以,“先行发生”不一定“时间上先发生”。
总结
(1)硬件内存架构使得我们必须建立内存模型来保证多线程环境下对共享内存访问的正确性;
(2)Java内存模型定义了保证多线程环境下共享变量一致性的规则;
(3)Java内存模型提供了工作内存与主内存交互的8大操作:lock、unlock、read、load、use、assign、store、write;
(4)Java内存模型对原子性、可见性、有序性提供了一些实现;
(5)先行发生的8大原则:程序次序原则、监视器锁定原则、volatile原则、线程启动原则、线程终止原则、线程中断原则、对象终结原则、传递性原则;
(6)先行发生不等于时间上的先发生;
彩蛋
Java内存模型是Java中很重要的概念,理解它非常有助于我们编写多线程代码,理解多线程的本质,笔者这里整理了一些不错的资料提供给大家。
《深入理解Java虚拟机》
《Java并发编程的艺术》
《深入理解java内存模型》
关注我的公众号“彤哥读源码”回复“JMM”领取上面三本书籍。
欢迎关注我的公众号“彤哥读源码”,查看更多源码系列文章,与彤哥一起畅游源码的海洋。
以上是关于java内存模型的JMM简介的主要内容,如果未能解决你的问题,请参考以下文章