3.5万字总结Java内存模型JMM与Volatile底层原理

本文来源于个人对Java内存模型的理解和书籍、网络博客相关讲解的总结!

一 基础

1.1 并发编程模型的分类

在进行并发编程时,我们需要关注下面两个核心问题:

  1. 线程通信;
  2. 线程同步

线程通信是指线程与线程之间使用什么方式来交换信息,
通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
**Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。**如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

1.2 Java 内存模型

众所周知,Java 语言使用 JVM 虚拟机屏蔽了平台差异,避免像 C/C++ 那样直接与操作系统接触,从而做到了无视平台,一次编译到处运行!为此,Java 程序想要运行,必须跑在 JVM 之上,JVM 示意图如下:

JVM运行时数据区
JVM在运行期间,会把自己管理的内存划分为以上区域(运行时数据区),每个区域有着各自的用途,在Java程序运行时会发挥着不同的作用。而JVM运行时数据区总共可划分为线程私有区、线程共享区两大块,下面来简单说说各大区域具体作用。

1.2.1 方法区(Method Area)

方法区(在Java8之后被更改为元数据空间),该区域属于线程共享区,又称Non-Heap(非堆)内存,主要用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码缓存等数据。值得注意的是:在方法区中有一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用来存放编译器生成的各种字面量和符号引用,这些内容会在类加载完成后载入到运行时常量池中,以便后续使用。
该区域有可能导致OOM异常,根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.2.2 JVM堆(Java Heap)

Java堆也属于线程共享的内存区域,它在虚拟机启动时创建,是JVM所管理的内存中,最大的一块,主要用来存放对象实例,几乎new的所有对象都在这里分配内存(注意这里用的是“几乎”,有些对象不一定在堆中)。
堆空间是垃圾收集器管理的主要区域,因此很多时候也被称做GC堆,如果在堆中没有空闲内存提供给新对象分配时,此时就会触发GC回收;如若经过GC后依旧没有空闲内存,并且堆空间也无法再扩展时,将会抛出OutOfMemoryError异常。

1.2.3 程序计数器(Program Counter Register)

程序计数器属于线程私有区域,是一小块内存空间,主要作为线程所执行的行号指示器。JVM字节码解释器工作时,通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成。
主要作用其实就是因为CPU时间片在调度线程工作时,会“中断/挂起”某个线程的操作,让另外一个线程开始工作,当“中断”的线程重新被CPU再次调度时,如何得知上次执行到那行代码了?就是靠程序计数器来得知上次执行的位置。

1.2.4 虚拟机栈(Java Virtual Machine Stacks)

虚拟机栈也属于线程私有区,它有着另一个名字叫:线程栈,操作系统在创建线程时分配,虚拟机栈的总数与线程数对应,主要在执行Java方法时,作为临时内存区域使用。
当线程开始执行一个方法时,会先创建一个栈桢来存储方法的的变量表、操作数栈、动态链接、返回值、返回地址等信息。每个方法从调用至结束,对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下:

虚拟机栈

1.2.5 本地方法栈(Native Method Stacks)

本地方法栈属于线程私有的数据区域,该区域跟C所编写的Native方法相关,JVM会在本地方法栈中,维护一张本地方法登记表,当有线程需调用Native方法时,这里会登记是哪个线程调用了哪个本地方法/接口,并不会在本地方法栈中直接发生调用,这里只是做个调用登记,而真正的调用,需要通过本地方法接口去调用本地方法库中C编写的函数。
一般情况下,我们无需关心此区域,因为在HotSpot虚拟机中,和虚拟机栈已经合二为一了。
之所以说这些的内容,是为了让大家搞清JVM内存模型,和JMM内存模型是完全两个不同的概念。JVM内存模型是Java程序在运行期间的数据区域,对于操作系统来说,它本质还是存在于主内存之中。
JMM则是Java语言与OS硬件架构层面的概念,主要作用是规定硬件架构与Java语言的内存模型,JMM并不存在具体的代码,而仅仅只是一种规范,并不能说是某些技术实现。

1.3 Java 内存模型的抽象

在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。**本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。**Java 内存模型的抽象示意图如下:
image.png
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  • 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  • 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:
image.png
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

1.3.1 JMM概述

Java Memory Model(简称JMM)Java内存模型,本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
由于JVM运行程序的实体是线程,每个线程创建时,JVM都会为其分配工作内存,用于存储线程私有的数据。而Java内存模型中,规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。当线程想对一个变量进行赋值/运算等操作时,必须在工作内存中进行。
为此,当线程想操作变量时,首先要将变量从主内存拷贝的自己的工作内存,然后对变量进行操作,操作完成后,再将变更后的值刷写回主内存。也就是说:线程不能直接操作主内存中的变量,为了避免造成数据污染问题,必须将主内存中的变量,拷贝到工作内存中。
有些小伙伴会疑惑:Java中线程在操作一个对象时,对象不应该是在堆中吗?栈内不是只能存对象的引用地址吗?这时线程是直接在堆上操作的吗?
这里简单说一下,当线程操作一个对象时,会先根据引用地址去找到主存中的真实对象,然后会将对象拷贝到自己的工作内存再操作……(因为任何一个对象,都是由基本数据组成的)。如果当操作的对象较大时,比如一个1MB+的对象,这时并不会完全拷贝,而是将自己需要操作的那部分成员拷贝回来。
前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间,无法访问对方的工作内存,线程间的通信(传值)必须要靠主内存来完成,其简要访问过程如下图:

