JVM 内存模型(JMM)
一、前言
昨天我们一起看过了内存结构的相关知识,同时,我又提到了很多小伙伴会将其与内存结构的概念搞混,今天,我们就来看看**内存模型(java memory model)**的相关概念,让各位对内存结构与内存模型的区别有一个更感性的认识
这里我们要首先明确它两的概念,内存结构是 JVM 中的概念,其主要是解决对象的分配问题,而内存模型是在内存结构上的进一步抽象,其作用是为了给解决并发安全问题提供一个简便的模型;后面为了方便,内存模型我都简称为 JMM
在讲解 JMM 之前,我们需要大致了解一下 硬件的内存结构
CPU缓存是为了平衡寄存器和主存读取速度而设计的,现代计算机缓存可能分为 1级、2级、3级缓存,这里我们统一将其抽象为一个缓存
通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器
JMM 我们一般直接抽象成下图表示:
可以看到,我们将 CPU 寄存器,还是不管多少级缓存,都抽象为本地变量
通过这种方法化简,可以让我们程序员更便捷的考虑并发问题,而如何化简这种脏活累活,都是由 JVM 帮我们完成的
对于我们的 JMM,其线程栈和堆中的数据,可能存储与硬件内存结构的任意位置:
正因如此,在并发情况下,就会出现如下两个问题:
- 可见性问题
- 竞态条件
这两个问题看起来高大上,但是看过下面的解释,你一定会恍然大悟的
**注意:**从这篇文章开始,我不会再将内容全部以问答的形式进行展现,而会在整理之后,在后面添加一道面试问答
二、内存模型问题总结
1、可见性问题和竞态条件
可见性问题:
假设有这么一个场景,两个线程要对共享数据区(这里就是物理内存)中的一个参数 count 进行 +1操作,此时,两个CPU 线程将共享数据区的数据各自读入自己的 CPU 缓存中,这种情况下,修改之后,只要不从 CPU 缓存中将修改后的数据刷新会主存,那么,当前线程的修改,对其他线程就是不可见的(其他线程不知道这个线程修改了数据),这就类似于数据库中的脏读
要解决此问题,可以使用Java的 volatile 关键字。 volatile关键字可以确保:
**1、**直接从主存中读取变量
2、在更新后,始终会写回主存(不使用 volatile的话,cpu会过一会儿才将缓存中修改的数据写入内存,这过一会儿就酿成了大祸)
竞态条件:
当两个线程想去修改主存中的数据的时候,(我们假定是想对主存中的变量 +1),不同 CPU 的线程,将主存中的 count 值,读入自己的 CPU 缓存中,并在各自的 CPU 缓存中进行了 +1 操作,此时两个线程总共对 count 变量进行了两次+1操作,但是当两个 CPU 缓存将自己的数据写入主存后,主存中的变量,显示的是只加了一次1
要解决这个问题,可以使用
synchronized
,这样可以保证:
**1、**任何时间点,只有一个线程可以访问临界区资源
**2、**保证所有变量都是从主存读取的
**3、**当退出同步代码块的时候,所有更新的变量会再次刷新回主存
2、处理器内存模型 和 JMM
提出处理器内存模型的概念,是为了解释处理器重排序带来的影响,并对其影响提出解决方案的
那为什么需要**重排序?**最基本的原因就是重排序可以提高效率,减少对内存总线的占用
在讲重排序的例子前,我们还要明确一个概念----写缓冲区:
1)写缓冲区
写缓冲区,其实就是将 cpu 的寄存器和多级缓存抽象为一个部分
2)为什么要指令重排
假设有这么一段程序
a=1;
a=2;
a=3;
其原本的指令顺序,是这样的:
a=1写入写缓存
将写缓存中的数据刷新到主存
a=2写入写缓存
将写缓存中的数据刷新到主存
a=3写入写缓存
将写缓存中的数据刷新到主存
可以看到,三个连续的数据修改,就对内存进行了三次写入,对于 CPU 来说,这是十分耗时的
所以,经过指令重排后,会变成下面这个样子:
a=1写入写缓存
a=2写入写缓存
a=3写入写缓存
将写缓存中的数据刷新到主存
这样,最后的结果与之前没有变化,但是我们将对内存中同一地址的多次修改给合并了,从而提高了运行效率
3)重排会带来什么问题
我们之前提到过,重排序可以提高效率,减少对内存总线的占用,我们来看下面这个例子:
// Processor A
a = 1; //A1
x = b; //A2
// Processor B
b = 2; //B1
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0
这段程序在重排序的影响下,最后的执行结果可能为 x = y = 0
4)不同处理器对重排序的要求
从上面两个小结可以看到,重排序有利有弊
不同处理器对于性能需求不同,其对重排序的松紧程度也不同
5)内存屏障
对于 JMM 来说,希望对程序员展示一个一致的内存模型,不能说因为不同电脑处理器不同,导致内存模型也会出现差异
那 JMM 怎么消除这种差异呢?JMM 的做法,是java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序:
对于对重排序放得松的 CPU ,就会对其插入更多的内存屏障,从而保证所有 CPU 都会为程序员展示一个一致的内存模型
了解了这部分知识,我们来看看相关的面试题:
什么是 CPU 的缓存一致性问题:
在多核 CPU 中,每个核的自己的缓存,关于同一个数据的缓存内容可能不一致。
并发编程会带来什么问题:
- **原子性问题:**原子性指的是一个操作执行途中,CPU 不可以中途暂停然后再调度,对于这个操作,要么执行完成,要么不执行。处理器优化可能导致原子性问题
- **有序性问题:**指的是程序执行的顺序按照代码的先后顺序执行,而不能随意重排,导致程序出现不一致的结果。
- **可见性问题:**指的是多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改后的值。我们上面说的缓存一致性,其实就是可见性问题
什么是内存模型:
Java内存模型定义了共享内存系统中多线程程序读写操作行为的规范,Java内存模型也就是为了解决这个并发编程问题而存在的。
内存模型怎么解决并发问题:
内存模型解决并发问题主要采取两种方式,分别是限制处理器优化,另一种是使用了内存屏障
这两种方式,JAVA 底层已经封装好了关键字给我们使用
关于解决并发编程中的原子性问题,Java底层封装了Synchronized的方式,来保证方法和代码块内的操作都是原子性的
而至于可见性问题,Java底层则封装了Volatile的方式,将被修饰的变量在修改后立即同步到主内存中。
至于有序性问题,其实也就是我们所说的重排序问题,Volatile关键字也会禁止指令的重排序,而Synchroinzed关键字由于保证了同一时刻只允许一条线程操作,自然也就保证了有序性。
3、Happens-before 规则
上一章最后我们提到,JVM 对程序员屏蔽了不同CPU的指令重排,那其具体表现形式是什么呢?
这里,就要提到我们的 Happens-before 规则了
Happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
重排序大致可以分为两类:
- 会改变执行结果的重排序
- 不会改变执行结果的重排序
对于第一种,JMM 要求编译器和处理器必须禁止
对于第二种,JMM 对编译器和处理器不做要求,虽然是不做要求,但是展现给程序员的表象是好像没有重排序一样
三、小结
JMM 对于我们后续学习并发编程十分重要,一定要对其中的知识有所了解
下回,我会和各位分享 GC 的相关知识