如果有人给你撕逼Java内存模型,就把这些问题甩给他原创
导读
JVM内存模型
(JMM)是并发的基础,要是想扎实的理解并发原理,那么就必须对JMM有比较深刻的认识。相信大部分朋友都有所了解了。这两天回顾了一下相关内容,在琢磨怎么才能更加直观的表达出这个内存模型
,并且对这个模型有比较深刻的认识。刚好最近想做做动画,所以打算练练手尝试下以动画的形式来描述下这个模型,顺便看看有没有成长为一个动画大师的资质。
为此,本文我我将从以下几个方面展开来说明:
-
内存模型是什么,有什么用,以及
Java内存模型
是怎样的; -
Java内存模型
是如何实现多线程同步
的; -
常见的
同步问题
;
无论你是跟同事、同学、上下级、同行、或者面试官撕逼的时候,大家都会使出自己毕生所学,通过各种手段一步一步的把对方逼上投降之路,典型的招式如夺命连环问,一环扣一环,直至分出高下。而讨论到JMM
,大家常见的撕逼方式如下:
如果您对这些问题都了如指掌,那么恭喜你,说明你的基础很扎实,是个狠人。但是也可以看看我下面精心准备的动图和说明图,交流下,看看有无错漏之处。如果你刚好有不太明白的知识点,继续往下看,可以解开你一切的迷惑,拨开Java代码背后的内存模型迷雾。下次有人跟你讨论Java内存模型 JMM(Java Memory Model)
,就把这些问题甩给他。
本文我们来探讨一下Java内存模型JMM。
说到JMM,我们不得不提及多处理器体系结构
,以及多线程
。
1、什么是内存模型
什么是内存模型
,为什么需要内存模型。我们得从高速缓存
带来的一些问题说起。
1.1、高速缓存
在多处理器
系统中,处理器通常具有一层或者多层的高速缓存
,这可以通过加快对数据的访问速度(因为数据更靠近处理器)和减少共享内存总线上的流量(因为可以满足许多内存操作)来提高性能。
但是以上的流程又带来了许多新的挑战,例如:
当两个处理器同时读取和写入相同的内存位置的时候会发生什么呢?他们将在什么条件下才可以看到一致的内容,怎么确保所有的缓存都是一致的呢?
为了解决一致性问题,需要各个处理器访问缓存的时候都遵循一些协议,读写的时候需要根据协议来进行操作,相关协议有:MSI、MESI、MOSI、Synapse、Firefly、Dragon Protocol等,如下图:
内存模型
可以理解为在特定操作协议下对特定的内存或高速缓存进行读写访问过程的一个抽象,即内存模型。
1.2、内存模型
在处理器级别,内存模型
定义了必要和充分的条件,以便当让其他处理器对内存的改动对当前处理器可见,不同的处理器有不同的内存模型:
-
一些处理器内存模型比较强大,表现为其所有处理器始终在任何给定的内存位置看到完全相同的值;
-
其他处理器的内存模型表现的比较弱,需要通过特殊的称为
内存屏障
(memory barriers)的指令来刷新缓存,以便对缓存的写入对其他处理器可见,或使本地处理器高速缓存无效,以便重新获取其他处理器写入的缓存。这些内存屏障通常在执行锁定和解锁操作的时候执行,对于高级语言来说,它们是不可见的。后面在讲解Java内存模型的时候会专门介绍下内存屏障。
2、内存模型
先来点概念性的东西,后面再上图。
Java内存模型
描述了多线程
代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了程序中变量与低级别的详细信息之间的关系,这些低级别详细信息在实际计算机系统中的存储器或寄存器之间进行存储和检索。
Java语言提供了volatile, final, 和 synchronized 旨在帮助程序员向编译器描述程序的并发要求。
Java内存模型定义了volatile和synchronized的行为,更重要的是,确保做了正确同步的Java程序可以在所有的处理器体系结构上正确运行。
Java内存模型主要参与者:
变量
:这里的变量,主要指实例字段、类变量,以及数组中的对象元素,不包括局部变量和方法参数(线程私有);
主内存
:共享的主存储器,变量保存在这里;因为一个线程不可能访问另一个线程的参数和局部变量,所以将局部变量视为驻留在共享主存储器或者工作内存里面都没有关系;
工作内存
:每个线程都有一个工作内存,在其中保留了自己必须使用或分配的变量的工作副本。线程执行的时候,将对这些工作副本继续操作。主内存包含每个变量的主副本。对于何时允许或要求线程将其变量的工作副本的内容传输到主副本存在一些规则,反之亦然;
Java线程
:后面介绍Java线程的文章会详细讲解。
那么可以得出一些的Java内存模型参与者协作图:
其中的线程引擎指的是JVM执行引擎。
2.1、Java内存模型原子操作
线程和主存的交互,Java内存模型定义了8种操作[1]:这些操作都是原子的(double和long类型有例外):
-
use:变量从工作内存传递给执行引擎。每当虚拟机线程遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
-
assign:把一个从执行引擎接收到的值复制给工作内存的变量。每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
-
read:把一个变量的值从主内存拷贝到工作内存,以便为随后的load动作使用;
-
load:把read操作从主内存获取的变量值放入工作空间的副本中;
-
store:把工作内存中的变量值传送到主内存中,为后续的write操作使用;
-
write:把store操作从工作内存得到的变量的值放入主内存的变量中;
-
lock:把一个变量标识为线程独占状态;
-
unlock:释放线程独占的变量。
我在想,怎么样才能更好地解释这8个操作呢?这8个操作主要是为变量服务的,让变量在主内存和工作内存之间来回移动,并传递给线程引擎去执行,最终我觉得用下面的代码例子制作成动画效果来解释下这个步骤,其中执行引擎执行的代码片段为:
1public class InterMemoryInteraction {
2
3 public synchronized static void add() {
4 ClassA classA = new ClassA();
5 classA.var +=2;
6 System.out.println(classA.var);
7 }
8
9 public static void main(String[] args) {
10 add();
11 }
12}
13
14class ClassA {
15 Integer var = 10;
16}
对应的关键反汇编指令:
112: getfield #4 // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;
215: invokevirtual #5 // Method java/lang/Integer.intValue:()I
318: iconst_2
419: iadd
520: invokestatic #6 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
623: dup_x1
724: putfield #4 // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;
这8个操作执行的可以通过如下动画演示:
这动画效果,看来还是离动画大师差个十万八千里,但是梦想还是要有的。画动画不易,画动图比较花时间,动画方式阐释到此告一段落。大家要是觉得好就给我点个赞,说不定第二季很快就上映了…
在Javase8中的文档,为了方便理解,这些操作做了调整,改为了以下几种操作[4] ,其实底层的模型并没有变。
Read
:读一个变量
Write
:写一个变量同步操作:
Volatile read
:易变读一个变量
Volatile write
:易变写一个变量
Lock
:锁定独占一个变量
Unlock
:释放一个独占的变量线程的第一个或者最后一个操作
启动线程或检测到线程已终止的操作
也可以通过如下流程描述这8个指令的工作:
2.2、volatile可见性和和有序性
volatile是JVM最轻量级的同步机制。
Java中的volatile关键字用作Java编译器和Thread的指示符,它们不缓存变量的值,始终从主内存读取它,因此,如果您希望共享实例中的读写操作是原子性的,可以将他们声明为volatile变量。
2.2.1、volatile的作用
变量使用了volatile之后意味着具有两种特性:
2.2.1.1、可见性:保证变量对所有的线程可见
变量的值在线程间传递均需要通过主内存来完成:在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值。
使用了volatile之后,能够保证新值能够立即同步到主内存,以及每次使用前立即从主内存刷新。
如下图:针对volatile
变量,执行use
操作之前,总是会同时触发read
和load
操作,执行assign
操作之后,总是会同时触发store
和write
操作:
但可见性并不意味这在并发下是安全的,考虑一下代码,开启20个线程,每个线程循环10000次给一个volatile的变量+1,我们期望结果是20000:
1public class VolatileTest {
2
3 public static volatile int race = 0;
4
5 public synchronized static void increase() {
6 race ++;
7 }
8
9 private static final int THREADS_COUNT = 20;
10
11 public static void main(String[] args) {
12 Thread[] threads = new Thread[THREADS_COUNT];
13 for (int i = 0; i < THREADS_COUNT; i++) {
14 threads[i] = new Thread(() -> {
15 for (int j = 0; j < 10000; j++) {
16 increase();
17 }
18 });
19 threads[i].start();
20 }
21 // 等待所有累加线程都结束
22 while (Thread.activeCount() > 2)
23 Thread.yield();
24 System.out.println(race);
25 }
26
27}
但是我们最终发现每次执行的结果都不太一样,但总是不会达到20000。
原因是虽然volatile确保了可见性,更新之后可以对其他线程立刻可见,但是这里的+1操作并不是原子的,看反汇编代码就比较清楚了:
10: getstatic #2 // Field race:I
23: iconst_1
34: iadd
45: putstatic #2 // Field race:I
严格上来说,即使反汇编的代码只有一条指令,实际翻译为本地机器码的时候也可能会对应多条机器指令,也就是说一条指令也不一定是原子操作。
如上图,两个线程同时执行getstatic指令,都获取到了最新的r(race这里简写为r)值10。
假设线程1先执行完了所有指令,那么会把工作内存1中最终的值11写回r变量;
然后线程2也执行相同的指令,把工作内存2中最终的值11写回r变量。
可见线程1的值被线程2覆盖了。
结论
对于不依赖当前值的assign操作,并且变量不需要与其他的状态变量共同参与不变约束,volatile可以确保其原子性。
典型的应用如:不管当前开关状态是什么,我现在要打开开关,那么操作之后,打开状态可以立即对其他线程可见。
2.2.1.2、有序性:禁止指令重排
下面我们看一个经典的双重检查锁定DCL问题。
为了支持惰性初始化,并且避免同步开销,我们编写的检查锁定代码可能会像下面这样:
1public class Singleton {
2
3 private static Singleton instance;
4
5 public static Singleton getInstance() {
6 if (instance == null) {
7 synchronized (Singleton.class) {
8 if (instance == null) {
9 instance = new Singleton();
10 // 这一句代码实际上会翻译为如下三句
11 // reg0 = calloc(sizeof(Singleton));
12 // reg0.<init>();
13 // instance = reg0;
14 }
15 }
16 }
17 return instance;
18 }
19
20 /**
21 * hsdis-amd64.dylib https://cloud.tencent.com/developer/article/1082675
22 * HSDIS是一个Java官方推荐 HotSpot虚拟机JIT编译代码的反汇编插件。我们有了这个插件后,通过JVM参数-XX:+PrintAssembly就可以加载这个HSDIS插件,
23 * 然后为我们把JIT动态生成的那些本地代码还原成汇编代码,然后打印出来。
24 * @param args
25 */
26 public static void main(String[] args) {
27 // 由于指令编排问题,可能返回空对象
28 Singleton.getInstance();
29 }
30
31}
如上面注释所属,创建单例的语句instance = new Singleton();
会翻译为三个语句,而这三个语句缺少顺序限制,即使是顺序的,也可能在单个CPU内核上面并发执行,导致执行顺序不确定。
在现代x86芯片上,即使在单个内核上,多个指令也肯能并行发生,早在1993年发布的第一批奔腾处理器中,x86就能够在一个内核上同时运行多个指令。从1995年的Pentium Pro开始,x86芯片开始无序运行我们的代码。
也就是说编译器或者CPU内核都有可能对操作指令进行重排序。
最终的执行顺序有可能是这样的
1reg0 = calloc(sizeof(Singleton));
2instance = reg0;
3reg0.<init>();
这样子,另一个线程就可能拿到了这个还没有执行构造方法<init>()
的空对象。
为了避免这个不期望的情况出现,我们需要在instance变量前面添加volatile:
private static volatile Singleton instance;
添加之后我们再次执行,可以看到生成的汇编代码有一个指令包含了lock前缀:
1 0x0000000113fc76a4: movabs $0x7957d2e18,%rax ; {oop(a 'java/lang/Class' = 'com/itzhai/jvm/executeengine/concurrency/Singleton')}
2 0x0000000113fc76ae: mov 0x20(%rsp),%rsi
3 0x0000000113fc76b3: mov %rsi,%r10
4 0x0000000113fc76b6: shr $0x3,%r10
5 0x0000000113fc76ba: mov %r10d,0x68(%rax)
6 0x0000000113fc76be: shr $0x9,%rax
7 0x0000000113fc76c2: movabs $0x10d94f000,%rsi
8 0x0000000113fc76cc: movb $0x0,(%rax,%rsi,1)
9 0x0000000113fc76d0: lock addl $0x0,(%rsp) ;*putstatic instance
10 ; - com.itzhai.jvm.executeengine.concurrency.Singleton::getInstance@24 (line 37)
这个lock前缀指令之前的一条指令就是对instance的赋值操作。
内存屏障:这个lock操作相当于一个内存屏障。遇到这个lock前缀之后,会让本CPU的cache写入内存,并且让其他CPU的cache无效化,从而实现了变量的可见性
。
同时这个内存屏障能够实现有序性
:volatile变量赋值语句所在的位置相当于一个内存屏障,赋值语句前后的的指令不能跨过这道屏障。
volatile实际上是通过内存屏障实现了可见性和有序性。
2.2.2、什么时候使用volatile
2.2.2.1、如果要读写long和double变量,可以使用volatile
long和double是64位数据类型,他们的原子性与平台有关,许多平台long和double变量分两步进行写,每步32位,可能会导致数据不一致。您可以通过在Java中使用volatile修饰long和double变量来避免此类问题。
2.2.2.2、需要使用可见性的场景
某一个线程更新一个具体的值(这个值的修改不依赖原值并且不需要与其他的状态变量共同参与不变约束)之后,需要其他线程能够立刻看到。
2.2.2.3、明确变量需要用于多线程访问
volatile变量可用于通知编译器特定字段将被多个线程访问,这将阻止编译器进行任何重排序或任何类型的优化,特别是在在多线程环境中是不希望的优化。
如下例
1private boolean isActive = thread;
2public void printMessage(){
3 while(isActive){
4 System.out.println("Thread is Active");
5 }
6}
如果没有volatile修饰符,则不能保证一个线程从另一线程中看到isActive的更新值。编译器还可以自由缓存isActive的值,而不必在每次迭代中从主内存中读取它。通过将isActive设置为volatile变量,可以避免这些问题。
2.2.2.4、双重锁检查
上面已经列举类这类例子,为了确保指令执行的有序性,所有需要加上volatile关键字。
2.2.3、volatile关键字使用要点
-
仅适用于变量;
-
保证变量值总是从主内存中读取,而不是从Thread的本地缓存,也就是工作内存;
-
使用volatile关键字声明的操作不一定都是原子的,取决于编译出来的汇编指令;
-
除了long和double类型,即使不使用volatile关键字,原始类型变量读和写都具有可见性;
-
如果一个变量没有在多线程之间共享,则不需要对变量使用volatile关键字;
-
volatile变量访问永远不会有阻塞机会,因为我们只进行简单的读取和写入操作,不会保持任何锁或等待任何锁。
2.3、同步操作Synchronized
通过使用同步,可以实现任意大语句块的原子性单位,使我们能够解决volatile无法实现的read-modify-write
问题。
2.3.1、底层是怎么实现的呢?
我们可以写一个代码来看看其反汇编代码:
可以发现,synchronized块最终变为了由monitorenter
和monitorexit
包裹的反汇编指令语句块。
翻看jvm规范看看这两个指令的作用 Chapter 6. The Java Virtual Machine Instruction Set[2]:
monitorenter:操作对象是一个reference对象,每个对象都与一个监视器关联,如果有其他线程获取了这个对象的monitor,当前的线程就要等待。每个对象的监视器有一个objectref条目计数器对象,成功进入监视器之后,监视器的objectref+1,然后,该线程就成为监视器的所有者了。
同一个线程重复执行monitorenter,会重新进入监视器,并且objectref+1。
monitorexit:操作对象是一个reference对象,执行该指令,objectref-1,直到objectref=0的时候,线程退出监视器,不再是对象所有者。
2.3.2、Synchronized如何实现可见性
Synchronized确保以可运行的方式使线程在同步块之前或者期间对内存的写入对于监视同一个对象的其他线程可见。
-
执行了
monitorenter
之后,释放监视器,并且将工作内存刷新到主内存中,以便该线程进行的写入对其他线程可见; -
在进入同步块之前,我们先要先执行
monitorenter
,使得当前线程的工作内存无效化,以便从主内存中重新加载变量。
思考以下代码有何问题?
1synchronized (new Object()) {}
2.4、final
Java中使用final字段的时候,JVM保证对象的使用者仅在构造完成后才能看到该字段的最终值。
为了达到这个目的,JVM会在final对象构造函数的末尾引入冻结
操作,该操作可以防止对构造函数进行任何后续操作,或者进行指令重排。
举个例子:
1instance = new Singleton();
从宏观上看,可以认为将new分解为3个语句:
1reg0 = calloc(sizeof(Singleton));
2reg0.<init>();
3instance = reg0;
在给instance赋值前,确保<init>()
构造方法限制下,保证了instance将得到最终值。
2.4、关于非原子的double和long变量
虚拟机实现选择可以不保证64位数据类型的load,store,read,write这个操作的原子性。
但一般虚拟机实现几乎都把64位数据的读写操作作为原子操作来对待,方便编码。
2.5、旧版本Java内存模型的问题
自1997年以来,在java语言规范中定义的Java内存模型中发现了一些严重的缺陷,这些缺陷使行为混乱(如final字段会更改其值),并且破坏了编译器执行常规优化的能力。为此,引入了JSR 133提案[3],JSR 133为Java语言定义了一种新的内存模型,优化了final和volatile的语义,该模型修复了早期内存模型的缺陷。本文截止到目前以上内容均是基于JSR 133规范来阐述的。
旧的模型允许将volatile写入与非volatile读写进行重新排序,这与大多数开发人员对volatile的直接并不一致,引发了混乱。程序员对于错误的同步其程序可能会发生什么的直接通常是错的,JSR 133的目标之一是引起人们对这一事实的关注。
3、JAVA内存模型并发不得不关注的3个问题
3.1、原子性
如何保证原子性?
-
Java中我们可以认为基本数据类型(除了double和long)的访问读写是具备原子性的;
-
可以通过synchronized关键字实现更大范围的原子性保证,底层是用到了
monitorenter
和monitorexit
指令实现的,对应的操作为:lock和unlock。
3.2、可见性
Java内存模型是通过变量修改后将新值同步会内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递没接的方式来实现可见性的。
普通变量和volatile变量都是这样实现的。
volatile变量能够立即同步到主内存,每次使用前立即从主内存刷新。所以volatile保证了多线程操作的变量可见性,而普通变量不能保证这一点。
另外两个能实现可见性的关键字:
-
synchronzied:对一个变量执行unlock操作之前,必须先把此变量同步会主内存(执行store,write操作)
-
final:final字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去的话,那么其他线程就能看见final字段的值。
3.3、有序性
在线程内观察,所有操作都是有序的(语义的串行),但是在一个线程内观察另一个线程,所有操作都是无序的(指令重排序导致的)的。
实现有序性:
-
volatile关键字:禁止指令重排;
-
synchronized:基于一个变量在同一个时刻只允许一条线程对其进行lock操作实现的,表现为持有同一个锁定两个同步块只能串行执行。
4、内存模型的先行发生规则
Java内存模型语义在内存操作(读取变量,写入变量,锁定,解锁)和其他线程操作(start和join)上约定了一些执行顺序:
-
程序次序规则:同一个线程,书写在前面的操作先行发生于书写在后面的操作;
-
监控锁定规则:unlock操作先行发生于后面同一个锁的lock操作;
-
volatile变量规则:一个volatile变量的写操作先行发生于后面对这个变量的读操作;
-
线程启动规则:Thread的start()方法先行发生于此线程每一个动作;
-
线程终止规则:线程所有操作都先行发生于对此线程的终止检测;
-
对象终结规则:对象从构造函数先行发生于它的finalize()方法;
-
传递性:如果A操作先行发生于B,B操作先行发生于C,那么A操作先行发生于C;
根据以上规则可言判断程序是否线程安全的。
5、结语
好了,本篇文章就介绍到这里了,相信你对上面的撕逼问题的答案已经有了比较深刻的认识。下次别人跟你讨论Java内存模型,就可以把这些问题抛给他了。
本文为arthinking基于相关技术资料和官方文档撰写而成,确保内容的准确性,如果你发现了有何错漏之处,烦请高抬贵手帮忙指正,万分感激。
大家可以关注我的博客:itzhai.com 获取更多文章,我将持续更新后端相关技术,涉及JVM、Java基础、架构设计、网络编程、数据结构、数据库、算法、并发编程、分布式系统等相关内容。
References
[1]: Threads and Locks
https://docs.oracle.com/javase/specs/jvms/se6/html/Threads.doc.html
[2]: Chapter 6. The Java Virtual Machine Instruction Set
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter
[3]: JSR 133: Java**TM **Memory Model and Thread Specification Revision
https://www.jcp.org/en/jsr/detail?id=133
[4]: Java Language Specification#17.4.2. Actions
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.2
《深入理解Java虚拟机-JVM高级特性与最佳实践》
x86 and amd64 instruction reference
https://www.felixcloutier.com/x86/
Java Language Specification#17.4. Memory Model
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4
The Java Memory Model
http://www.cs.umd.edu/~pugh/java/memoryModel/
JSR 133 (Java Memory Model) FAQ
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
How Volatile in Java works? Example of volatile keyword in Java
https://javarevisited.blogspot.com/2011/06/volatile-keyword-java-example-tutorial.html
JSR 133: Java Memory Model and Thread Specification Revision
https://www.jcp.org/en/jsr/detail?id=133