JMM
重点注意:JMM与JVM内存区域,这是两个不同层次的概念,在理解JMM时,请不要带着JVM内存模型的思维去理解。更恰当的说:JMM描述的是一组规则,通过这组规则控制Java程序中,各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性拓展延伸的。
JMM与JVM内存区域唯一相似点就是:都存在共享数据区域和私有数据区域。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,这个说法虽不是那么专业,可是要表达的含义大致相同,下面来具体说说JMM中的主内存和工作内存。

1.3.2 主内存

在JMM中,主内存属于线程共享区,从某个程度上讲,主存应该包括了堆和方法区。
主要存储的是共享数据,不管是类的成员变量、还是方法中的局部变量,又或者共享的类信息、常量、静态变量等数据,包括所有线程创建的实例对象,都会被存放在主内存中(除开栈上分配的对象)。
由于属于共享数据区域,多条线程对同一个数据进行非原子性操作时,就会发生线程安全问题。

1.3.3 工作内存

工作内存则属于线程私有区,从某个程度上讲,应该包括程序计数器、虚拟机栈以及本地方法栈。
主要存储当前方法的所有本地变量信息,每个线程的工作内存对其它线程不可见,比如T1的工作内存中,存储着主内存中拷贝回来的某个共享变量副本,这对于T2线程也是不可见的。
就算是两个线程执行的是同一段代码、同一个方法,它们也只会在各自的工作内存中,创建属于当前线程的本地变量、字节码行号指示器、相关Native方法等信息,而不会两者之间共用一块内存的数据。
注意:由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,线程之间的通讯需要依赖于主存,因此存储在工作内存的数据不存在线程安全问题。

1.3.4 工作内存与主内存的关系

弄清楚主内存和工作内存后,接着了解一下两者的数据存储类型及操作方式。
根据虚拟机规范,对于一个实例对象的成员方法而言,如果方法中包含的本地变量(局部变量),是boolean、byte、short、char、int、long、float、double八大基本数据类型,这将直接存储在工作内存的帧栈结构的局部变量表中。
倘若本地变量是引用类型,那么该对象的在内存中的具体引用地址,将会被存储在工作内存的帧栈结构的局部变量表中;而具体的实例对象,将存储在主内存(共享数据区域:堆)中。但对于实例对象的成员字段,不管是基本数据类型,还是Integer、Double等包装类型,又或者是引用类型,都会被存储到堆区(栈上分配除外)。
至于static变量以及类本身相关信息将会存储在主内存中。
需要注意的是:在主内存中的实例对象可以被多条线程共享,倘若两条线程同时调用同一个类的、同一个方法,那这两条线程需要将操作的数据,拷贝一份到自己的工作内存中,在工作内存运算完成后,才刷新到主内存,简单示意图如下所示:

代码


运算示例
方法内的局部变量操作:

代码


运算示例

二、硬件内存架构、OS与Java多线程实现原理及JMM

2.1 计算机硬件内存架构


硬件内存架构
如上图所示,这是一副CPU与内存操作的简易图,实际没有这么简单,为了理解方便,我们省去了南北桥。
就目前计算机而言,一般拥有多个CPU,并且每个CPU可能存在多个核心,多核是指在一枚处理器中,集成两个或多个完整的计算引擎(核心),这样就可以支持多任务并行执行。从多线程的调度来说,每个线程都会映射到各个CPU核心上并行执行。
在CPU内部有一组CPU寄存器,寄存器存储CPU直接访问和处理的数据,也就是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时,往往很多时间花费在等待内存准备数据上。
为了解决上述问题,于是在寄存器和主内存间,添加了CPU高速缓存,该缓存区域空间比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中同一地址的数据,很容易影响CPU执行速度,此时高速缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。
需要注意的是:寄存器并不每次数据都可以从缓存中取得,只要不是同一个内存地址的数据,那寄存器就得绕过缓存从内存取数据,这种现象有个专业的名称叫:缓存的命中率。从缓存中取算命中,从内存中取算没命中,由此可见,缓存命中率的高低也会影响CPU执行性能。
上述就是CPU、缓存以及主内存,三者之间的简要交互过程,总而言之,当CPU需要访问主存数据时,会先读取一部分主存数据到CPU高速缓存(后续CPU缓存中,存在需要的数据就会直接从缓存获取),进而在读取缓存数据到寄存器。当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
实则就类似于Java–>Redis–>MySQL的关系,Java程序性能由于MySQL要走磁盘受到了影响,导致Java程序在处理请求时,每次都会因为MySQL过慢而陷入阻塞等待状态,必须等到MySQL的结果返回后才能继续工作,那么此时这种模式的问题是:
数据库的读写速度,跟不上Java应用程序的性能,导致整个请求处理起来变的很慢,但实际上在DB处理读写操作的过程,Java线程需要阻塞等待,这样会导致整体系统的吞吐量下降,此时我们可以加入Redis缓存来提升响应速度,从而提升系统整体的吞吐和性能。
上述的主存就类似于MySQL,CPU高速缓存就类似于Redis,而CPU寄存器就类似于Java程序。

