原文:多线程之Java内存模型(JMM)(一)html
多任务和高并发是衡量一台计算机处理器的能力重要指标之一。通常衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS
)这个指标比较能说明问题,它表明着一秒内服务器平均能响应的请求数,而TPS
值与程序的并发能力有着很是密切的关系。在讨论Java
内存模型和线程以前,先简单介绍一下硬件的效率与一致性。java
因为计算机的存储设备与处理器的运算能力之间有几个数量级的差距,因此现代计算机系统都不得不加入一层读写速度尽量接近处理器运算速度的高速缓存(cache
)来做为内存与处理器之间的缓冲:将运算须要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。数组
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,可是引入了一个新的问题:缓存一致性(Cache Coherence
)。在多处理器系统中,每一个处理器都有本身的高速缓存,而他们又共享同一主存,以下图所示:多个处理器运算任务都涉及同一块主存,须要一种协议能够保障数据的一致性,这类协议有MSI
、MESI、MOSI
及Dragon Protocol
等。Java
虚拟机内存模型中定义的内存访问操做与硬件的缓存访问操做是具备可比性的,后续将介绍Java
内存模型。
缓存
除此以外,为了使得处理器内部的运算单元能竟可能被充分利用,处理器可能会对输入代码进行乱起执行(Out-Of-Order Execution
)优化,处理器会在计算以后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化相似,Java
虚拟机的即时编译器中也有相似的指令重排序(Instruction Recorder
)优化。服务器
定义Java
内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java
的并发操做不会产生歧义。可是,也必须得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各类特性(寄存器、高速缓存等)来获取更好的执行速度。通过长时间的验证和修补,在JDK1.5
发布后,Java
内存模型就已经成熟和完善起来了。JMM
给咱们一种规范,它描述了多线程程序如何与内存交互。多线程
JMM大体描述:并发
JMM
描述了线程如何与内存进行交互。Java
虚拟机规范视图定义一种Java
内存模型,来屏蔽掉各类操做系统内存访问的差别,以实现Java
程序在各类平台下都能达到一致的访问效果。app
JMM
描述了JVM
如何与计算机的内存进行交互。jvm
JMM
都是围绕着原子性,有序性和可见性进行展开的。函数
JMM
的主要目标是定义程序中各个变量的访问规则,虚拟机将变量存储到内存和从内存取出变量这样的底层细节。此处的变量指在堆中存储的元素。
Java
内存模型规定全部的共享变量都存储在主内存中,而每条线程有本身的工做内存(本地内存),工做内存保存了共享变量的副本,而不一样内存又没法访问对方的工做内存,因此若是线程在工做内存中修改了变量副本,其它线程是无从得知的。
线程的传值均须要经过主内存来完成
Java
内存模型定义了8种操做来完成主内存与工做内存的交互细节,虚拟机必须保证这8种操做的每个操做都是原子的,不可再分的。
lock
: 做用于主内存的变量,把变量标识为线程独占的状态。
unlock
: 与lock
对应,把主内存中处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定。
read
: 做用于主内存的变量,把一个变量的值从主内存传输到线程的工做内存,便于随后的load
使用。
load
:做用于工做内存的变量,把read
读取到的变量放入工做内存副本。
use
:做用于工做内存,把工做内存的变量值传递给执行引擎,每当虚拟机遇到一个须要使用到变量的值的字节码指令时将会执行这个操做。
assign
: 做用于工做内存,把执行引擎收到的值赋给工做内存的变量,虚拟机遇到赋值字节码时候执行这个操做。
store
:做用于工做内存,把变量的值传输到住内存中,以便随后的write
使用。
write
:做用于主内存,把store
操做从工做内存获得的值放入主内存的变量中。
Java内存模型还规定了在执行上述八种基本操做时,必须知足以下规则:
- 不容许
read
和load
,store
和write
操做之一单独出现。- 不容许一个线程丢弃它最近的
assign
操做。即变量在工做内存中改变了帐号必须把变化同步回主内存。- 不容许一个线程无缘由地(没有发生过任何
assign
操做)把数据从工做内存同步回主内存中。- 一个新的变量只容许在主内存中诞生,不容许工做内存直接使用未初始化的变量。
- 一个变量同一时刻只容许一条线程进行
lock
操做,但同一线程能够lock
屡次,lock
屡次以后必须执行一样次数的unlock
操做。- 若是对一个变量执行
lock
操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行load
或assign
操做初始化变量的值。- 若是一个变量事先没有被
lock
操做锁定,则不容许对它执行unlock
操做;也不容许去unlock
一个被其余线程锁定的变量。- 对一个变量执行
unlock
操做以前,必须先把此变量同步到主内存中(执行store
和write
操做)。
这8种操做定义至关严谨,实践起来又比较麻烦,可是能够有助于咱们理解多线程的工做原理。有一个与此8种操做相等的Happen-before
原则。
这个是Java
内存模型下无需任何同步器协助就已经存在,能够直接在编码中使用。若是两个操做之间的关系不在此列,而且没法从下列规则推导出来的话,它们的顺序就没有保障,虚拟机能够对他们进行任意的重排。
自然的happens-before:
程序次序规则:一个线程内,按照代码顺序,书写在前面的操做先行发生于书写在后面的操做;
锁定规则:一个unlock
操做先行发生于后面对同一个锁的lock
操做;
volatile变量规则:对一个变量的写操做先行发生于后面对这个变量的读操做;
传递规则:若是操做A
先行发生于操做B
,而操做B
又先行发生于操做C
,则能够得出操做A
先行发生于操做C
;
线程启动规则:Thread
对象的start()
方法先行发生于此线程的每个动做;
线程中断规则:对线程interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中全部的操做都先行发生于线程的终止检测,咱们能够经过Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()
方法的开始。
这8条原则摘自《深刻理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面咱们来解释一下前4条规则:
对于程序次序规则来讲,个人理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操做先行发生于书写在后面的操做”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,由于虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,可是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。所以,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但没法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说不管在单线程中仍是多线程中,同一个锁若是出于被锁定的状态,那么必须先对锁进行了释放操做,后面才能继续进行lock操做。
第三条规则是一条比较重要的规则。直观地解释就是,若是一个线程先去写一个变量,而后一个线程去进行读取,那么写入操做确定会先行发生于读操做。
第四条规则实际上就是体现happens-before原则具有传递性。
JVM
定义了一些程序运行时会使用到的运行时数据区,其中一些会随着虚拟机启动而建立,随着虚拟机退出而销毁。另一些是与现场一一对应的,这些线程对应的数据区会随着线程的开始和结束而建立和销毁。
这部分参考JVM规范
能够支持多条线程同时容许,每一条Java虚拟机线程都有本身的PC
寄存器。任意时刻,一条JVM
线程以后执行一个方法的代码,这个方法被称为当前方法(current method
)。
若是这个方法不是Native
的,那么PC
寄存器就保存JVM
正在执行的字节码指令地址。
若是是Native
的,那么PC
寄存器的值为undefined
。
PC
寄存器的容量至少能保证一个returnAddress
类型的数据或者一个平台无关的本地指针的值。
每个JVM
线程都有本身的私有虚拟机栈,这个栈与线程同时建立,用于存储栈帧(Frame
)。
栈用来存储局部变量与一些过程结果的地方。在方法调用和返回中也扮演了很重要的角色。
栈能够试固定分配的也能够动态调整。
若是请求线程分配的容量超过JVM栈容许的最大容量,抛出StackOverflowError
异常。
若是JVM
栈能够动态扩展,扩展的动做也已经尝试过,可是没有申请到足够的内存,则抛出OutOfMemoryError
异常。
堆是能够可供各个线程共享的运行时存储区域,也是供全部类的实例和数组对象分配内存的区域。堆在JVM
启动的时候建立。
堆所存储的就是被GC
所管理的各类对象。
堆也是能够固定大小和动态调整的。
实际所需的堆超过的GC
所提供的最大容量,那么JVM
抛出OutOfMemoryError
异常。
也是各个线程共享的运行时内存区,它存储每个类的实例信息,运行时常量池,字段和方法数据,构造函数和普通方法的字节码等内容。还有一些特殊方法。
方法区是堆的逻辑组成部分,也在JVM
启动时建立,简单的JVM能够不实现这个区域的垃圾收集。
方法区也可固定大小和动态分配与堆同样,内存空间不够,那么JVM
抛出OutOfMemoryError
异常。
在方法区中分配,在加载类和接口到虚拟机以后,就建立对应的运行时常量池。
它是class
文件中每个类或接口的常量池表的运行时表现形式。
存储区域不够用时候抛出OutOfMemoryError
异常。
JDK
中Native
的方法,System
类和Thread
类中有不少。使用C
语言编写的方法,这个也一般叫作C stack
。
能够不支持本地方法栈,可是若是支持的时候,这个栈通常会在线程建立的时候按线程分配。
与栈的错误同样,StackOverFlowError
和OutOfMemeoryError
。
注意,具体 JVM 的内存结构,其实取决于其实现,不一样厂商的JVM,或者同一厂商发布的不一样版本,都有可能存在必定差别。
画了一个简单的内存结构图,里面展现了前面提到的堆、线程栈等区域,并从数量上说明了什么是线程私有,例如,程序计数器、Java 栈等,以及什么是 Java 进程惟一。另外,还额外划分出了直接内存等区域。
里简要介绍两点区别:
直接内存(Direct Memory)区域,它就是 Direct Buffer 所直接分配的内存,也是个容易出现问题的地方。尽管,在 JVM 工程师的眼中,并不认为它是JVM 内部内存的一部分,也并未体现 JVM 内存模型中。
JVM 自己是个本地程序,还须要其余的内存去完成各类基本任务,好比,JIT Compiler 在运行时对热点方法进行编译,就会将编译后的方法储存在 Code Cache 里面;GC 等功能须要运行在本地线程之中,相似部分都须要占用内存空间。这些是实现 JVM JIT 等功能的须要,但规范中并不涉及。
若是深刻到 JVM 的实现细节,你会发现一些结论彷佛有些模棱两可。好比:
Java 对象是否是都建立在堆上的呢?
我注意到有一些观点,认为经过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,可是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么作,这一点在逃逸分析相关的文档里已经说明,因此能够明确全部的对象实例都是建立在堆上。
目前不少书籍仍是基于 JDK 7 之前的版本,JDK 已经发生了很大变化,Intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。可是,Intern 字符串缓存和静态变量并非被转移到元数据区,而是直接在堆上分配,因此这一点一样符合前面一点的结论:对象实例都是分配在堆上。
接下来,咱们来看看什么是 OOM 问题,它可能在哪些内存区域发生?
首先,OOM 若是通俗点儿说,就是 JVM 内存不够用了,javadoc 中对OutOfMemoryError的解释是,没有空闲内存,而且垃圾收集器也没法提供更多内存。
这里面隐含着一层意思是,在抛出 OutOfMemoryError 以前,一般垃圾收集器会被触发,尽其所能去清理出空间,例如:
JVM 会去尝试回收软引用指向的对象等。
在java.nio.BIts.reserveMemory() 方法中,咱们能清楚的看到,System.gc() 会被调用,以清理空间,这也是为何在大量使用 NIO 的 Direct Buffer 之类时,一般建议不要加下面的
参数,毕竟是个最后的尝试,有可能避免必定的内存不足问题。
-XX:+DisableExplictGC
固然,也不是在任何状况下垃圾收集器都会被触发的,好比,咱们去分配一个超大对象,相似一个超大数组超过堆的最大值,JVM 能够判断出垃圾收集并不能解决这个问题,因此直接抛出OutOfMemoryError。
从前面分析的数据区的角度,除了程序计数器,其余区域都有可能会由于可能的空间不足发生OutOfMemoryError,简单总结以下:
堆内存不足是最多见的 OOM 缘由之一,抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”,缘由可能千奇百怪,例如,可能存在内存泄漏问题;也颇有可能就是堆的大小不合理,好比咱们要处理比较可观的数据量,可是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,致使堆积起来,内存没法释放等。
而对于 Java 虚拟机栈和本地方法栈,这里要稍微复杂一点。若是咱们写一段程序不断的进行递归调用,并且没有退出条件,就会致使不断地进行压栈。相似这种状况,JVM 实际会抛出StackOverFlowError;固然,若是 JVM 试图去扩展栈空间的的时候失败,则会抛出OutOfMemoryError。
对于老版本的 Oracle JDK,由于永久代的大小是有限的,而且 JVM 对永久代垃圾回收(如,常量池回收、卸载再也不须要的类型)很是不积极,因此当咱们不断添加新类型的时候,永久代出现 OutOfMemoryError 也很是多见,尤为是在运行时存在大量动态类型生成的场合;相似 Intern 字符串缓存占用太多空间,也会致使 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space”。
随着元数据区的引入,方法区内存已经再也不那么窘迫,因此相应的 OOM 有所改观,出现OOM,异常信息则变成了:“java.lang.OutOfMemoryError: Metaspace”。
直接内存不足,也会致使 OOM。
在执行程序时为了提升性能,编译器和处理器常常会对指令进行重排序。重排序分红三种类型:
一、编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,能够从新安排语句的执行顺序。
二、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序。
三、内存系统的重排序。因为处理器使用缓存和读写缓冲区,这使得加载和存储操做看上去多是在乱序执行。
从Java
源代码到最终实际执行的指令序列,会通过下面三种重排序:
为了保证内存的可见性,Java
编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java
内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad
和StoreStore
四种: