在面试、并发编程、一些开源框架中总是会遇到volatile与synchronized。synchronized如何保证并发安全?volatile语义的内存可见性指的是什么?这其中又跟 JMM 有什么关系,在并发编程中 JMM 的作用是什么,为什么需要 JMM?与 JVM 内存结构有什么区别?
总结出里面的核心知识点以及面试重点,图文并茂无畏面试与并发编程,全面提升并发编程内功!
JMM 与 JVM 内存结构有什么区别?
到底什么是JMM (Java Memory Model)内存模型,JMM 的跟并发编程有什么关系?
内存模型最重要的内容:指令重排、原子性、内存可见性。
volatile内存可见性指的是什么?它的运用场景以及常见错误使用方式避坑指南。
分析 synchronized 实现原理跟 monitor 的关系;
JVM 内存与 JMM 内存模型
接下来我们通过图文的方式分别认识JVM 内存结构和JMM 内存模型,DJ, trop the beat, lets’go!
JVM 内存结构这么骚,需要和虚拟机运行时数据一起唠叨,因为程序运行的数据区域需要他来划分各领风骚。
Java 内存模型也很妖娆,不能被 JVM 内存结构来搞混淆,实际他是一种抽象定义,主要为了并发编程安全访问数据。
总结下就是:
JVM 内存结构和 Java 虚拟机的运行时区域有关;
Java 内存模型和 Java 的并发编程有关。
JVM 内存结构
Java 代码是运行在虚拟机上的,我们写的 .java 文件首先会被编译成 .class 文件,接着被 JVM 虚拟机加载,并且根据不同操作系统平台翻译成对应平台的机器码运行
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 内存模型)
首先他不是“真实存在”,而是和多线程相关的一组“规范”,需要每个 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 的值已经被第一个线程修改了,但是其他线程却看不到被修改后的值。
原子性
我们大致可以认为基本数据类型变量、引用类型变量、声明为 volatile 的任何类型变量的访问读写是具备原子性的(long 和 double 的非原子性协定:对于 64 位的数据,如 long 和 double,Java 内存模型规范允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性,即如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。
但由于目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的 long 和 double 变量专门声明为 volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。比如i++;
Java 内存模型解决的问题
JMM 最重要的的三点内容:重排序、原子性、内存可见性。那么 JMM 又是如何解决这些问题的呢?
JMM 抽象出主存储器(Main Memory)和工作存储器(Working Memory)两种。
主存储器是实例位置所在的区域,所有的实例都存在于主存储器内。比如,实例所拥有的字段即位于主存储器内,主存储器是所有的线程所共享的。
工作存储器是线程所拥有的作业区,每个线程都有其专用的工作存储器。工作存储器存有主存储器中必要部分的拷贝,称之为工作拷贝(Working Copy)。
线程是无法直接对主内存进行操作的,如下图所示,线程 A 想要和线程 B 通信,只能通过主存进行交换。
八个操作
为了支持 JMM,Java 定义了 8 种原子操作(Action),用来控制主存与工作内存之间的交互:
read读取:作用于主内存,将共享变量从主内存传动到线程的工作内存中,供后面的 load 动作使用。
load载入:作用于工作内存,把 read 读取的值放到工作内存中的副本变量中。
store存储:作用于工作内存,把工作内存中的变量传送到主内存中,为随后的 write 操作使用。
write写入:作用于主内存,把 store 传送值写到主内存的变量中。
use使用:作用于工作内存,把工作内存的值传递给执行引擎,当虚拟机遇到一个需要使用这个变量的指令,就会执行这个动作。
assign赋值:作用于工作内存,把执行引擎获取到的值赋值给工作内存中的变量,当虚拟机栈遇到给变量赋值的指令,执行该操作。比如int i = 1;
lock(锁定)作用于主内存,把变量标记为线程独占状态。
unlock(解锁)作用于主内存,它将释放独占状态。
JMM 总结
JMM 是一个抽象概念,由于 CPU 多核多级缓存、为了优化代码会发生指令重排的原因,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工作内存于主内存的概念,并且通过八个原子操作以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排导致的线程安全问题,JMM 是并发编程的基础。
网友评论