2.2 OS与JVM线程关系及Java线程实现原理

经过上面的阐述,我们大致了解了JVM内存模型、Java内存模型、硬件内存架构,下面来聊聊Java中线程的实现原理,这有助于我们理解Java内存模型与硬件内存架构的关系。
在Java中,线程是基于一对一模型实现的,所谓的一对一模型,就是在语言级别的层面,去间接调用系统内核的线程模型,如我们在使用Java线程时:

new  Thread(Runnable).start();

JVM最终会调用当前操作系统的内核线程,来执行当前的Runnable任务。
这里需了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程由操作系统内核进行控制,内核通过操作调度器完成对线程的执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多个任务的原因。
可是由于我们编写的多线程程序,是属于语言层面的多线程,一般无法直接调用/创建内核线程,取而代之的是一种轻量级的进程(Light Weight Process),这也是通常意义上的线程。由于每个轻量级进程都会映射一个内核线程,因此我们可以通过轻量级进程来调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间一对一的关系,就称为Java线程与OS内核线程的一对一模型。如下图:

一对一模型
Java中的每个线程都会经过OS内核,进而被映射到CPU中处理。当然,如果CPU存在多核,那么一个CPU同时也能并行调度执行多个线程。

2.3 JMM与硬件内存架构的关系

通过前面是分析后,我们可以发现,Java线程的执行,最终都会映射到硬件处理器上执行,但Java内存模型和硬件内存架构并不完全一致。
对于硬件内存来说只有寄存器、高速缓存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(线程共享数据区域)之分。也就是说JMM内存划分,无法对硬件的内存产生任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说,都有可能存储在计算机主内存、CPU缓存或者寄存器中。
总的来说,Java内存模型和硬件内存架构是相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉(注意:对于JVM内存区域划分也是同样的道理)。

相互交叉

2.4 为什么需要有JMM的存在?

接着来谈谈Java内存模型存在的必要性,因为我们去学习某个知识的话要做到知其然知其所以然。
由于线程是OS的最小调度单位,所有程序运行时的实体,本质上都是一条条线程,运行在OS上的Java程序也不例外。而每个线程创建时,JVM都会为其创建一个工作内存(可以理解成虚拟机栈),用于存储线程私有的数据,线程如果想要操作主存中的某个变量,必须通过工作内存间接完成。
主要过程是将变量从主内存拷贝到线程自己的工作内存空间,然后先在工作内存中对变量进行操作,操作完成后再将变量刷写回主内存,如果存在两个线程同时对主内存中的、同一个实例对象/变量进行操作,这就有可能诱发线程安全问题。如下图,主内存中存在一个共享变量int i = 0:

示例

2.4.1 第一种情况(左图)

现有A、B两条线程,分别对变量i进行操作,A、B线程各自的都会先将主存中的i,拷贝一份副本到自己的工作内存,然后再在工作内存中对i进行自增操作。
那么假设此时A、B线程,同时将主存中i=0这个数据,拷贝到自己的工作内存进行操作。在这个过程中,其实A在自己工作内存中对i进行自增操作,这对B工作内存的副本i是不可见的,当A完成自增操作后,会将结果i=1刷写回主存。
此时B也做了i++操作,也是先基于主存中拷贝i=0这个值,接着在自己的工作内存完成i++自增操作,所以B刷写回主存的值也是1。
可是诸位仔细分析一下,此刻两条线程都对主存中的i进行了自增操作,理想结果应该是i=2,但是现在的结果却是i=1。现在这个现象,就可以被称为“线程安全问题、数据污染问题、数据不一致问题……”。

2.4.2 第二种情况(右图)

假设现在A线程想要把i的值修改为2,而B线程却想要读取i的值,那么B线程读取到的值,是A线程更新后的i=2这个值,还是更新前的i=1这个值呢?答案是不确定,即B线程有可能读取到A线程更新前的1,也有可能读取到A线程更新后的2。
这是因为工作内存属于每个线程的私有数据区域,而线程A修改变量i时,首先是将变量从主内存拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量i写回主内存。对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题。
假如A线程修改完后,正在将数据写回主内存,而B线程此时正在读取主内存的i,也就是将i=1拷贝到自己的工作内存中,这样B线程读取到的值就是i=1。但如果A线程已将i=2写回主内存后,B线程才开始读取的话,那么此时B线程读到的就是2,但到底是哪种情况先发生呢?这是不确定的。
所以如上两种情况,对于程序来说是不应该的,假设把这个变量i换成淘宝双十一的商品库存数,A、B线程换成参加双十一的用户,这时就会导致超卖、重复卖等问题的出现,这会由于技术问题造成业务经济受损,尤其是是在类似于淘宝双十一此类的大促活动中,此类问题如果不控制好,出现问题的风险会成倍增长,其实这也就是所谓的线程安全问题。
为了解决上述两类问题,JVM定义了一组规则,通过这组规则来决定:一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(JMM),JMM整体是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。

