Java
内存模型规范了Java
虚拟机与计算机内存是如何协同工作的。Java
虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java
内存模型。
JMM
中最重要的三点:
- 指令重排
- 原子性
- 内存可见性
1、什么是 Java 内存模型?
Java 内存结构 VS Java 内存模型
Java
内存结构和Java
虚拟机的运行时区域有关;Java
内存模型和Java
的并发编程有关。
简单介绍一下 Java 内存结构:
其实 Java
内存结构就是我们常说的 Java
运行时数据区域。
JVM
在执行 Java
程序的过程中将所管理的内存划分为几个不同的数据区域,这些区域都有各自的用途。由五个部分组成:
(1)程序计数器
(2)Java
虚拟机栈
(3)本地方法栈
(4)Java
堆
(5)方法区
从 Java 代码到 CPU 指令
我们都知道,编写的 Java
代码,最终还是要转化为 CPU
指令才能执行的。
大致流程如下:
(1)Java
源代码文件。
(2)通过 javac
编译成字节码文件,生成后缀名为 .class
的文件。
(3)将字节码文件通过 JVM
解释成机器指令。
(3)将机器指令用 CPU
执行。
为什么需要 JMM
Java 非常需要一个标准,来让 Java
开发者、编译器工程师和 JVM
工程师能够达成一致。
达成一致后,我们就可以很清楚的知道什么样的代码最终可以达到什么样的运行效果,让多线程运行结果可以预期,这个标准就是 JMM
,这就是需要 JMM
的原因。
如果不加以规范,那么同样的 Java
代码,完全可能产生不一样的执行效果,那是不可接受的,这也违背了 Java
“书写一次、到处运行”的特点。
JMM 是什么
JMM 是规范
JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。这样一来,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。
如果没有 JMM 内存模型来规范,那么很可能在经过了不同 JVM 的“翻译”之后,导致在不同的虚拟机上运行的结果不一样,那是很大的问题。
JMM
与处理器、缓存、并发、编译器有关。它解决了 CPU
多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。
JMM 是工具类和关键字的原理
volatile、synchronized、Lock
等,其实它们的原理都涉及 JMM
。正是 JMM
的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。
2、指令重排
什么是重排序
假设我们写了一个 Java
程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。
但实际上,编译器、JVM
或者 CPU
都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。
重排序的好处:提高处理速度
重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。
重排序的 3 种情况
编译器优化
编译器(包括 JVM、JIT
编译器等)出于优化的目的,在编译的过程中会进行一定程度的重排。
但是重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。
CPU 重排序
CPU
同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。
所以即使之前编译器不发生重排,CPU
也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。
内存的“重排序
内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果。
由于内存有缓存的存在,在 JMM
里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
3、原子性
什么是原子性和原子操作
具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。
比如转账行为就是一个原子操作,该过程包含扣除余额、银行系统生成转账记录、对方余额增加等一系列操作。虽然整个过程包含多个操作,但由于这一系列操作被合并成一个原子操作,所以它们要么全部执行成功,要么全部不执行,不会出现执行一半的情况。
i++
这一个操作不是一个原子性操作,这一行代码在 CPU
中执行,可能会变成 3 个指令:
- 读取;
- 增加;
- 保存。
Java 中的原子操作有哪些
- 除了
long
和double
之外的基本类型(int、byte、boolean、short、char、float
)的读/写操作,都天然的具备原子性; - 所有引用
reference
的读/写操作; - 加了
volatile
后,所有变量的读/写操作(包含long
和double
)。这也就意味着long
和double
加了volatile
关键字之后,对它们的读写操作同样具备原子性; - 在
java.concurrent.Atomic
包中的一部分类的一部分方法是具备原子性的,比如AtomicInteger
的incrementAndGet
方法。
long 和 double 的原子性
long
和 double
的值需要占用 64 位的内存空间,而对于 64 位值的写入,可以分为两个 32 位的操作来进行。
本来是一个整体的赋值操作,就可能被拆分为低 32 位和高 32 位的两个操作。如果在这两个操作之间发生了其他线程对这个值的读操作,就可能会读到一个错误、不完整的值。
如果使用 volatile
修饰了 long
和 double
,那么其读写操作就必须具备原子性了。
原子操作 + 原子操作 != 原子操作
简单地把原子操作组合在一起,并不能保证整体依然具备原子性。
4、内存可见性
可见性问题:假设某个值已经被某个线程修改了,但是其他线程却看不到,也就是此时读取的值还是旧的值,读取不到已经修改过的值。
解决:给变量加上
volatile
修饰,其他的代码不变。
synchronized
不仅保证了原子性,还保证了可见性。
synchronized
不仅保证了临界区内最多同时只有一个线程执行操作,同时还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值。因为如果其他线程看不到之前所做的修改,依然也会发生线程安全问题。
5、主内存和工作内存
CPU 有多级缓存,导致读的数据过期
由于 CPU
的处理速度很快,相比之下,内存的速度就显得很慢,所以为了提高 CPU
的整体运行效率,减少空闲时间,在 CPU
和内存之间会有 cache
层,也就是缓存层的存在。虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:
线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。
什么是主内存和工作内存
Java
作为高级语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,用 JMM
定义了一套读写数据的规范。
每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM
控制的。
主内存和工作内存的关系
JMM
有以下规定:
(1)存储规定。所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
(2)读写规定。线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
(3) 通信规定。主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
6、happens-before 规则
什么是 happens-before
Happens-before
关系是用来描述和可见性相关问题的:如果第一个操作 happens-before
第二个操作(也可以描述为,第一个操作和第二个操作之间满足 happens-before
关系),那么我们就说第一个操作对于第二个操作一定是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果。
happens-before 关系的规则
(1)单线程规则
在一个单独的线程中,按照程序代码的顺序,先执行的操作 happen-before
后执行的操作。也就是说,如果操作 x 和操作 y 是同一个线程内的两个操作,并且在代码里 x 先于 y 出现,那么有 hb(x, y),正如下图所示:
(2)锁操作规则
synchronized 和 Lock 接口等。
操作 A 是解锁,而操作 B 是对同一个锁的加锁,那么 hb(A, B) 。
(3)volatile 变量规则
对一个 volatile
变量的写操作 happen-before
后面对该变量的读操作。
(4)线程启动规则
Thread
对象的 start
方法 happen-before
此线程 run
方法中的每一个操作。
(5)线程 join 规则
假设线程 A 通过调用 threadB.start()
启动了一个新线程 B,然后调用 threadB.join()
,那么线程 A 将一直等待到线程 B 的 run 方法结束(不考虑中断等特殊情况),然后 join
方法才返回。在 join
方法返回后,线程 A 中的所有后续操作都可以看到线程 B 的 run 方法中执行的所有操作的结果,也就是线程 B 的 run
方法里面的操作 happens-before
线程 A 的 join
之后的语句。
(6)中断规则
对线程 interrupt
方法的调用 happens-before
检测该线程的中断事件。
(7)并发工具类的规则
- 线程安全的并发容器(如
HashTable
)在 get 某个值时一定能看到在此之前发生的 put 等存入操作的结果。也就是说,线程安全的并发容器的存入操作happens-before
读取操作。 - 信号量(
Semaphore
)它会释放许可证,也会获取许可证。这里的释放许可证的操作happens-before
获取许可证的操作,也就是说,如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到。 Future
:Future
有一个 get 方法,可以用来获取任务的结果。那么,当Future
的get
方法得到结果的时候,一定可以看到之前任务中所有操作的结果,也就是说Future
任务中的所有操作happens-before Future
的get
操作。- 线程池:要想利用线程池,就需要往里面提交任务(
Runnable
或者Callabl
e),这里面也有一个happens-before
关系的规则,那就是提交任务的操作happens-before
任务的执行。