在面试、并发编程、一些开源框架中总是会遇到 volatile
与 synchronized
。synchronized
如何保证并发安全?volatile
语义的内存可见性指的是什么?这其中又跟 JMM 有什么关系,在并发编程中 JMM 的作用是什么,为什么需要 JMM?与 JVM 内存结构有什么区别?
总结出里面的核心知识点以及面试重点,图文并茂无畏面试与并发编程,全面提升并发编程内功!
分别图解下 JVM 内存结构和 JMM 内存模型,这里不会讲太多 JVM 相关的,未来会有专门讲解 JVM 以及垃圾回收、内存调优的文章。敬请期待……
接下来我们通过图文的方式分别认识 JVM 内存结构和 JMM 内存模型,DJ, trop the beat, lets’go!
JVM 内存结构这么骚,需要和虚拟机运行时数据一起唠叨,因为程序运行的数据区域需要他来划分各领风骚。
Java 内存模型也很妖娆,不能被 JVM 内存结构来搞混淆,实际他是一种抽象定义,主要为了并发编程安全访问数据。
总结下就是:
Java 代码是运行在虚拟机上的,我们写的 .java 文件首先会被编译成 .class 文件,接着被 JVM 虚拟机加载,并且根据不同操作系统平台翻译成对应平台的机器码运行,如下如所示:
JVM跨平台
从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了。JVM 只需要保证能够正确加载 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了。
JVM 通过 Java 类加载器加载 javac 编译出来的 class 文件,通过执行引擎解释执行或者 JIT 即时编译调用才调用系统接口实现程序的运行。
JVM加载
而虚拟机在运行程序的时候会把内存划分为不同的数据区域,不同区域负责不同功能,随着 Java 的发展,内存布局也在调整之中,如下是 Java 8 之后的布局情况,移除了永久代,使用 Mataspace 代替,所以-XX:PermSize -XX:MaxPermSize
等参数变没有意义。JVM 内存结构如下图所示:
JVM内存布局
执行字节码的模块叫做执行引擎,执行引擎依靠程序计数器恢复线程切换。本地内存包含元数据区域以及一些直接内存。
数据共享区域存储实例对象以及数组,通常是占用内存最大的一块也是数据共享的,比如 new Object() 就会生成一个实例;而数组也是保存在堆上面的,因为在 Java 中,数组也是对象。垃圾收集器的主要作用区域。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。
我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,通常在在堆上分配,逃逸分析的情况下可能会在栈分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
Java 虚拟机栈基于线程,即使只有一个 main 方法,都是以线程的方式运行,在运行的生命周期中,参与计算的数据会出栈与入栈,而「虚拟机栈」里面的每条数据就是「栈帧」,在 Java 方法执行的时候则创建一个「栈帧」并入栈「虚拟机栈」。调用结束则「栈帧」出栈,随之对应的线程也结束。
public int add() {
int a = 1, b = 2;
return a + b;
}
add 方法会被抽象成一个「栈帧」的结构,当方法执行过程中则对应着操作数 1 与 2 的操作数栈入栈,并且赋值给局部变量 a 、b ,遇到 add 指令则将操作数 1、2 出栈相加结果入栈。方法结束后「栈帧」出栈,返回结果结束。
每个栈帧包含四个区域:
这里有一个重要的地方,敲黑板了:
每个线程拥有一个「虚拟机栈」,每个「虚拟机栈」拥有多个「栈帧」,而栈帧则对应着一个方法。每个「栈帧」包含局部变量表、操作数栈、动态链接、方法返回地址。方法运行结束则意味着该「栈帧」出栈。
如下图所示:
JVM虚拟机栈
存储每个 class 类的元数据信息,比如类的结构、运行时的常量池、字段、方法数据、方法构造函数以及接口初始化等特殊方法。
元空间是在堆上么?
答:不是在堆上分配的,而是在堆外空间分配,方法区就是在元空间中。
字符串常量池在那个区域中?
答:这个跟 JDK 不同版本不同区别,JDK 1.8 之前,元空间还没有出道成团,方法区被放在一个叫永久代的空间,而字符串常量就在此间。
JDK 1.7 之前,字符串常量池也放在叫作永久带的空间。JDK 1.7 之后,字符串常量池从永久带挪到了堆上凑。
所以,从 1.7 版本开始,字符串常量池就一直存在于堆上。
跟虚拟机栈类似,区别在于前者是为 Java 方法服务,而本地方法栈是为 native 方法服务。
保存当前正在执行的 JVM 指令地址。我们的程序在线程切换中运行,那凭啥知道这个线程已经执行到什么地方呢?
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。
DJ, drop the beats!有请“码哥字节”,拨弄 Java 内存模型这根动人心弦。
首先他不是“真实存在”,而是和多线程相关的一组“规范”,需要每个 JVM 的实现都要遵守这样的“规范”,有了 JMM 的规范保障,并发程序运行在不同的虚拟机得到出的程序结果才是安全可靠可信赖。
如果没有 JMM 内存模型来规范,就可能会出现经过不同 JVM “翻译”之后,运行的结果都不相同也不正确。
JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题数据,保证不同的并发语义关键字得到相应的并发安全的数据资源保护。
主要目的就是让 Java 程序员在各种平台下达到一致性访问效果。
是 JUC 包工具类和并发关键字的原理保障
volatile、synchronized、Lock
等,它们的实现原理都涉及 JMM。有了 JMM 的参与,才让各个同步工具和关键字能够发挥作用同步语义才能生效,使得我们开发出并发安全的程序。
JMM 最重要的三点内容:重排序、原子性、内存可见性。
我们写的 bug 代码,当我以为这些代码的运行顺序按照我神来之笔的书写的顺序执行的时候,我发现我错的。实际上,编译器、JVM、甚至 CPU 都有可能出于优化性能的目的,并不能保证各个语句执行的先后顺序与输入的代码顺序一致,而是调整了顺序,这就是指令重排序。
重排序优势
可能我们会疑问:为什么要指令重排序?有啥用?
如下图:
Java并发编程78讲
经过重排序之后,情况如下图所示:
Java并发编程78讲
重排序后,对 a 操作的指令发生了改变,节省了一次 Load a 和一次 Store a,减少了指令执行,提升了速度改变了运行,这就是重排序带来的好处。
重排序的三种情况
举个例子:
线程 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 方法。下面我们来分析一下,代码在实际运行过程中的情景是怎么样的,如下图所示:
它们都可以从主内存中去获取到这个信息,对两个线程来说 x 都是 0。可是此时我们假设第 1 个线程先去执行 write 方法,它就把 x 的值从 0 改为了 1,但是它改动的动作并不是直接发生在主内存中的,而是会发生在第 1 个线程的工作内存中,如下图所示。
那么,假设线程 1 的工作内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,但是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。
volatile、synchronized、final、和锁
都能保证可见性。要注意的是 volatile,每当变量的值改变的时候,都会立马刷新到主内存中,所以其他线程想要读取这个数据,则需要从主内存中刷新到工作内存上。
而锁和同步关键字就比较好理解一些,它是把更多个操作强制转化为原子化的过程。由于只有一把锁,变量的可见性就更容易保证。
我们大致可以认为基本数据类型变量、引用类型变量、声明为 volatile 的任何类型变量的访问读写是具备原子性的(long 和 double 的非原子性协定:对于 64 位的数据,如 long 和 double,Java 内存模型规范允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性,即如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。
但由于目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的 long 和 double 变量专门声明为 volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。比如 i++;
JMM 最重要的的三点内容:重排序、原子性、内存可见性。那么 JMM 又是如何解决这些问题的呢?
JMM 抽象出主存储器(Main Memory)和工作存储器(Working Memory)两种。
线程是无法直接对主内存进行操作的,如下图所示,线程 A 想要和线程 B 通信,只能通过主存进行交换。
经历下面 2 个步骤:
1)线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
JMM内存模型
从抽象角度看,JMM 定义了线程与主内存之间的抽象关系:
八个操作
为了支持 JMM,Java 定义了 8 种原子操作(Action),用来控制主存与工作内存之间的交互:
int i = 1;
深入浅出Java虚拟机
如上图所示,把一个变量数据从主内存复制到工作内存,要顺序执行 read 和 load;而把变量数据从工作内存同步回主内存,就要顺序执行 store 和 write 操作。
由于重排序、原子性、内存可见性,带来的不一致问题,JMM 通过 八个原子动作,内存屏障保证了并发语义关键字的代码能够实现对应的安全并发访问。
原子性保障
JMM 保证了 read、load、assign、use、store 和 write 六个操作具有原子性,可以认为除了 long 和 double 类型以外,对其他基本数据类型所对应的内存单元的访问读写都是原子的。
但是当你想要更大范围的的原子性保证就需要使用 ,就可以使用 lock 和 unlock 这两个操作。
内存屏障:内存可见性与指令重排序
那 JMM 如何保障指令重排序排序,内存可见性带来并发访问问题?
内存屏障(Memory Barrier)用于控制在特定条件下的重排序和内存可见性问题。JMM 内存屏障可分为读屏障和写屏障,Java 的内存屏障实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。
组合如下:
JMM 是一个抽象概念,由于 CPU 多核多级缓存、为了优化代码会发生指令重排的原因,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工作内存于主内存的概念,并且通过八个原子操作以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排导致的线程安全问题,JMM 是并发编程的基础。
并且 JMM 为程序中所有的操作定义了一个关系,称之为 「Happens-Before」原则,要保证执行操作 B 的线程看到操作 A 的结果,那么 A、B 之间必须满足「Happens-Before」关系,如果这两个操作缺乏这个关系,那么 JVM 可以任意重排序。
Happens-Before
它是 Java 中的一个关键字,当一个变量是共享变量,同时被 volatile
修饰当值被更改的时候,其他线程再读取该变量的时候可以保证能获取到修改后的值,通过 JMM 屏蔽掉各种硬件和操作系统的内存访问差异 以及 CPU 多级缓存等导致的数据不一致问题。
需要注意的是,volatile 修饰的变量对所有线程是立即可见的,关键字本身就包含了禁止指令重排的语意,但是在非原子操作的并发读写中是不安全的,比如 i++ 操作一共分三步操作。
相比 synchronised
Lock
volatile
更加轻量级,不会发生上下文切换等开销,接着跟着「码哥字节」来分析下他的适用场景,以及错误使用场景。
volatile 的作用
boolean 标志位
共享变量只有被赋值和读取,没有其他的多个复合操作(比如先读数据再修改的复合运算 i++),我们就可以使用 volatile 代替 synchronized 或者代替原子类,因为赋值操作是原子性操作,而 volatile 同时保证了 可见性,所以是线程安全的。
如下经典场景 volatile boolean flag
,一旦 flag 发生变化,所有的线程立即可见。
volatile boolean shutdownRequested;
...
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
线程 1 执行 doWork() 的过程中,可能有另外的线程 2 调用了 shutdown,线程 1 里吗读区到修改的值并停止执行。
这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested
标志从false
转换为true
,然后程序停止。
双重检查(单例模式)
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) { // 1
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton(); //2
}
}
return instance;
}
}
在双重检查锁模式中为什么需要使用 volatile 关键字?
假如 Instance 类变量是没有用 volatile 关键字修饰的,会导致这样一个问题:
在线程执行到第 1 行的时候,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。
造成这种现象主要的原因是创建对象不是原子操作以及指令重排序。
第二行代码可以分解成以下几步:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
根源在于代码中的 2 和 3 之间,可能会被重排序。例如:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
这种重排序可能就会导致一个线程拿到的 instance 是非空的但是还没初始化完全。
img
面试官可能会问你,“为什么要 double-check?去掉任何一次的 check 行不行?”
我们先来看第二次的 check,这时你需要考虑这样一种情况,有两个线程同时调用 getInstance 方法,由于 singleton 是空的 ,因此两个线程都可以通过第一重的 if 判断;然后由于锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。
不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时如果没有第二重 if (singleton == null) 判断的话,那么第二个线程也会创建一个实例,此时就破坏了单例,这肯定是不行的。
而对于第一个 check 而言,如果去掉它,那么所有线程都会串行执行,效率低下,所以两个 check 都是需要保留的。
volatile 不适合运用于需要保证原子性的场景,比如更新的时候需要依赖原来的值,而最典型的就是 a++ 的场景,我们仅靠 volatile 是不能保证 a++ 的线程安全的。代码如下所示:
public class DontVolatile implements Runnable {
volatile int a;
public static void main(String[] args) throws InterruptedException {
Runnable r = new DontVolatile();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((DontVolatile) r).a);
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
a++;
}
}
}
最终的结果 a < 2000。
互斥同步是常见的并发正确性保障方式。同步就好像在公司上班,厕所只有一个,现在一帮人同时想去「带薪拉屎」占用厕所,为了保证厕所同一时刻只能一个员工使用,通过排队互斥实现。
互斥是实现同步的一种手段,临界区、互斥量(Mutex)和信号量(Semaphore)都是主要互斥方式。互斥是因,同步是果。
监视器锁(Monitor 另一个名字叫管程)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
mutex 的工作方式
在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的, 几个关键属性:
ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1。
若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。
在 Java 中,最基本的互斥同步手段就是 synchronised,经过编译之后会在同步块前后分别插入 monitorenter
, monitorexit
这两个字节码指令,而这两个字节码指令都需要提供一个 reference 类型的参数来指定要锁定和解锁的对象,具体表现如下所示:
synchronised 用的锁也存在 Java 对象头里,在 JVM 中,对象在内存的布局分为三块区域:对象头、实例数据、对其填充。
对象头
对象头是 synchronised 实现的关键,使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字宽(一个字宽代表 4 个字节,一个字节 8bit)来存储对象头(如果对象是数组则会分配 3 个字宽,多出来的 1 个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。
Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。
其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word 在不同的锁状态下存储的内容不同,在 32 位 JVM 中默认状态为下:
在运行过程中,Mark Word 存储的数据会随着锁标志位的变化而变化,可能出现如下 4 种数据:
锁标志位的表示意义:
到目前为止,我们再总结一下前面的内容,synchronized(lock) 中的 lock 可以用 Java 中任何一个对象来表示,而锁标识的存储实际上就是在 lock 这个对象中的对象头内。
Monitor(监视器锁)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以 synchronized 是 Java 语言中的一个重量级操作。
为什么任意一个 Java 对象都能成为锁对象呢?
Java 中的每个对象都派生自 Object 类,而每个 Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc 进行对应。其次,线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的 Java 对象是天生携带 monitor。
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识, ObjectMonitor 这个对象和线程争抢锁的逻辑有密切的关系。
JMM 总结
JVM 内存结构和 Java 虚拟机的运行时区域有关;
Java 内存模型和 Java 的并发编程有关。JMM 是并发编程的基础,它屏蔽了硬件和系统造成的内存访问差异,保证了 一致性、原子性、并禁止指令重排保证了安全访问。通过总线嗅探机制使得缓存数据失效, 保证 volatile 内存可见性。
JMM 是一个抽象概念,由于 CPU 多核多级缓存、为了优化代码会发生指令重排的原因,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工作内存于主内存的概念,并且通过八个原子操作以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排导致的线程安全问题,JMM 是并发编程的基础。
synchronized 原理
提到了锁的几个概念,偏向锁、轻量级锁、重量级锁。在 JDK1.6 之前,synchronized 是一个重量级锁,性能比较差。从 JDK1.6 开始,为了减少获得锁和释放锁带来的性能消耗,synchronized 进行了优化,引入了偏向锁和轻量级锁的概念。
所以从 JDK1.6 开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是: 无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率,锁可以升级但是不能降级。
最后,感谢原作者的创作:
链接:https://zhuanlan.zhihu.com/p/271717200
来源:知乎