2.5 Java内存模型JMM围绕的三大特性

2.5.1 原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
比如对于一个静态变量int i = 0,两条线程同时对它赋值,线程A操作为i=1,而线程B操作为i=2,不管线程如何运行,最终 i的值要么是1,要么是2,A、B线程之间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。
有点要注意的是:对于32位系统的来说,byte、short、int、float、boolean、char等基本数据类型的读写是原子操作,而long、double类型的数据,它们的读写并非原子性的!
也就是说:如果存在两条线程同时对long或double类型的数据进行读写操作时,相互之间会存在干扰。因为对于32位虚拟机来说,每次原子读写是32bit,而long、double是64bit的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值、又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。
不过也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前商用的虚拟机中,几乎都把64位数据的读写操作,作为原子操作来执行,因此对于这个问题不必太在意,知道有这么回事即可。
其实原子性操作的本质,是指一组操作要么全部执行成功,要么就全部失败。举个例子:
下单:{增加订单,减库存}
对于用户来说,下单是一组操作,那么系统就必须保证下单操作的原子性,“增加订单、减库存”要么全部成功,要么全部失败,不能出现“增加订单成功、减库存失败”这种现象。这个例子从宏观上来讲,就是一个原子性操作的体现。
非原子性操作反之,如果一组操作可以被打断,这说明就是一组非原子性操作,如“增加订单之后,减库存的操作可以被打断”(线程安全问题产生的根本原因,就是由于多线程对一个共享资源进行非原子性操作导致的)。
但是有个点,在我们研究Java并发编程以及在研究可见性时需要注意,就是计算机在执行程序时,会通过“指令重排”优化它的操作。计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令进行重排序,一般分以下3种。
①编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
②指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句,无需依赖前面语句的执行结果),处理器可以改变语句对应机器指令的执行顺序。
③内存系统的重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储store操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
其中编译器优化的重排序,属于编译期重排;指令并行的重排、内存系统的重排属于处理器重排。在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。

2.5.1.1 重排序

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
image.png
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

2.5.1.2 编译器优化指令重排
// 主存的共享变量
int a = 0;
int b = 0;

//线程A                   线程B
代码1:int x = a;         代码3:int y = b;
代码2:b = 1;             代码4:a = 2;

此时有四行代码1、2、3、4,其中1、2属于线程A,3、4属于线程B,两个线程同时执行,从程序的执行上来看,由于并行执行的原因,按理应该是x=0、y=0;这个结果,理论上不会出现x=2、y=1;这种结果,但是实际上这种情况是有概率出现的,因为编译器会对一些前后不依赖/影响、耦合度为0的代码,进行指令重排优化,假设此时编译器对这段代码进行指令重排优化后,出现的情况如下:

//线程A                   线程B
代码2:b = 1;         代码4:a = 2;
代码1:int x = a;     代码3:int y = b;

这种情况下再结合之前的线程安全问题一起理解,那就有可能出现x=2、y=1;这种结果,这也就说明在多线程环境下,由于编译器会对代码做指令重排的优化工作(因为一般代码都是由上往下执行,指令重排是OS对单线程代码的优化),最终导致在多线程环境下,多个线程使用变量时无法保证一致性。
不过值得注意的是:编译器重排的前提,是代码不存在依赖性时才会发生。而依赖性可分为两种,一种是数据依赖,如:

int a = 1;
int b = a;

上述代码中b依赖a的数据。另一种是控制依赖,如:

boolean f = ture;
if(f){
    System.out.println("123");
})

上述if判断这个流程控制,依赖于变量f的值。像这类前后存在依赖关系的代码,就无法进行重排序。

2.5.1.3 处理器指令重排

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:
取指:IF
译码和取寄存器操作数:ID
执行或者有效地址计算:EX
存储器访问:MEM
写回:WB
CPU在工作时,需要将一个指令分为上述多个步骤依次执行(不同硬件有可能不一样),由于每一步会使用不同的硬件完成操作。比如取值时,会用到PC寄存器和存储器;译码时会用到指令寄存器组;执行时会用到ALU(算术逻辑单元)、写回时用到寄存器组。为了提高硬件利用率,CPU执行指令是按流水线技术来工作的,如下:

