Java内存模型(二)
三、Java内存模型的抽象
主内存和工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量(指实例字段、静态字段和构成数组对象的元素,但是不包含局部变量与方法参数,因为局部变量与方法参数是线程私有的,不会被共享)的底层细节。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面将的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示:
关于主内存与工作内存之间具体的交互协议,即如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java定义了8种操作来完成:
操作 | 说明 |
---|---|
lock(锁定) | 作用于主内存的变量,把一个变量标识为一条线程独占的状态 |
unclock(解锁) | 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read(读取) | 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存,以便随后的load动作使用 |
load(载入) | 作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中 |
use(使用) | 作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎 |
assign(赋值) | 作用于工作内存的变量,把执行引擎接收到的值赋给工作内存的变量 |
store(存储) | 作用于工作内存的变量,把工作内存中一个变量的值传送给主内存中,以便随后的write操作使用 |
write(写入) | 作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中 |
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read
和load
操作,如果要把变量从工作内存同步回主内存,那就要顺序地执行store
和write
操作,注意Java内存模型只要求上述两个操作必须顺序地执行,而没有保证是连续执行。
内存模型的抽象
在Java虚拟机中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量(Local variables)
,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通信由Java内存模型(JMM
)控制,**JMM
决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM
定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)
中,每个线程都有一个私有的本地内存(local memory)
,本地内存中存储了该线程以读/写共享变量的副本。**本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
如上图所示,本地内存A和B有主内存中共享变量x
的副本。假设初始时,这三个内存中的x
值都为0。线程A在执行时,把更新后的x
值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x
值,此时线程B的本地内存的x
值也变为了1。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
四、JVM对Java内存模型的实现
在JVM内部,Java内存模型把内存分成了两部分:线程栈区 和 堆区,下图展示了Java内存模型在JVM中的逻辑视图:
JVM
中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。
所有原始类型(boolean
、byte
、short
、char
、int
、long
、float
、double
)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,它们之间是不能共享的。
堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte
、Integer
、Long
等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:
一个本地变量如果是原始类型,那么它会被完全存储到栈区。 一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
static
类型的变量以及类本身相关信息都会随着类本身存储在堆区。
堆中的对象可以被多线程共享。如果一个线程获得一个对象的引用,它便可访问这个对象的成员变量。但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。
下图展示了上面描述的过程:
不管是什么内存模型,最终还是运行在计算机硬件上的,所以我们有必要了解计算机硬件内存架构,下图就简单描述了当代计算机硬件内存架构:
现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。
五、Java内存模型和硬件架构之间的桥接
正如上面讲到的,Java内存模型和硬件内存架构并不一致。从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:
共享对象对各个线程的可见性
当多个线程同时操作同一个共享对象时,如果没有合理的使用 volatile
或 synchronized
、Lock
等,一个线程对共享对象的更新有可能导致其它线程不可见。
想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。
如图:左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:
要解决共享对象可见性这个问题,我们可以使用 volatile
关键字。 volatile
关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。**volatile
原理是基于CPU内存屏障指令实现的**,后面会讲到。
共享对象的竞争现象
如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。
如下图所示,线程A和线程B共享一个对象obj
。假设线程A从主存读取obj.count
变量到自己的CPU缓存,同时,线程B也读取了obj.count
变量到它的CPU缓存,并且这两个线程都对obj.count
做了加1操作。此时obj.count
加1操作被执行了两次,不过都在不同的CPU缓存中。
如果这两个加1操作是串行执行的,那么obj.count
变量便会在原始值上加2,最终主存中的obj.count
的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的obj.count
只会增加1次变成2,尽管一共有两次加1操作。
参考文献
- 《深入理解Java虚拟机:JVM高级特性与最佳实践》,周志明著
- 深入理解java内存模型系列文章
- 全面理解Java内存模型