在未正确使用锁的时候,多线程的程序可能变的很容易出错,并且难以排查。而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操作从工作内存得到的值放入主内存的变量中。
执行上述8种基本操作的规则:
- 不允许read和load,store和write操作之一单独出现。
- 不允许一个线程丢弃它最近的assign操作。即变量在工作内存中改变了账号必须把变化同步回主内存
- 一个新的变量只允许在主内存中诞生,不允许工作内存直接使用未初始化的变量。
- 一个变量同一时刻只允许一条线程进行lock操作,但同一线程可以lock多次,lock多次之后必须执行同样次数的unlock操作
- 如果对一个变量进行lock操作,那么将会清空工作内存中此变量的值。
- 不允许对未lock的变量进行unlock操作,也不允许unlock一个被其它线程lock的变量
- 如果一个变量执行unlock操作,必须先把次变了同步回主内存中。
这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栈允许的最大容量,抛出*Error异常
- 如果JVM栈可以动态扩展,扩展的动作也已经尝试过,但是没有申请到足够的内存,则抛出OutofMemoryError异常
3. Heap(堆)
堆是可以可供各个线程共享的运行时存储区域,也是供所有类的实例和数组对象分配内存的区域。堆在JVM启动的时候创建。
堆所存储的就是被GC所管理的各种对象。
堆也是可以固定大小和动态调整的:
实际所需的堆超过的GC所提供的最大容量,那么JVM抛出OutofMemoryError异常。
4. Method Area(方法区)
也是各个线程共享的运行时内存区,它存储每一个类的实例信息,运行时常量池,字段和方法数据,构造函数和普通方法的字节码等内容。还有一些特殊方法。
方法区是堆的逻辑组成部分,也在JVM启动时创建,简单的JVM可以不实现这个区域的垃圾收集。
方法区也可固定大小和动态分配与堆一样,内存空间不够,那么JVM抛出OutofMemoryError异常。
5. Run-Time Constant Pool(运行时常量池)
在方法区中分配,在加载类和接口到虚拟机之后,就创建对应的运行时常量池。
它是class文件中每一个类或接口的常量池表的运行时表现形式。像字符串。Java的主要类型。
存储区域不够用时候抛出OutofMemoryError异常。
6. Native Method Stacks(原生方法栈或本地方法栈)
JDK中native的方法,System类和Thread类中有很多。使用C语言编写的方法,这个也通常叫做C stack。
可以不支持本地方法栈,但是如果支持的时候,这个栈一般会在线程创建的时候按线程分配。
与栈的错误一样,*Error和OutOfMemeoryError.
一个案例
- 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
- 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
- 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
- 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
- 静态成员变量跟随着类定义一起也存放在堆上。
- 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。
最后
这次主要讲了一些规则相关的东西,及Java中运行时数据存储的位置,建议看一下《深入理解JVM》最后一章。最好下载JSR-133规范对照着看。
参考:
- Java内存模型
- 《深入理解Java虚拟机》
- 《Java高并发程序设计》
- 《JVM specification》