CPU流水线指令
流水线技术:类似于工厂中的生产流水线,工人们各司其职,做完自己的就往后面传,然后开始一个新的,做完了再往后面传递…。CPU执行指令亦是如此,如果等到一条指令执行完毕后,再开始执行下一条指令,效率无疑特别低。就好比工厂的生产流水线,先等到一个产品生产完毕之后,再开始生产下一个,效率非常低下并且浪费人工,如果真这样干,一条流水线同时就只会有一个工人在做事,其他的看着,只有当这个产品走到最后一个人手上,并且最后一个人完成了组装之后,第一个工人再开始第二个产品的制作。
从图中可以看出,当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行。这样做是有好处的,如果每个步骤花费1ms,如果第2条指令,需要等待第1条指令执行完后再执行的话,则需要等待5ms,但如果使用流水线技术,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。
虽然流水线技术可以大大提升CPU的性能,但不幸的是:一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装工序中断,那么该工序往后的工人,都有可能进入一轮或者几轮等待组装零件的过程。
因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的,如下:

i = a + b;
y = c - d;


指令流水线

指令描述
LW R1,aLW指令表示load,其中LW R1,a表示把a的值加载到寄存器R1中
LW R2,b表示把b的值加载到寄存器R2中
R3,R1,R2ADD指令表示加法,把R1 、R2的值相加,并把结果存入R3寄存器中
SW i,R3SW表示store,即将R3寄存器的值保持到变量i中
LW R4,c表示把c的值加载到寄存器R4中
LW R5,d表示把d的值加载到寄存器R5中
SUB R6,R4,R5SUB指令表示减法,把R4、R5的值相减,并存入R6寄存器中
SW y,R6表示将R6寄存器的值保持到变量y中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是图中只要有X的地方,就会导致指令流水线出现停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?
这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,因此就产生了停顿,其他指令也是类似的情况。
前面讲过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,c和LW R5,d移动到前面执行,毕竟LW R4,c和LW R5,d的执行,并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令,在R4,R5加载完成后才执行的,没有影响,过程如下:

重排前

重排后
正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。
关于编译器重排以及指令重排(这两种情况后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到:对于单线程而言,指令重排几乎不会带来任何影响,毕竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而言,指令重排就可能导致严重的程序乱序执行问题,如下:

int a = 0;
boolean f = false;

public void methodA(){
    a = 1;
    f = true;
}
public void methodB(){
    if(f){
        int i = a + 1;
    }
}

如上述代码,线程A、B同时对该实例对象进行操作,其中A线程调用methodA方法,而B线程调用methodB方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

线程A                      线程B
 methodA:                methodB:
 代码1:f= true;           代码1:f= true;
 代码2:a = 1;             代码2: a = 0 ; //读取到了未更新的a
                          代码3: i =  a + 1;

由于指令重排的原因,线程A的f置为true被提前执行了,接着线程A正准备执行a=1赋值操作。可此时因为f=true了,所以线程B正好读取f的值为true,就会进入if分支,直接获取a的值。
而此时线程A还在自己的工作内存中,对拷贝过来的变量副本a(0)进行赋值操作,这时还未刷写到主存。此时线程B读取a变量,读到的a值还是为0,那么拷贝到线程B工作内存的a变量值会等于0,然后B线程在工作内存中执行i=a+1操作,因为处理器指令重排的原因,线程B读到a为0,导致最终i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。
因此,请记住,指令重排只会保证单线程中串行语义的执行一致性。单线程环境时,能够通过指令重排优化程序性能、消除CPU停顿;但是这种重排序,并不会关心多线程间的语义一致性。

2.5.2 可见性

经过前面的阐述,如果真正理解了指令重排现象之后,各位小伙伴再来理解可见性就很容易了,可见性指的是:当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值
对于串行程序来说,可见性是不存在的,因为在任何一步操作中修改了某个变量的值,后续的操作都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作,都是拷贝到各自的工作内存运算的,运算完成后才刷回主内存中。
这就可能存在一个线程A修改了共享变量i的值,还未写回主内存时,另外一个线程B又对主内存中的i进行操作,此时A线程工作内存中i对线程B不可见,这种工作内存与主内存之间同步延迟的现象,就造成了可见性问题。
另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道:无论是编译器优化还是处理器优化的重排现象,在多线程环境下,都会会导致程序乱序执行的情况出现,从而也就导致可见性问题。

2.5.3 有序性

有序性是指:对于单线程执行的代码,我们总是认为代码是按顺序依次执行的,这样的理解如果是放在单线程环境下没有问题,毕竟对于单线程而言确实如此,执行会根据编码的顺序从上往下执行,就算发生指令重排序,由于所有硬件优化的前提都是必须遵守as-if-serial语义,所以不管怎么排序,都不会、不能影响单线程程序的执行结果,我们将这称之为有序执行。
反之,对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后,可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。要明白的是:在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的。前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

2.6 JMM是如何解决上述问题的?

真正理解上述所有内容后,再来看Java为我们提供的解决方案,如原子性问题,除了JVM自身保障了读写基本数据类型的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字,或者Lock锁接口的实现类来保证程序执行的原子性,关于synchronized的详解(能保证三特性不能禁止指令重排),下章我们会讲到。
而工作内存与主内存同步延迟现象导致的可见性问题,可以使用加锁或者Volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。
除了靠sychronized和volatile关键字(volatile关键字不能保证原子性,只能保证的是禁止指令重排与可见性问题)来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

2.7 JMM中的happens-before原则

2.7.1 线程在执行的过程中与内存的交互

在了解JMM的happens-before原则之前,必须先对线程执行过程中,与内存的交互操作要有个简单的认知。Java程序在执行的过程中,实际就是OS在调度JVM的“线程”执行,执行的过程就是与内存的交互操作,而内存交互操作有8种(虚拟机实现必须保证每一个操作都是原子的,不可在分的,对于double、long类型的变量来说,load、store、read和write操作在某些平台上允许例外):

  • • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;
  • • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
  • • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中;
  • • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;
  • • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;
  • • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用;
  • • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM对这八种指令的使用,制定了如下规则:

  • • ①不允许read和load、store和write操作之一单独出现。即:使用了read必须load,使用了store必须write;
  • • ②不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存;
  • • ③不允许一个线程将没有assign的数据从工作内存同步回主内存;
  • • ④一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作;
  • • ⑤一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;
  • • ⑥如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值;
  • • ⑦如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;
  • • ⑧对一个变量进行unlock操作之前,必须把此变量同步回主内存;

JMM对这八种操作规则和对volatile的一些特殊规则,就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析,所以一般我们也不会通过上述规则进行分析。更多的时候,会使用JMM中的happens-before规则来进行分析。

2.7.2 JMM中的happens-before原则

假如在多线程开发过程中,我们需要通过加锁或volatile来解决这些问题的话,那么编写程序的时候会非常麻烦,而且加锁本质上是让多线程的并行执行变为了串行执行,这样会大大的影响程序的性能,那么其实真的需要嘛?不需要,因为在JMM中还为我们提供happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before原则内容如下。

  • 一、程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行;
  • 二、锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁);
  • 三、volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性。简单的理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;
  • 四、线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A,在执行线程B的start方法前修改了共享变量的值,那么当线程B执行start方法时,线程A变更过的共享变量,对线程B可见;
  • 五、传递性优先级规则:A先于B,B先于C,那么A必然先于C;
  • 六、线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见;
  • 七、线程中断规则:对线程interrupt()方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断;
  • 八、对象终结规则:对象的构造函数执行,结束先于finalize()方法。

