【Java】并发(三) | JMM
什么是 Java 内存模型(JMM)?
JMM 即 Java Memory Model,Java 内存模型。
它的基本目标是:
- 确保基本的读写操作的原子性:多个线程对一个变量的读写操作是不可分割的
- 确保线程的可见性:一个线程对共享变量的修改,能够被其他线程看到。
- 确保线程的有序性:保证代码的执行顺序不会被编译器或 CPU 重新排列,使得代码的执行顺序符合开发者的预期,从而避免在并发环境下出现意外的结果。
操作系统有一套内存模型,而 Java 是跨平台实现的,因此它需要自己定义一套内存模型屏蔽各操作系统之间的差异。
JMM (JSR133) 定义了 Java 源码到 CPU 指令执行一套规范,我们仅需直接使用 Java 提供的并发类(synchronized、volatile 等),知晓它定义的 happens-before 原则,即可写出并发安全的代码,无需关心底层的 CPU 指令重排、多级缓存等各种底层原理。
抽象的来看 JMM 会把内存分为本地内存和主存,每个线程都有自己的私有化的本地内存,然后还有个存储共享数据的主存。
Happens-Before
Happens-Before 是 JMM 中的重要概念,用于确定两个操作之间的执行顺序,确保多线程程序的正确性和一致性,底层主要是利用内存屏障来实现的。
具体来说:Happens-Before 关系定义了某个操作的结果对另一个操作可见,即如果操作 A Happens-Before 操作 B,则操作 A 的结果对操作 B 可见。
Happens-Before 规则包括以下几个重要的顺序:
1)程序顺序规则:
在一个线程内,按照代码顺序,前面的操作 Happens-Before 后面的操作。
2)监视器锁规则:
对一个锁的解锁操作 Happens-Before 后续对这个锁的加锁操作。
3)volatile 变量规则:
对一个 volatile 变量的写操作 Happens-Before 后续对这个 volatile 变量的读操作。
4)线程启动规则:
对线程的 Thread.start() 调用 Happens-Before 该线程中的每一个动作。
5)线程终止规则:
线程中的所有操作 Happens-Before 其他线程检测到该线程已经终止(通过 Thread.join()、Thread.isAlive() 返回的值等)。
6)线程中断规则:
对线程的 interrupt() 调用 Happens-Before 检测到中断事件的代码(如 Thread.interrupted() 或 Thread.isInterrupted())。
7)对象终结规则:
一个对象的初始化完成 Happens-Before 它的 finalize() 方法的开始。
8)传递性:
如果操作 A Happens-Before 操作 B,操作 B Happens-Before 操作 C,则操作 A Happens-Before 操作 C。
volatile 关键字
volatile 关键字是用来保证有序性和可见性的。
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile
关键字还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。(我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流水线阻塞,提高 CPU 的执行效率。)
volatile
关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
public class VolatoleAtomicityDemo { |
正常情况下,运行上面的代码理应输出 2500
。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500
。
为什么会出现这种情况呢?不是说好了,volatile
可以保证变量的可见性嘛!
也就是说,如果 volatile
能保证 inc++
操作的原子性的话。每个线程中对 inc
变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。
很多人会误认为自增操作 inc++
是原子性的,实际上,inc++
其实是一个复合操作,包括三步:
- 读取 inc 的值。
- 对 inc 加 1。
- 将 inc 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
inc
进行读取操作之后,还未对其进行修改。线程 2 又读取了inc
的值并对其进行修改(+1),再将inc
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
inc
的值进行修改(+1),再将inc
的值写回内存。
这也就导致两个线程分别对 inc
进行了一次自增操作后,inc
实际上只增加了 1。
其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以。
synchronized 和 volatile 的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。volatile
不会造成线程的阻塞;synchronized
可能会造成线程的阻塞。volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化。
Java 中的 final 关键字是否能保证变量的可见性?
不可以。
你可能看到一些答案说可以保证可见性,那不是我们常说的可见性。
一般而言我们指的可见性是一个线程修改了共享变量,另一个线程可以立马得知更改,得到最新修改后的值。
而 final 并不能保证这种情况的发生,volatile 才可以。
而有些答案提到的 final 可以保证可见性,其实指的是 final 修饰的字段在构造方法初始化完成,并且期间没有把 this 传递出去,那么当构造器执行完毕之后,其他线程就能看见 final 字段的值。
如果不用 final 修饰的话,那么有可能在构造函数里面对字段的写操作被排序到外部,这样别的线程就拿不到写操作之后的值。
对于 final 域,编译器和处理器要遵守两个重排序规则(参考自infoq程晓明):
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
所以这才是 final 的可见性,这种可见性和我们在并发中常说的可见性不是一个概念!
所以 final 无法保证可见性!
什么是 Java 中的指令重排?
为了提高程序执行的效率,CPU或者编译器就将执行命令重排序。
原因是因为内存访问的速度比 CPU 运行速度慢很多,因此需要编排一下执行的顺序,防止因为访问内存的比较慢的指令而使得 CPU 闲置着,其实和典型的烧水喝茶洗杯子的排序类似。
CPU 执行有个指令流水线的概念,还有分支预测等。
总之为了提高效率就会有指令重排的情况,导致指令乱序执行的情况发生,不过会保证结果肯定是与单线程执行结果一致的,这叫 as-if-serial。
不过多线程就无法保证了,在 Java 中的 volatile 关键字可以禁止修饰变量前后的指令重排。