概述
多任务和高并发是衡量一台计算机处理器的能力重要指标之一。一般衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS
)这个指标比较能说明问题,它代表着一秒内服务器平均能响应的请求数,而TPS
值与程序的并发能力有着非常密切的关系。在讨论Java
内存模型和线程之前,先简单介绍一下硬件的效率与一致性。
硬件的效率与一致性
由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache
)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence
)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI
、MESI、MOSI
及Dragon Protocol
等。Java
虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的,后续将介绍Java
内存模型。
除此之外,为了使得处理器内部的运算单元能竟可能被充分利用,处理器可能会对输入代码进行乱起执行(Out-Of-Order Execution
)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java
虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder
)优化。
Java内存模型(JMM)
定义Java
内存模型并不是一件容易的事情,这个模型必须定义得足够严谨,才能让Java
的并发操作不会产生歧义。但是,也必须得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存等)来获取更好的执行速度。经过长时间的验证和修补,在JDK1.5
发布后,Java
内存模型就已经成熟和完善起来了。JMM
给我们一种规范,它描述了多线程程序如何与内存交互。
JMM大致描述:
JMM
描述了线程如何与内存进行交互。Java
虚拟机规范视图定义一种Java
内存模型,来屏蔽掉各种操作系统内存访问的差异,以实现Java
程序在各种平台下都能达到一致的访问效果。
JMM
描述了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
原则。
Happen-before原则
这个是Java
内存模型下无需任何同步器协助就已经存在,可以直接在编码中使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们的顺序就没有保障,虚拟机可以对他们进行任意的重排。
天然的happen-before:
- 程序顺序原则:一个线程内包装语义的串行性
volatile
变量的写,先发生于读,这保证了volatile变量的可见性- 锁规则:
unlock
先与lock
- 传递性:
A
先于B
,B
先于C
,那么A
必然先于C
- 线程的
start
先于线程的每一个动作- 线程的所有操作优先于线程的终结(
Thread.join()
)- 线程的中断(
interupt
)先于被中断线程的代码- 对象的构造函数执行,先于
finalize()
方法
Java运行时数据区
JVM
定义了一些程序运行时会使用到的运行时数据区,其中一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些是与现场一一对应的,这些线程对应的数据区会随着线程的开始和结束而创建和销毁。
这部分参考JVM规范
1. PC寄存器
可以支持多条线程同时允许,每一条Java虚拟机线程都有自己的PC
寄存器。任意时刻,一条JVM
线程之后执行一个方法的代码,这个方法被称为当前方法(current method
)。
如果这个方法不是Native
的,那么PC
寄存器就保存JVM
正在执行的字节码指令地址。
如果是Native
的,那么PC
寄存器的值为undefined
。
PC
寄存器的容量至少能保证一个returnAddress
类型的数据或者一个平台无关的本地指针的值。
2. JVM Stack(虚拟机栈)
每一个JVM
线程都有自己的私有虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Frame
)。
栈用来存储局部变量与一些过程结果的地方。在方法调用和返回中也扮演了很重要的角色。
栈可以试固定分配的也可以动态调整。
如果请求线程分配的容量超过JVM栈允许的最大容量,抛出StackOverflowError
异常。
如果JVM
栈可以动态扩展,扩展的动作也已经尝试过,但是没有申请到足够的内存,则抛出OutOfMemoryError
异常。
3. Heap(堆)
堆是可以可供各个线程共享的运行时存储区域,也是供所有类的实例和数组对象分配内存的区域。堆在JVM
启动的时候创建。
堆所存储的就是被GC
所管理的各种对象。
堆也是可以固定大小和动态调整的。
实际所需的堆超过的GC
所提供的最大容量,那么JVM
抛出OutOfMemoryError
异常。
4. Method Area(方法区)
也是各个线程共享的运行时内存区,它存储每一个类的实例信息,运行时常量池,字段和方法数据,构造函数和普通方法的字节码等内容。还有一些特殊方法。
方法区是堆的逻辑组成部分,也在JVM
启动时创建,简单的JVM可以不实现这个区域的垃圾收集。
方法区也可固定大小和动态分配与堆一样,内存空间不够,那么JVM
抛出OutOfMemoryError
异常。
5. Run-Time Constant Pool(运行时常量池)
在方法区中分配,在加载类和接口到虚拟机之后,就创建对应的运行时常量池。
它是class
文件中每一个类或接口的常量池表的运行时表现形式。
存储区域不够用时候抛出OutOfMemoryError
异常。
6. Native Method Stacks(原生方法栈或本地方法栈)
JDK
中Native
的方法,System
类和Thread
类中有很多。使用C
语言编写的方法,这个也通常叫做C stack
。
可以不支持本地方法栈,但是如果支持的时候,这个栈一般会在线程创建的时候按线程分配。
与栈的错误一样,StackOverFlowError
和OutOfMemeoryError
。
指令重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:
1、编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java
源代码到最终实际执行的指令序列,会经过下面三种重排序:
为了保证内存的可见性,Java
编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java
内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad
和StoreStore
四种:
一个案例
- 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
- 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
- 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
- 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
- 静态成员变量跟随着类定义一起也存放在堆上。
- 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。
参考资料:
Java内存模型