happens-before原则无需添加任何手段来保证,这是由JMM规定的,Java程序默认遵守如上八条原则,下面我们再通过之前的案例,重新认识这八条原则是如何判断线程是否会出现安全问题:

int a = 0;
boolean f = false;
public void methodA(){
    a = 1;
    f = true;
}
public void methodB(){
    if(f){
        int i = a + 1;
    }
}

同样的道理,目前存在A、B两条线程,线程A调用实例对象的methodA()方法,线程B调用methodB()方法,线程A先启动、线程B后启动,那么线程B读取到的i值是多少呢?
现在依据happens-before的八条原则进行判断:

  • • 由于存在两条线程同时调用,因此程序顺序原则不合适。
  • • methodA()方法和methodB()方法都没有使用同步手段,锁规则也不合适。
  • • 没有使用volatile关键字,volatile变量原则不适应。
  • • 线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性在本次案例也不合适。
  • • 线程A、B的启动时间虽然有先后,但线程B执行结果却不确定。

也就是说,上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。
修复这个问题的方式很简单,要么给methodA()方法和methodB()方法添加同步手段(加锁),或者给共享变量添加volatile关键字修饰,保证该变量在被一个线程修改后总对其他线程可见。

三 处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:

// Processor A
a = 1; //A1  
x = b; //A2

// Processor B
b = 2; //B1  
y = a; //B2

// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:
image.png
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。
下面是常见处理器允许的重排序类型的列表:

Load-LoadLoad-StoreStore-StoreStore-Load数据依赖
sparc-TSONNNYN
x86NNNYN
ia64YYYYN
PowerPCYYYYN

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。

  • ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
  • ※注 2:上表中的 x86 包括 x64 及 AMD64。
  • ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
  • ※注 4:数据依赖性后文会专门说明。

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

3.1happens-before

从 JDK5 开始,java 使用新的 JSR -133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如下图所示:
image.png
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

3.2 重排序

3.3 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称代码示例说明
写后读a = 1;b = a;写一个变量之后,再读这个位置。
写后写a = 1;a = 2;写一个变量之后,再写这个变量。
读后写a = b;b = 1;读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

3.4 as-if-serial 语义

as-if-serial 语义的意思指:**不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。**编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:
image.png
如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:
image.png
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

3.5 程序顺序规则

根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:

  • A happens- before B;
  • B happens- before C;
  • A happens- before C;

这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。
这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。

3.6 重排序对多线程的影响

现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;             //2
    }

    Public void reader() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:
image.png
如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:
image.png
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3.3 顺序一致性

