今天看《Java多线程实战指南-设计模式篇》的时候发现里面提到一个 Java
内存模型(JMM,Java Memory Model
)术语,对这个概念有些模糊了,就在网上查找资料。
找到一篇比较好的文章
发现已经有好几篇中文翻译了,不过还是想自己动手试一试
主要内容:
- 引言
Java
内存模型(The Internal Java Memory Model
)- 硬件存储器体系结构(
Hardware Memory Architecture
) - 解决
Java
内存模型与硬件内存结构之间的差异
引言
Java
内存模型指定了 Java
虚拟机(JVM,Java Virtual Machine
)如何与计算机内存(RAM
)一起工作。Java
虚拟机是一个计算机模型(a model of a whole computer
),所以它包含了一个内存模型,又名 Java
内存模型。
想要设计好的并发程序(design correctly behaving concurrent programs
)必须了解 Java
内存模型,它指定了线程如何查看和写入共享变量,以及如何同步访问共享变量(how and when different threads can see values written to shared variabels by other threads, and how to synchronize access to shared variables when necessary
)
原始的 Java
内存模型存在不足之处,在 Java1.5
版本进行过修改,新版本的 Java
内存模型在 Java8
同样适用
Java
内存模型
Java
内存模型
在 JVM
中,Java
内存模型将内存分为线程栈(Thread Stack
)和堆(Heap
)。其逻辑图如下
JVM
中每个线程均拥有各自的线程栈,线程栈中保存有该线程执行到指定位置所调用的方法,称之为调用栈(call stack
)
线程栈中还保存了调用栈方法的局部变量(local variable
),由于线程仅能访问自己的线程栈,所以其它线程无法访问当前线程所创建的局部变量。即使两个线程正在执行同一段代码,它们会在各自的线程栈中创建自己的局部变量
原始类型(primitive type
,包括 boolean,byte,short,char,int,long,float,double
)的局部变量完全保存在线程栈中,无法被其它线程所访问,当前线程只可以将该局部变量的值复制给其它线程。
堆保存了 Java
应用程序中创建的所有对象,无论是哪个线程创建的对象,均放置在堆中。比如原始类型的包装类(Byte,Integer,Long
等);或者是在方法中创建的局部对象;以及对象中包含的其它对象。
下图展示了线程栈中保存了调用栈以及局部变量,堆中保存了对象
如果局部变量是原始类型,那么它保存在线程栈中;如果局部变量是一个对象引用(a reference to an object
),那么该引用保存在线程栈中,对象保存在堆中。
对象的成员变量(member variable
)和对象一样保存在堆中,不论是原始类型,还是对象的引用(That is true both when the member variable is of a primitive type, and if it is a reference to an object
)。
静态类变量和类定义一起存储在堆中(Static class variables are also stored on the heap along with the class definition
)
只要线程拥有对象的引用,它可以访问堆中的所有对象,那么它也可以访问对象的成员变量。如果两个线程在同一时间(at the same time
)调用了同一对象(on the same object
)的方法,它们都可以访问对象的成员变量,但是每个线程拥有各自独立的局部变量。
上述说明如下所示
在上图中,两个线程均拥有一组变量。其中一个变量(Local variable 2
)指向了堆中的一个共享对象(a shared object
)Object3
。这个引用是一个局部变量,所以保存在线程各自的线程栈中
共享对象 Object3
有两个成员变量是对象 - Object2
和 Object4
,所以线程也可以通过 Object3
访问 Object2
和 Obejct4
两个线程栈中的一个局部变量(Local variable 3
)也引用了一个对象,不过是独立的,分别引用了堆中的 Object1
和 Object5
其实现代码示例如下:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member1 = 67890;
}
当两个线程执行 run
方法后,JVM
内存执行如上图所示。run
方法调用了 methodOne
,其后 methodOne
调用了 methodTwo
。
methodOne
定义了一个原始类型局部变量 localVariable1
和一个引用对象的局部变量 localVariable2
每个线程执行 methodOne
后都将在线程栈中创建局部变量 localVariable1
和 localVariable2
,其中,localVariable2
均指向堆中的同一个对象。上面代码中,localVariable2
指向的是一个静态变量引用的对象。静态变量保存在堆中,仅创建一个,所以两个变量 localVariable2
指向的是同一个类 MySharedObject
实例。实例 MySharedObject
也存储在堆中,对应上图中的 Object3
类 MySharedObject
包含了两个成员变量,它们和对象一起保存在堆中,这两个成员变量指向了两个 Integer
类型对象,它们对应上图中的 Object2
和 Object4
每次执行 methodTwo
时,都会创建一个局部变量 localVariable1
,以及创建一个类实例 Integer
,所以局部变量 localVariable1
保存在线程栈中,类实例 Integer
保存在堆中,且每个线程中 localVariable1
引用的类对象相互独立,分别对应上图中的 Object1
和 Object5
上图中类 MySharedObject
还拥有了两个原始类型成员变量 member1
和 member2
,它们和类对象一起保存在堆中
硬件存储器体系结构
现代硬件存储器体系结构(Modern Hardware Memory Archiecture
)和 Java
内存模型有些不同,本小节先描述一个通用的硬件存储器体系结构,然后下一小节讨论 Java
内存模型如何适配硬件存储器体系结构。
下面是一个现代硬件存储器体系结构的简化图
现在计算机都有 2
个或者更多的 CPU
,同时每个 CPU
也可能有多个核心。单个 CPU
在一段时间内可以运行单个线程,那么多个 CPU
的出现让多线程同时运行成为了可能
每个 CPU
都有一组寄存器,大多数情况下还拥有缓存,所有 CPU
共享一个主内存
这 3
种存储介质的大小如下:
主内存 > 缓存 > 寄存器
CPU
访问这 3
种存储介质的速度如下:
寄存器 > 缓存 > 主内存
通常情况下,CPU
需要读取主内存数据时,它会将部分主内存数据读取到缓存,甚至将部分缓存数据读取到寄存器,再进行操作;而当 CPU
将数据写入主内存时,首先它将寄存器数据刷新到缓存,然后在某一时刻将缓存数据写入主内存
缓存数据写入主内存的时机通常是在 CPU
需要将数据写入缓存时,缓存会将部分数据写入主内存,然后接收 CPU
写入的数据,不会一次性将所有缓存数据写入主内存中。通常情况下,每次缓存操作(读写)都以一个很小的内存块为单位,称之为 cache lines
。缓存读时,会将一个或多个 cache lines
写入主内存;同样的,缓存写时,CPU
会将一个或多个 cache lines
写入缓存
额外问题1:为什么存储介质区分为寄存器,缓存和内存
总的结论:速度和价格的平衡。现代计算机存储结构
CPU 寄存器 - 缓存 - 内部存储器(内存)- 外部存储器(硬盘)
就寄存器,缓存和内存而言,寄存器的读写速度最快,内存的读写速度最慢,缓存介于两者之间
有几个原因:
- 介质不同。缓存使用
SRAM
,内存使用DRAM
; - 距离不同。距离
CPU
越近,读取时间越短,寄存器就在CPU
里面,缓存次之,内存最远; - 设计方式不同。寄存器小,可以设计成高耗电和高成本,而内存不行;
- 工作方式不同。寄存器的读取方式相对简单;内存读取方法复杂。
参考:
请问CPU,内核,寄存器,缓存,RAM,ROM的作用和他们之间的联系?
额外问题2:为什么缓存层中有多级缓存
通常情况下,查询数据时会在同一片存储区域
程序运行情况表明,程序产生的地址往往集中在存储器逻辑地址空间的很小范围内
而指令通常又是顺序执行,所以分布连续,再加上循环程序和子程序段都要运行多次
数据也是如此,这种现象称为“程序访问的局部性Locality of Reference”
所以设计缓存层,存储容量小于内存,但是可以将常用的数据存放在里面,并且由于缓存容量小,可以设计成更昂贵,更耗电,以加快查询速度
而且 L1 Cache
的大小受限于硅的局限性以及高时钟频率的要求,所以设计 L2 Cache
,来弥补 L1 Cache
缺失的数据
参考:
为什么在 CPU 中要用 Cache 从内存中快速提取数据?
解决 Java
内存模型与硬件内存结构之间的差异
Java
内存模型与硬件内存结构之间的差异
上两节提到的 Java
内存模型和硬件内存结构存在差异性,同时硬件内存结构无法区分线程栈和堆
在硬件内存结构中,线程堆和栈可能出现在寄存器,缓存和主内存中。如下图所示
当对象和变量可能存储在硬件存储器的不同位置时,会出现一些问题。主要有以下两个方面:
- 线程写入共享变量时的可见性(
Visibility of thread updates (writes) to shared variables
) - 读取,检测和写入共享变量时的竟态条件(
Race conditions when reading, checking and writing shared variables
)
下面解决这两个问题
共享对象的可见性(Visibility of Shared Objects
)
两个线程共享同一个对象
- 起初,该对象保存在主内存中;
- 其中一个线程进入运行状态,读取该对象到
CPU
缓存,并进行相关操作; - 操作完成后,该
CPU
并没有将缓存中的对象刷新到主内存,此时,修改后的对象对于在其它CPU
运行的线程来说不可见; - 另一个
CPU
上的线程读取主内存中的对象到缓存,此时,主内存中的对象状态落后于先前缓存中的对象状态。
下图演示了这种情况:
- 运行在左边
CPU
的线程将主内存中的共享共享对象写入该CPU
缓存,并改变其变量count
为2
,但是该对象变化没有及时刷新到主内存; - 当运行在右边
CPU
的线程从主内存读取共享对象到CPU
缓存时,其count
变量值仍为1
。
为解决这一问题,可用 volatile
关键字修改该变量。它确保了每次读取和写入变量操作都是从主内存进行,保证了数据的可见性
竟态条件(Race Conditions
)
当多个线程同时对一个共享变量进行更新时,将会发生竟态条件
假如线程 A
读取了共享对象的变量 count
到 CPU
缓存,同时线程 B
也读取了变量 count
,此时两个线程在不同的 CPU
中运行
然后两个线程均对变量 count
执行 +1
操作,如果这两个对象依次将结果刷新会主内存,此时 count
值比原先大 1
,但是正确结果应该是大 2
,因为执行了两次 +1
操作
下图证明了这种情况
为解决竟态条件,可以使用同步块 (synchronized block
),同步块保证了每次仅有一个线程执行临界区域的代码
同时,同步块中的变量均从主内存进行读取,当前线程运行完同步块代码后,将变量数据重新刷新到主内存,不论该变量是否有 volatile
关键字定义