作者 | 李健青
来源 | 码哥字节(ID:MageByte)
在面试、并发编程、一些开源框架中总是会遇到 volatile
与 synchronized
。synchronized
如何保证并发安全?volatile
语义的内存可见性指的是什么?这其中又跟 JMM 有什么关系,在并发编程中 JMM 的作用是什么,为什么需要 JMM?与 JVM 内存结构有什么区别?
「码哥字节」 总结出里面的核心知识点以及面试重点,图文并茂无畏面试与并发编程,全面提升并发编程内功!
-
JMM 与 JVM 内存结构有什么区别? -
到底什么是 JMM (Java Memory Model) 内存模型,JMM 的跟并发编程有什么关系? -
内存模型最重要的内容: 指令重排、原子性、内存可见性。 -
volatile 内存可见性指的是什么?它的运用场景以及常见错误使用方式避坑指南。 -
分析 synchronized 实现原理跟 monitor 的关系;
JVM 内存与 JMM 内存模型
在这推荐几篇「码哥」的炸裂文章给大家,内容与并发无关,就是这么突然的推荐!
往期推荐
「码哥字节」会分别图解下 JVM 内存结构和 JMM 内存模型,这里不会讲太多 JVM 相关的,未来会有专门讲解 JVM 以及垃圾回收、内存调优的文章。敬请期待……
接下来我们通过图文的方式分别认识 JVM 内存结构和 JMM 内存模型,DJ, trop the beat, lets’go!
JVM 内存结构这么骚,需要和虚拟机运行时数据一起唠叨,因为程序运行的数据区域需要他来划分各领风骚。
Java 内存模型也很妖娆,不能被 JVM 内存结构来搞混淆,实际他是一种抽象定义,主要为了并发编程安全访问数据。
总结下就是:
-
JVM 内存结构和 Java 虚拟机的运行时区域有关; -
Java 内存模型和 Java 的并发编程有关。
JVM 内存结构
Java 代码是运行在虚拟机上的,我们写的 .java 文件首先会被编译成 .class 文件,接着被 JVM 虚拟机加载,并且根据不同操作系统平台翻译成对应平台的机器码运行,如下如所示:

从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了。JVM 只需要保证能够正确加载 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了。
JVM 通过 Java 类加载器加载 javac 编译出来的 class 文件,通过执行引擎解释执行或者 JIT 即时编译调用才调用系统接口实现程序的运行。

而虚拟机在运行程序的时候会把内存划分为不同的数据区域,不同区域负责不同功能,随着 Java 的发展,内存布局也在调整之中,如下是 Java 8 之后的布局情况,移除了永久代,使用 Mataspace 代替,所以 -XX:PermSize -XX:MaxPermSize
等参数变没有意义。JVM 内存结构如下图所示:

执行字节码的模块叫做执行引擎,执行引擎依靠程序计数器恢复线程切换。本地内存包含元数据区域以及一些直接内存。
堆(Heap)
数据共享区域存储实例对象以及数组,通常是占用内存最大的一块也是数据共享的,比如 new Object() 就会生成一个实例;而数组也是保存在堆上面的,因为在 Java 中,数组也是对象。垃圾收集器的主要作用区域。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。
我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,通常在在堆上分配,逃逸分析的情况下可能会在栈分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
虚拟机栈(Java Virtual Machine Stacks)
Java 虚拟机栈基于线程,即使只有一个 main 方法,都是以线程的方式运行,在运行的生命周期中,参与计算的数据会出栈与入栈,而「虚拟机栈」里面的每条数据就是「栈帧」,在 Java 方法执行的时候则创建一个「栈帧」并入栈「虚拟机栈」。调用结束则「栈帧」出栈,随之对应的线程也结束。
public int add() {
int a = 1, b = 2;
return a + b;
}
add 方法会被抽象成一个「栈帧」的结构,当方法执行过程中则对应着操作数 1 与 2 的操作数栈入栈,并且赋值给局部变量 a 、b ,遇到 add 指令则将操作数 1、2 出栈相加结果入栈。方法结束后「栈帧」出栈,返回结果结束。

