天天看点

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

JVM 内存模型(JMM)

一、前言

昨天我们一起看过了内存结构的相关知识,同时,我又提到了很多小伙伴会将其与内存结构的概念搞混,今天,我们就来看看**内存模型(java memory model)**的相关概念,让各位对内存结构与内存模型的区别有一个更感性的认识

这里我们要首先明确它两的概念,内存结构是 JVM 中的概念,其主要是解决对象的分配问题,而内存模型是在内存结构上的进一步抽象,其作用是为了给解决并发安全问题提供一个简便的模型;后面为了方便,内存模型我都简称为 JMM

在讲解 JMM 之前,我们需要大致了解一下 硬件的内存结构

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

CPU缓存是为了平衡寄存器和主存读取速度而设计的,现代计算机缓存可能分为 1级、2级、3级缓存,这里我们统一将其抽象为一个缓存

通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器

JMM 我们一般直接抽象成下图表示:

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

可以看到,我们将 CPU 寄存器,还是不管多少级缓存,都抽象为本地变量

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

通过这种方法化简,可以让我们程序员更便捷的考虑并发问题,而如何化简这种脏活累活,都是由 JVM 帮我们完成的

对于我们的 JMM,其线程栈和堆中的数据,可能存储与硬件内存结构的任意位置:

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

正因如此,在并发情况下,就会出现如下两个问题:

  • 可见性问题
  • 竞态条件

这两个问题看起来高大上,但是看过下面的解释,你一定会恍然大悟的

**注意:**从这篇文章开始,我不会再将内容全部以问答的形式进行展现,而会在整理之后,在后面添加一道面试问答

二、内存模型问题总结

1、可见性问题和竞态条件

可见性问题:

假设有这么一个场景,两个线程要对共享数据区(这里就是物理内存)中的一个参数 count 进行 +1操作,此时,两个CPU 线程将共享数据区的数据各自读入自己的 CPU 缓存中,这种情况下,修改之后,只要不从 CPU 缓存中将修改后的数据刷新会主存,那么,当前线程的修改,对其他线程就是不可见的(其他线程不知道这个线程修改了数据),这就类似于数据库中的脏读

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

要解决此问题,可以使用Java的 volatile 关键字。 volatile关键字可以确保:

**1、**直接从主存中读取变量

2、在更新后,始终会写回主存(不使用 volatile的话,cpu会过一会儿才将缓存中修改的数据写入内存,这过一会儿就酿成了大祸)

竞态条件:

当两个线程想去修改主存中的数据的时候,(我们假定是想对主存中的变量 +1),不同 CPU 的线程,将主存中的 count 值,读入自己的 CPU 缓存中,并在各自的 CPU 缓存中进行了 +1 操作,此时两个线程总共对 count 变量进行了两次+1操作,但是当两个 CPU 缓存将自己的数据写入主存后,主存中的变量,显示的是只加了一次1

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

要解决这个问题,可以使用

synchronized

,这样可以保证:

**1、**任何时间点,只有一个线程可以访问临界区资源

**2、**保证所有变量都是从主存读取的

**3、**当退出同步代码块的时候,所有更新的变量会再次刷新回主存

2、处理器内存模型 和 JMM

提出处理器内存模型的概念,是为了解释处理器重排序带来的影响,并对其影响提出解决方案的

那为什么需要**重排序?**最基本的原因就是重排序可以提高效率,减少对内存总线的占用

在讲重排序的例子前,我们还要明确一个概念----写缓冲区:

1)写缓冲区

写缓冲区,其实就是将 cpu 的寄存器和多级缓存抽象为一个部分

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

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

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

4)不同处理器对重排序的要求

从上面两个小结可以看到,重排序有利有弊

不同处理器对于性能需求不同,其对重排序的松紧程度也不同

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

5)内存屏障

对于 JMM 来说,希望对程序员展示一个一致的内存模型,不能说因为不同电脑处理器不同,导致内存模型也会出现差异

那 JMM 怎么消除这种差异呢?JMM 的做法,是java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序:

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

对于对重排序放得松的 CPU ,就会对其插入更多的内存屏障,从而保证所有 CPU 都会为程序员展示一个一致的内存模型

了解了这部分知识,我们来看看相关的面试题:

什么是 CPU 的缓存一致性问题:

在多核 CPU 中,每个核的自己的缓存,关于同一个数据的缓存内容可能不一致。

并发编程会带来什么问题:

  • **原子性问题:**原子性指的是一个操作执行途中,CPU 不可以中途暂停然后再调度,对于这个操作,要么执行完成,要么不执行。处理器优化可能导致原子性问题
  • **有序性问题:**指的是程序执行的顺序按照代码的先后顺序执行,而不能随意重排,导致程序出现不一致的结果。
  • **可见性问题:**指的是多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改后的值。我们上面说的缓存一致性,其实就是可见性问题

什么是内存模型:

Java内存模型定义了共享内存系统中多线程程序读写操作行为的规范,Java内存模型也就是为了解决这个并发编程问题而存在的。

内存模型怎么解决并发问题:

内存模型解决并发问题主要采取两种方式,分别是限制处理器优化,另一种是使用了内存屏障

这两种方式,JAVA 底层已经封装好了关键字给我们使用

关于解决并发编程中的原子性问题,Java底层封装了Synchronized的方式,来保证方法和代码块内的操作都是原子性的

而至于可见性问题,Java底层则封装了Volatile的方式,将被修饰的变量在修改后立即同步到主内存中。

至于有序性问题,其实也就是我们所说的重排序问题,Volatile关键字也会禁止指令的重排序,而Synchroinzed关键字由于保证了同一时刻只允许一条线程操作,自然也就保证了有序性。

3、Happens-before 规则

上一章最后我们提到,JVM 对程序员屏蔽了不同CPU的指令重排,那其具体表现形式是什么呢?

这里,就要提到我们的 Happens-before 规则了

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

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 对编译器和处理器不做要求,虽然是不做要求,但是展现给程序员的表象是好像没有重排序一样

带你看看 JVM 内存模型(JMM)JVM 内存模型(JMM)

三、小结

JMM 对于我们后续学习并发编程十分重要,一定要对其中的知识有所了解

下回,我会和各位分享 GC 的相关知识