3.3.1 数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量,
  • 在另一个线程读同一个变量,
  • 而且写和读没有通过同步来排序。

多个线程对共享变量执行非原子性操作。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)-- 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
3.3.2 顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。 +(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:

image.png
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。
为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
假设这两个线程使用监视器来正确同步:A 线程的三个操作执行后释放监视器,随后 B 线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
image.png
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
image.png
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

3.3.3 同步程序的顺序一致性效果

下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。
请看下面的示例代码:

class SynchronizedExample {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() {
        a = 1;
        flag = true;
    }

    public synchronized void reader() {
        if (flag) {
            int i = a;
            ……
        }
    }
}

上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:
image.png
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

3.3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:**线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。**为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
  • JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:
image.png
如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。
当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:
image.png
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的无效值。

四 JMM总结

4.1 处理器内存模型

**顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。**JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  • 放松程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
  • 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
  • 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。

注意,这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。
下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称对应的处理器Store-Load 重排序Store-Store 重排序Load-Load 和 Load-Store 重排序可以更早读取到其它处理器的写可以更早读取到当前处理器的写
TSOsparc-TSO X64YY
PSOsparc-PSOYYY
RMOia64YYYY
PowerPCPowerPCYYYYY

在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在第一章以说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。
上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:
image.png
如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。

4.1 JMM,处理器内存模型与顺序一致性内存模型之间的关系

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:
image.png
从上图我们可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。

4.2 JMM 的设计

从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。下面让我们看看 JSR-133 是如何实现这一目标的。
为了具体说明,请看前面提到过的计算圆面积的示例代码:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在三个 happens- before 关系:

  • A happens- before B;
  • B happens- before C;
  • A happens- before C;

由于 A happens- before B,happens- before 的定义会要求:A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

下面是 JMM 的设计示意图:
image.png
从上图可以看出两点:

  • JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
  • JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

4.3 JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:
image.png
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

4.4 JSR-133 对旧内存模型的修补

JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:

  • 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
  • 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。

五 Volatile关键字

5.1 Volatile关键字保证的可见性

Volatile 是 Java 提供的轻量级同步工具,它能保证可见性和做到禁止指令重排做到有序性,但是它不能保证原子性,如果你的程序必须保障原子性的话,那么可以考虑使用 JUC 原子包下的原子类(后续篇章会讲到),或者靠加锁的方式来保证。
但是我们假设使用 volatile 来修饰共享变量,那它能够保证的是:一个线程对 volatile 所修饰的变量进行更改操作后,总是能对其他线程可见,如下:

volatile int i = 0;
public void add(){
      i++;
}

对于上述代码,任何线程调用add()方法对i进行i++操作后,对其他线程都是可见的,但这段代码不存在线程安全问题吗?存在,为什么?因为i++并不是原子性操作,i++实际上是三个操作的组成:
从主存读取值、工作内存中+1操作、将运算结果刷写回主存。
任何一条线程在执行任何一步时,都有可能被其他线程打断,所以还是会出现线程安全问题(具体参考之前描述线程安全问题第一种情况)。
为此,我们要清楚,此时如果有多条线程调用add()方法,依旧会出现线程安全问题,如果想要解决这个问题,还是需要使用synchronized、lock或者原子类来保证,volatile关键字只能禁止指令重排以及可见性。
现在再来看一个案例,此类场景可以使用volatile关键字修饰变量,从而达到线程安全的目的,如下:

volatile boolean flag;

public void toTrue(){
      flag = true;
}

public void methodA(){
      while(!flag){
          System.out.println("我是false....false.....false.......");
      }
}

由于对boolean变量flag值的修改,属于原子性操作,因此可以通过使用volatile修饰flag变量,使用该变量对其他线程立即可见,从而达到线程安全的目的。
那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程工作内存中的共享变量值刷新到主内存中;当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程只能从主内存中重新读取共享变量。
volatile变量正是通过这种写-读方式实现对其他线程可见(其内存语义实现则是通过内存屏障,稍后会说明)。

5.2 Volatile如何禁止指令重排序的?

volatile关键字另一个作用,就是禁止编译器或者处理器对指令进行重排优化,从而避免多线程环境下程序出现乱序执行的现象,那volatile是如何做到禁止指令重排优化的呢?先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条MemoryBarrier,则会告诉编译器和CPU,不管什么指令都不能和这条MemoryBarrier指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用:强制刷出各种CPU缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2;确保Load1指令数据的装载,发生于Load2及后续所有装载指令的数据装载之前。
StoreStore BarriersStore1; StoreStore; Store2;确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Store2及后续所有存储指令的数据写入之前。
LoadStore BarriersLoad1; LoadStore; Store2;确保Load1指令数据的装载,发生于Store2及后续所有存储指令的数据写入之前。
StoreLoad BarriersStore1; StoreLoad; Load2;确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Load2及后续所有装载指令的数据装载之前。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令。

Java编译器在生成指令序列的适当位置,会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。
JMM把内存屏障指令分为4类,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。案例如下:

public class Singleton{
  private static Singleton singleton;
  
  private Singleton(){}
  
  public static Singleton getInstance(){
     if(singleton == null){
          synchronized(Singleton.class){
                if(singleton == null){
                      singleton = new Singleton();
               }
          }
      }
  }
}

上述代码是一个经典双重检测的单例模式,这段代码在单线程环境下并没有什么问题,可是如果在多线程环境下,就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的singleton不为null时,singleton的引用对象可能没有完成初始化。
因为singleton = new Singleton();可以分为以下3步完成(伪代码):

// 1.分配对象内存空间
memory = allocate();

// 2.初始化对象
singleton(memory); 

// 3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory;

由于步骤1和步骤2间可能会重排序,如下:

// 1.分配对象内存空间
memory = allocate(); 

// 3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory; 

// 2.初始化对象
singleton(memory);

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
但是指令重排只会保证串行语义的执行的一致性(单线程),并不会关心多线程间的语义一致性。所以当一条线程访问singleton不为null时,由于singleton实例未必已初始化完成,也就造成了线程安全问题。
那么该如何解决呢,很简单,我们使用volatile禁止new singleton()时发生指令重排即可:

private volatile static Singleton singleton;

下面简单说明一下双重锁单例的流程:

双重锁单例解释

5.3 volatile关键字的真正实现

private volatile static Singleton singleton;

在这个双重锁单例的例子中,为singleton变量加上volatile后,会禁止new这个操作,被其他线程打断,而我们提到过:volatile具备禁止指令重排序的作用,但是这种禁止重排序,并不是禁止了所有指令重排,下面展开聊聊。

A操作
B操作
C操作
D操作
E操作

现在这里有五个指令操作,假设它们之间不存在依赖性,因此可以被任意重排序,可以是ACDEB、ABEDC……,现在对B、C、D操作加上内存屏障:

A操作

内存屏障
B操作
C操作
D操作
内存屏障

E操作

这会禁止A、B、C、D、E五个操作重排序嘛?其实并不会,依旧会发生重排,比如重排成E、(D、B、C)、A,但由于我对B、C、D加了内存屏障,尽管可以重排序,但(B、C、D)三个操作变成了一个整体,内部就算重排,也不会有任何影响。
这相当于把(B、C、D)当成了一个指令(原子操作),不可以被其他线程打断,所以volatile可不可以禁止指令重排呢?答案是可以的,说禁止也对,说不禁止也对,就是相对视角的问题,许多资料讲述:“volatile可以禁止指令重排序”,其实更具体的说法应该是:“volatile可以禁止屏障内的指令,和屏障外的指令发生重排序”。
同时还有个误区要纠正一下:volatile并没有直接使用OS的内存屏障指令,而是使用JVM内存屏障字节码指令,JVM的内存屏障字节码指令会间接使用OS的内存屏障指令。这句话有点绕,简单来说就是:JVM对OS原生的内存屏障指令有层封装,volatile使用的是JVM封装后的内存屏障。
JVM内存屏障字节码指令的定义,位于HotSpot源码的bytecodeInterpreter.cpp文件中:

字节码内存屏障
许多资料讲述volatile可见性时,会直接跳过JVM这层封装,直接去聊操作系统级别的MESI等一致性协议,其实这是有点不太妥当的,因为OS的内存屏障指令,保证了cpu寄存器、高速缓冲区、机器内存的数据一致性,这是硬件层面的数据一致性。
而JVM的内存屏障指令(字节码指令),保证了JVM线程工作内存(线程栈),和JVM程序主内存中的数据一致性,这是软件层面的数据一致性。
JVM的指令最终会依赖OS的指令,但有些资料会跳过了JVM内存屏障这层封装,直接跟你去聊了OS内存屏障,这就导致了许多人,压根不清楚JVM还有一层封装,以为volatile直接用了OS的原语指令。搞清这个误区后,接着再来说说volatile如何实现的可见性。
保证内存数据的可见性,原理还是内存屏障,但用的是读+写屏障,当多个线程读共享变量时,会触发读屏障,读屏障中会记录哪些线程读了这个变量,然后当一条线程写回数据时,就会触发写屏障,此时写屏障里面,就会根据前面“读屏障”记录下来的线程,去通知所有还未刷回的线程,重新再来读取一次最新值,以此实现了内存中共享数据的可见性。
在硬件层面,将volatile修改的高速缓存数据,写回到机器内存时,这个写回内存操作会将把其他处理器(寄存器)中,缓存了该地址的数据置为无效。多核处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据,来检查自己寄存器中的值是不是过期了,当处理器发现自己寄存器中对应内存地址的数据被修改时,就会将当前处理器的缓存行设置成无效状态,当处理器对这个无效状态的数据进行修改时,就会重新从机器内存中读取数据到CPU寄存器。
PS:上述两段话中,前者是JVM字节码指令保障的软件层面数据一致性,后者是OS原语指令保障的硬件层面数据一致性,两者相结合,从而实现了volatile关键字的可见性。

  • 21
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值