每个栈帧包含四个区域:
-
局部变量表:基本数据类型、对象引用、retuenAddress 指向字节码的指针; -
操作数栈 -
动态连接 -
返回地址
这里有一个重要的地方,敲黑板了:
-
实际上有两层含义的栈,第一层是「栈帧」对应方法;第二层对应着方法的执行,对应着操作数栈。 -
所有的字节码指令,都会被抽象成对栈的入栈与出栈操作。执行引擎只需要傻瓜式的按顺序执行,就可以保证它的正确性。
每个线程拥有一个「虚拟机栈」,每个「虚拟机栈」拥有多个「栈帧」,而栈帧则对应着一个方法。每个「栈帧」包含局部变量表、操作数栈、动态链接、方法返回地址。方法运行结束则意味着该「栈帧」出栈。
如下图所示:

方法区(Method Area)元空间
存储每个 class 类的元数据信息,比如类的结构、运行时的常量池、字段、方法数据、方法构造函数以及接口初始化等特殊方法。
元空间是在堆上么?
答:不是在堆上分配的,而是在堆外空间分配,方法区就是在元空间中。
字符串常量池在那个区域中?
答:这个跟 JDK 不同版本不同区别,JDK 1.8 之前,元空间还没有出道成团,方法区被放在一个叫永久代的空间,而字符串常量就在此间。
JDK 1.7 之前,字符串常量池也放在叫作永久带的空间。JDK 1.7 之后,字符串常量池从永久代挪到了堆上凑。
所以,从 1.7 版本开始,字符串常量池就一直存在于堆上。
本地方法栈(Native Method Stacks)
跟虚拟机栈类似,区别在于前者是为 Java 方法服务,而本地方法栈是为 native 方法服务。
程序计数器(The PC Register)
保存当前正在执行的 JVM 指令地址。我们的程序在线程切换中运行,那凭啥知道这个线程已经执行到什么地方呢?
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。
JMM(Java Memory Model,Java 内存模型)
DJ, drop the beats!有请“码哥字节”,拨弄 Java 内存模型这根动人心弦。
首先他不是“真实存在”,而是和多线程相关的一组“规范”,需要每个 JVM 的实现都要遵守这样的“规范”,有了 JMM 的规范保障,并发程序运行在不同的虚拟机得到出的程序结果才是安全可靠可信赖。
如果没有 JMM 内存模型来规范,就可能会出现经过不同 JVM “翻译”之后,运行的结果都不相同也不正确。
JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题数据,保证不同的并发语义关键字得到相应的并发安全的数据资源保护。
主要目的就是让 Java 程序员在各种平台下达到一致性访问效果。
是 JUC 包工具类和并发关键字的原理保障
volatile、synchronized、Lock
等,它们的实现原理都涉及 JMM。有了 JMM 的参与,才让各个同步工具和关键字能够发挥作用同步语义才能生效,使得我们开发出并发安全的程序。
JMM 最重要的三点内容:重排序、原子性、内存可见性。
指令重排序
我们写的 bug 代码,当我以为这些代码的运行顺序按照我神来之笔的书写的顺序执行的时候,我发现我错的。实际上,编译器、JVM、甚至 CPU 都有可能出于优化性能的目的,并不能保证各个语句执行的先后顺序与输入的代码顺序一致,而是调整了顺序,这就是指令重排序。
重排序优势
可能我们会疑问:为什么要指令重排序?有啥用?
如下图:

经过重排序之后,情况如下图所示:

重排序后,对 a 操作的指令发生了改变,节省了一次 Load a 和一次 Store a,减少了指令执行,提升了速度改变了运行,这就是重排序带来的好处。
重排序的三种情况
-
编译器优化
比如当前唐伯虎爱慕 “秋香”,那就把对“秋香”的爱慕、约会放到一起执行效率就高得多。避免在撩“冬香”的时候又跑去约会“秋香”,减少了这部分的时间开销,此刻我们需要一定的顺序重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,不能把对“秋香”说的话传到“冬香”的耳朵里,否则能任意排序的话,后果不堪设想,“时间管理大师”非你莫属。
-
CPU 重排序
这里的优化跟编译器类似,目的都是通过打乱顺序提高整体运行效率,这就是为了更快而执行的秘密武器。
-
内存“重排序”
我不是真正意义的重排序,但是结果跟重排序有类似的成绩。因为还是有区别所以我加了双引号作为不一样的定义。
由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的数据正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。
举个例子:
线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。
内存可见性
先来看为何会有内存可见性问题
public class Visibility {
int x = 0;
public void write() {
x = 1;
}
public void read() {
int y = x;
}
}
内存可见性问题:当 x 的值已经被第一个线程修改了,但是其他线程却看不到被修改后的值。
假设两个线程执行的上面的代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。下面我们来分析一下,代码在实际运行过程中的情景是怎么样的,如下图所示:
