JMM简介
Java内存模型的由来
在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当编写的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。比如在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问出错。但是Java语言最大的特征就是跨平台性,如果不能像C/C++一样不解决上面那个问题,就不能会存在“一次编写,到处运行”的特点。而这个时候,Java为了解决这个问题,就出现了Java内存模型(Java Memory Model,JMM)。JMM其实就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。
JMM内存划分
JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的共享数据,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。计算机系统中存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的;而每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
说明: 主内存就是计算机的物理内存,而工作内存可以理解为CPU和主存之间的一个高速缓存。
- 主内存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题
- 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
Java语言的设计者在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存。而JMM就是规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
注意: JMM中的主内存、工作内存与JVM中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看主内存应该包括了堆和方法区,而工作内存则应该包括程序计数器、虚拟机栈以及本地方法栈。
注意: Java内存模型规定了所有的变量都存储在主内存中,线程的工作内存中保存了被该线程使用的变量的副本(从主内存中拷贝的),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存的数据。线程间变量值的传递只能通过主内存完成。
JMM与硬件内存架构
就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。
多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说 Java 内存模型对内存的划分对硬件内存并没有任何影响,因为 JMM 只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或者寄存器中,因此总体上来说,Java 内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
注意: JVM的内存划分其实也是同样的道理,都是Java语言层面的抽象概念。
JMM内存交互规则
JMM制定了一套标准来保证开发者在编写多线程程序时,能够控制什么时候内存会被同步给其他线程。JMM中规定内存交互操作有8种原子操作,每种操作都有自己作用的的区域,具体操作如下:
- lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
- use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
- assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
- store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
- write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM中的8中操作规定了线程对主内存的操作过程,隐式的规定:线程之间要通信必须通过主内存,JMM的线程通信如下:
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本的内存A中更新过的共享变量刷新到主内存中去;
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,很明显这样一定会出现错误。因此,JMM还规定了在执行上述八种基本操作时,必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作
- 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
JMM三大特性
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等解决原子性、有序性和可见性三大问题。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。而Java并发其实就是围绕着原子性、可见性、有序性来进行的。
原子性
我们把一个或者多个操作在CPU执行的过程中不能被中断的特性称之为原子性,这里所说的原子性主要指CPU指令级别的原子性。
一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。我们看下面的代码:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
在上面的四行代码中,其实只有语句1是确保原子性的。
语句2中,需要进行下面的操作:
- 先要去读取x的值【read(读取)-load(加载)】
- 再将x的值写入工作内存【store(存储)-write(写入)】
虽然“读取x的值”和“将x的值写入工作内存”都是原子操作,但是合起来就不再是原子操作。又比如语句3,由下面的3个原子操作组成:
- 读取变量x的值【read(读取)-load(加载)】
- 进行加一操作【use(使用)-assign(赋值)】
- 将新的值赋值给变量x【store(存储)-write(写入)】
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
注意: volatile关键字不能保证原子性。
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。我们举个例子:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
JMM是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。在Java中保证变量的可见性主要有两种方式:
- 使用volatile关键字修饰变量,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值;而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
- 通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。
比如下面这段程序:
public class Test {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
// 线程t不会如预想的停下来
run = false;
}
}
正常来看,main线程中“run=false”时,t线程就应该停止运行了,但是运行结果确实t线程永远无法停止:
而其原因就是main线程修改了共享变量run,但是仅仅只是修改的在main线程的工作内存的副本,还没有刷新到主内存中,因此t线程一直读取到的是run的旧值。为了解决这个问题,我们只需要给run这个共享变量加上volatile这个关键字即可,或者对run操作上时进行上锁。
有序性
有序性指的是程序并不是按照代码的先后顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序。而这里的改变先后执行顺序主要指的就是指令重排序:“编译器和处理器为了提高程序运行效率而对指令序列进行重新排序的手段,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的”。在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,但是却会影响到多线程并发执行的正确性。举个例子:
boolean inited = false;
if (inited == false) {
context = loadContext(); //语句1
inited = true; //语句2
}
doSomethingwithconfig(context); //语句3
由于语句1和语句2没有依赖性,语句1和语句2可能 并行执行 或者 语句2先于语句1执行,如果这段代码2个线程同时执行,线程1执行了语句2,而语句1还没有执行完,这个时候线程2判断inited为true,则执行语句3,但由于context没有初始化完成,则会导致出现未知的异常。
在Java中,通常也有两种方式解决指令重排序导致的有序性问题:
- 使用volatile关键字:volatile关键字可以禁止指令重排序,因为被volatile修饰的变量会存在一个读写屏障(详见volatile关键字)。
- 使用锁:synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
至于为什么指令重排序可以提高CPU效率,可以看下面这个形象生动的例子:
现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。每条指令都可以分为:“取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回”这5个阶段。
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。
注意: 指令重排可以提高效率的原因在于分工、分阶段,从而达到指令级并行。当前,指令重排的前提是重排指令不能影响结果。
小结
从上面的三大特性的介绍可知,synchronized和Lock可以保证原子性、可见性和有序性,而volatile只能保证可见性和有序性。虽然synchronized和Lock完全的解决了并发的三大问题,但是相比于volatile关键字,性能差了很多,虽然编译器提供了很多锁优化技术,但是如果能用volatile解决并发问题的就用volatile关键字,不要滥用锁,否则会导致系统性能的下降。
参考文章: