引言
本篇文章结合我个人对Java内存模型的理解以及相关书籍资料为前提全面剖析JMM内存模型,本文的书写思路先阐述JVM内存模型、硬件与OS(操作系统)内存区域架构、Java多线程原理以及Java内存模型JMM之间的串联关系之后再对Java内存模型进行进一步剖析,因为大部分小伙伴在描述Java内存模型JMM时总是和JVM内存模型的概念相互混淆,那么本文的目的就是帮助各位小伙伴彻底理解JMM内存模型。(本人文章都是以个人理解+相关书籍资料为前提进行撰写,如果错误或疑问欢迎各位看官评论区留言纠正,谢谢!)
一、彻底理解JVM内存模型与Java内存模型JMM的区别
1.1、JVM内存模型(JVM内存区域划分)
众所周知,Java程序如果想要运行那么必须是要建立在JVM的前提下的,Java使用JVM虚拟机屏蔽了像C那样直接与操作系统或者OS接触,让Java语言操作全部建立在JVM的基础之上从而做到了无视平台,一次编译到处运行。
JVM在运行Java程序时会把自己管理的内存划分为以上区域(运行时数据区),每个区域都有各自的用途以及在Java程序运行时发挥着自己的作用,而其实运行时数据区又会将运行时数据区划分为线程私有区以及线程共享区(GC不会发生在线程私有区),以下为各大区域具体作用:
方法区(Method Area):
方法区(在Java8之后方法区的概念更改为元数据空间)属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存(并不是所有新建对象new Object()
在分配时都会在堆中),注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成,主要作用其实就是因为CPU的时间片在调度线程工作时会发生“中断”某个线程让另外一个线程开始工作,那么当这个“中断”的线程重新被CPU调度时如何得知上次执行到那行代码了?就是通过负责此类的程序计数器来得知。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。当线程开始执行时,每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下:
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 C所编写的 Native 方法相关,当有程序需要调用 Native 方法时,JVM会在本地方法栈中维护着一张本地方法登记表,这里只是做登记是哪个线程调用的哪个本地方法接口,并不会在本地方法栈中直接发生调用,因为这里只是做一个调用登记,而真正的调用需要通过本地方法接口去调用本地方法库中C编写的函数,一般情况下,我们无需关心此区域。
之所以说这块的内容是需要让大家理解清楚JVM内存模型和JMM内存模型是完全两个不同的概念,JVM内存模型是处于Java的JVM虚拟机层面的,实际上对于操作系统来说,本质上JVM还是存在于主存中,而JMM是Java语言与OS和硬件架构层面的,主要作用是规定硬件架构与Java语言的内存模型,而本质上不存在JMM这个东西,JMM只是一种规范,并不能说是某些技术实现。
1.2、Java内存模型JMM概述
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程如果想要对一个变量读取赋值等操作那么必须在工作内存中进行.所以当线程想操作变量时首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量刷写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝(PS:有些小伙伴可能会疑惑,Java中线程在执行一个方法时就算里面引用或者创建了对象,他不是也存在堆中吗?栈内存储的不仅仅只是对象的引用地址吗?这里简单说一下,当线程真正运行到这一行时会根据局部表中的对象引用地址去找到主存中的真实对象,然后会将对象拷贝到自己的工作内存再操作…,但是当所操作的对象是一个大对象时(1MB+)并不会完全拷贝,而是将自己操作和需要的那部分成员拷贝),前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
重点注意!!!JMM与JVM内存区域的划分是不同的概念层次,在理解JMM的时候不要带着JVM的内存模型去理解,更恰当说JMM描述的是一组规则,通过这组规则控制程Java序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性拓展延伸的。
JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下:
主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中(除开开启了逃逸分析和标量替换的栈上分配和TLAB分配),不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行非原子性操作时可能会发现线程安全问题。
工作内存: 主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,线程之间的通讯还是需要依赖于主存,因此存储在工作内存的数据不存在线程安全问题。
弄清楚主内存和工作内存后,接了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中的局部变量表,但倘若本地变量是引用类型,那么该对象的在内存中的具体引用地址将会被存储在工作内存的帧栈结构中的局部变量表,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区(栈上分配与TLAB分配除外)。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两条线程同时调用了同一个类的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,简单示意图如下所示:
二、计算机硬件内存架构、OS与Java多线程实现原理及Java内存模型
2.1、计算机硬件内存架构
正如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,我们省去了南北桥。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行。从多线程的调度来说,每个线程都会映射到各个CPU核心中并行执行。
在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一地址的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。
需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,由此可见,缓存命中率的高低也会影响CPU执行性能。这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。实则就类似于Appcalition(Java) --> Cache(Redis) --> DB(MySQL)
的关系,Java程序的性能由于DB需要走磁盘受到了影响,导致Java程序在处理请求时需要等到DB的处理结果,而此时负责处理该请求的线程一直处于阻塞等待状态,只有当DB处理结果返回了再继续负责工作,那么实际上整个模型中的问题是:DB的速度跟不上Java程序的性能,导致整个请求处理起来变的很慢,但是实际上在DB处理的过程Java的线程是处于阻塞不工作的状态的,那么实则是没有必要的,因为这样最终会导致整体系统的吞吐量下降,此时我们可以加入Cache(Redis)来提升程序响应效率,从而整体提升系统吞吐和性能。(实际上我们做性能优化的目的就是让系统的每个层面处理的速度加快,而架构实际上就是设计一套能够吞吐量更大的请求的系统)。
2.2、OS与JVM线程关系及Java线程实现原理
在以上的阐述中我们大致了解完了硬件的内存架构和JVM内存模型以及Java内存模型之后,接着了解Java中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在WindowsOS和LinuxOS上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,比如:new Thread(Runnable);
JVM内部是转而调用当前操作系统的内核线程来完成当前Runnable任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为Java程序中的线程与OS的一对一模型。如下图:
Java程序中的每个线程都会经过OS被映射到CPU中进行处理,当然,如果CPU存在多核,那么一个CPU同时也能并行调度执行多个线程。
2.3、JMM与硬件内存架构的关系
通过前面对JVM内存模型、Java内存模型JMM、硬件内存架构以及Java多线程的实现原理进行分析后,我们可以发现,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中。因此总体来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于JVM内存区域划分也是同样的道理)
2.4、为什么需要有JMM的存在?
接着来谈谈Java内存模型存在的必要性,因为我们去学习某个知识的话要做到知其然知其所以然。由于线程是OS的最小操作单位,那么所有的程序运行时的实体本质上都是是一条条线程,Java程序需要运行在OS上也不例外,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程如果想要操作主存中的某个变量,那么必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的线程自己的工作内存空间,然后对变量先在工作内存中进行操作,操作完成后再将变量刷写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。如下图,主内存中存在一个共享变量int i = 0,
第一种情况(左图):
现在有A和B两条线程分别对该变量i进行操作,A/B线程各自的都会先将主存中的i拷贝到自己的工作内存存储为共享变量副本i,然后再对i进行自增操作,那么假设此时A/B同时将主存中i=0拷贝到自己的工作内存中进行操作,那么其实A在自己工作内存中的i进行自增操作是对B的工作内存的副本i不可见的,那么A做了自增操作之后会将结果1刷写回主存,此时B也做了i++操作,那么实际上B刷写回主存的值也是基于之前从主存中拷贝到自己工作内存的值i=0,那么实际上B刷写回主存的值也是1,但是实际上我是两条线程都对主存中 i 进行了自增操作,理想结果应该是i=2,但是此时的情况结果确实i=1。
第二种情况(右图):
假设现在A线程想要修改 i 的值为2,而B线程却想要读取 i 的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A修改变量 i 时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量 i 写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将i=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=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位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。
其实本质上原子性操作指的就是一组大操作要么就全部执行成功,要么就全部失败,举个例子:下单:{增加订单,减库存} 那么对于用户来说下单是一个操作,那么系统就必须保证下单操作的原子性,要么就增加订单和减库存全部成功,不存在增加订单成功,减库存失败,那么这个例子从宏观上来就就是一个原子性操作,非原子性操作反之,线程安全问题产生的根本原因也是由于多线程情况下对一个共享资源进行非原子性操作导致的。
但是有个点在我们深入研究Java的并发编程以及在研究可见性之前时需要注意的,就是计算机在程序执行的时候对它的优化操作 – 指令重排。计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:
- 编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令并行的重排: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统的重排: 由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。
2.5.1.1、编译器优化指令重排
int a = 0;
int b = 0;
//线程A 线程B
代码1:int x = a; 代码3:int y = b;
代码2:b = 1; 代码4:a = 2;
此时有4行代码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对单线程运行的优化),最终导致在多线程环境下时多个线程使用变量能否保证一致性是无法确定的(PS:编译器重排的基础是代码不存在依赖性时才会进行的,而依赖性可分为两种:数据依赖(int a = 1;int b = a;)和控制依赖(boolean f = ture;if(f){sout(“123”);}))。
2.5.1.2、处理器指令重排
先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:
取指:IF
译码和取寄存器操作数:ID
执行或者有效地址计算:EX
存储器访问:MEM
写回:WB
CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:
(流水线技术:类似于工厂中的生产流水线,工人们各司其职,做完自己的就往后面传,然后开始一个新的,做完了再往后面传递…而指令执行也是一样的,如果等到一条指令执行完毕之后再开始下一条的执行,就好比工厂的生产流水线,先等到一个产品生产完毕之后再开始下一个,效率非常低下并且浪费人工,这样一条流水线上同时只会有一个工人在做事,其他的看着,只有当这个产品走了最后一个人手上了并且最后一个工人完成了组装之后第一个工人再开始第二个产品的工作)
从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的,如下:
i = a + b;
y = c - d;
指令 | 描述 |
---|---|
LW R1,a | LW指令表示 load,其中LW R1,a表示把a的值加载到寄存器R1中 |
LW R2,b | 表示把b的值加载到寄存器R2中 |
ADD R3,R1,R2 | ADD指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。 |
SW i,R3 | SW表示 store 即将 R3寄存器的值保持到变量i中 |
LW R4,c | 表示把c的值加载到寄存器R4中 |
LW R5,d | 表示把d的值加载到寄存器R5中 |
SUB R6,R4,R5 | SUB指令表示减法,把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,直接获取a的值,而此时线程A还在自己的工作内存中对当中拷贝过来的变量副本a进行赋值操作,结果还未刷写到主存,那么此时线程B读取到的a值还是为0,那么拷贝到线程B工作内存的a=0;然后并在自己的工作内存中执行了 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、Java中JMM是怎么去解决如上问题的?
在真正的理解了如上所以内容之后,再来看Java为我们提供的解决方案,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者Lock锁接口的实现类来保证程序执行的原子性,关于synchronized的详解(能保证三特性不能禁止指令重排),后续我们会讲到。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用加锁或者Volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字(volatile关键字不能保证原子性,只能保证的是禁止指令重排与可见性问题)来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。
2.7、Java内存模型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对这八种指令的使用,制定了如下规则:
- 1)、不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write;
- 2)、不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存;
- 3)、不允许一个线程将没有assign的数据从工作内存同步回主内存;
- 4)、一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作;
- 5)、一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;
- 6)、如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值;
- 7)、如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;
- 8)、对一个变量进行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值是多少呢?现在依据8条原则,由于存在两条线程同时调用,因此程序次序原则不合适。methodA()方法和methodB()方法都没有使用同步手段,锁规则也不合适。没有使用volatile关键字,volatile变量原则不适应。线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。线程A和线程B的启动时间虽然有先后,但线程B执行结果却是不确定,也是说上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。修复这个问题的方式很简单,要么给methodA()方法和methodB()方法添加同步手段(加锁)或者给共享变量添加volatile关键字修饰,保证该变量在被一个线程修改后总对其他线程可见。
三、Volatile关键字
3.1、Volatile关键字保证的可见性
Volatile是Java提供的轻量级同步工具,它能保证可见性和做到禁止指令重排做到有序性,但是它不能保证原子性,如果你的程序必须做到原子性的话那么可以考虑使用JUC的原子包下的原子类(后续篇章会讲到)或者加锁的方式来保证,但是我们假设如果使用volatile来修饰共享变量,那么它能够保证的是一个线程对它所修饰的变量进行更改操作后总是能对其他线程可见,如下:
volatile int i = 0;
public void add(){
i++;
}
对于如上代码,我们任何线程调用add()方法之后对 i 进行i++ 操作之后都是对其他线程可见的,但是这段代码不存在线程安全问题吗?存在,为什么?因为 i++ 并不是原子性操作, i++实际上是三个操作的组成,从主存读取值、工作内存中+1操作、操作结果刷写回主存三步操作所组成的,它们三步中其中一条线程在执行任何一步的时候都有可能被打断,那么还是会出现线程安全问题(具体参考上述线程安全问题第一种情况),但是我们要清楚,此时如果有多条线程调用add()方法,那么此时还是会出现线程安全问题,如果想要解决还是需要使用sync或者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变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。
3.2、Volatile关键字怎么做到禁止指令重排序的?
volatile关键字另一个作用就是禁止编译器或者处理器对进行指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,那么volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2; | 确保Load1指令数据的装载之前发生于Load2及后续所有装载指令的数据装载。 |
StoreStore Barriers | Store1; StoreStore; Store2; | 确保Store1数据的存储对其他处理器可见(刷新到内存中)并之前发生于Store2及后续所有存储指令的数据写入。 |
LoadStore Barriers | Load1; LoadStore; Store2; | 确保Load1指令数据的装载之前发生于Store2及后续所有存储指令的数据写入。 |
StoreLoad Barriers | Store1; 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步完成(伪代码)
memory = allocate(); //1.分配对象内存空间
singleton(memory); //2.初始化对象
singleton = memory; //3.设置singleton指向刚分配的内存地址,此时singleton != null
由于步骤1和步骤2间可能会重排序,如下:
memory = allocate(); //1.分配对象内存空间
singleton = memory; //3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton(memory); //2.初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问singleton不为null时,由于singleton实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止singleton变量被执行指令重排优化即可。
private volatile static Singleton singleton;
四、总结
哪么到这里如果是认真阅读的小伙伴其实通过对这篇文章的理解,相信对Java内存模型JMM已经有了一个清晰的认知,那么其实这篇文章是我们在探究Java并发编程时的第一道门槛,我后续也会继续发布有关并发专题相关的文章,如果你对于文章中的某些点有其他看法或者文章中你认为存在问题或者你对文章中某些点存在任何疑问,欢迎你评论区留言一起探讨,谢谢!
五、参考资料与书籍
- 《深入理解JVM虚拟机》
- 《Java并发编程之美》
- 《Java高并发程序设计》
- 《亿级流量网站架构核心技术》
- 《Java并发编程实战》