Java内存模型(Memory Model)

java内存模型、指令重排、JMM、happens-before

总结

JMM 是 jvm 定义的一套规范,用来规范多线程并发时对共享资源的访问规则,如何保证多线程的可见性、原子性、有序性。JMM 把内存分为线程的工作内存和主内存。

CPU 缓存

我们知道 CPU 是有缓存的,CPU 缓存是为了解决主内存和 CPU 处理速度不对等的问题。其工作方式是先复制一份数据到 CPU 缓存中,当 CPU 需要用到的时候就可以直接从 CPU 缓存中读取数据,当运算完成后,再将运算得到的数据写回主内存中。但是,这样存在 内存缓存不一致性的问题 !比如执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU 缓存中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

那么 CPU 为了解决内存缓存不一致性问题就需要定制协议规范,即内存模型,无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

指令重排

为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序,即执行代码的顺序和实际代码编写顺序不一定相同。常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

例:a = b + c; d = e - f ; 先加载 b、c(注意,有可能先加载 b,也有可能先加载 c ),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。提高了效率!

然而指令重排序可以保证串行语义一致,但无法保证多线程间的语义也一致 ,在多线程下指令重排序可能会导致一些问题。

对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。

  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。

  • 对于处理器,通过插入内存屏障或内存栅栏的方式来禁止特定类型的处理器重排序。

JMM

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

所以 Java 线程之间的通信由 Java 内存模型控制,同时保证了 java 的跨平台,定义了并发编程的规范,抽象了线程和主内存的关系,避免出现像 CPU 指令重排导致的多线程问题。

JMM 核心概念:

  • 内存分区:JMM 将内存分为 主内存线程工作内存, 主内存就是 所有线程共享的内存区域,包括堆内存和方法区。线程工作内存就是每个线程独有的内存区域,包括局部变量、操作栈、寄存器等。
  • 可见性:一个线程对共享变量的修改,其他线程能够立即看到。
  • 原子性:一个操作要么全部完成,要么全部不完成,不会出现中间状态。
  • 顺序性:程序中代码的执行顺序与代码的书写顺序一致。

JMM 如何抽象线程和主内存的关系

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

在 JDK 2 之前,Java 的内存模型实现是从 主存 (即共享内存)读取变量,而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。 什么是主内存、本地内存

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • 本地内存 :每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:

  • 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  • 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障。不过多线程操作主内存的共享变量也是有线程安全问题的

happens-before 原则

前面提到了指令重排可能会引发多线程的执行问题,为此 JMM 抽象了 happens-before 原则来解决这个指令重排序问题。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

并发编程的三个重要特性

原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。

synchronized还可以保证可见性!

可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

在 Java 中,可以借助synchronizedvolatile 以及各种 Lock 实现可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

我们上面讲重排序的时候也提到过,指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

在 Java 中,volatile 关键字可以禁止指令进行重排序优化

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计