什么是 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 {
public volatile static int inc = 0;

public void increase() {
inc++;
}

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatoleAtomicityDemo.increase();
}
});
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}

正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500

为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者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程晓明):

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

所以这才是 final 的可见性,这种可见性和我们在并发中常说的可见性不是一个概念!

所以 final 无法保证可见性!

什么是 Java 中的指令重排?

为了提高程序执行的效率,CPU或者编译器就将执行命令重排序。

原因是因为内存访问的速度比 CPU 运行速度慢很多,因此需要编排一下执行的顺序,防止因为访问内存的比较慢的指令而使得 CPU 闲置着,其实和典型的烧水喝茶洗杯子的排序类似。

CPU 执行有个指令流水线的概念,还有分支预测等。

总之为了提高效率就会有指令重排的情况,导致指令乱序执行的情况发生,不过会保证结果肯定是与单线程执行结果一致的,这叫 as-if-serial。

不过多线程就无法保证了,在 Java 中的 volatile 关键字可以禁止修饰变量前后的指令重排。