理解Java内存模型(JMM)及volatile关键字

1. 理解Java内存区域与Java内存模型

1.1 Java内存区域

在这里插入图片描述

Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中黄色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。

  • 本地内存:线程共享区域,在Java8中是本地内存,也是我们通常说的堆外内存。包含元空间和直接内存。不知道大家有没有注意到Java8和Java8之前的JVM内存区域的区别,在Java8之前有一个永久代的概念,实际上指的是Hotspot虚拟机上的永久代。他用永久代实现了JVM规范定义的方法区功能。主要存储类的信息,静态变量,常量,即时编译器编译后的代码等。这部分由于是在堆中实现的,受到GC的管理,不过由于永久代有-XX:MaxPermSize的上限,所以如果动态生成类(把类信息放到永久代)或大量执行String.intern(将字符串放入到永久代常量池中),很容易造成OOM。有人说我们可以把永久代设置的足够大,但很难确定一个合适的大小,受类的数量,常量数量的多少影响很大。所以在Java8中就把方法区的实现移到本地内存的元空间中,这样方法区就不受JVM控制了,也就不会进行GC了。也因此提升了性能(发生GC会发生stop the world,造成性能受到一定影响,后文会提到)。也就不存在由于永久代限制了大小而导致的OOM异常了(假设总内存1G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。综上所述,在 Java 8 以后这一区域也不需要进行 GC。
  • JVM堆(Java Heap):Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例和数组对象,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
  • 程序计数器(Program Counter Register):属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 虚拟机栈(Java Virtual Machine Stacks):属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程。
  • 本地方法栈(Native Method Stacks):本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。

这里简单说下Java内存区域,是为了更好的区分后面的Java内存模型,毕竟这两种划分是不同层次的概念。

1.2 Java内存模型

Java内存模型(即java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一种规则或规范,通过这组规范来定义了程序中各个变量(包括实例字段静态字段构成数组对象的元素)的访问方式。但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会给其分配工作内存(或者栈空间)。用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存中,主内存是共享区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行。首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作操作完成后再将变量写会主内存。不能直接操作主内存中的变量,工作内存中存储着主内存中变量的副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信只能依靠主内存来完成。线程、主内存和工作内存的交互关系如下图所示。
在这里插入图片描述
需要注意的是,JMMjava内存区域的划分是不同的概念层次,更恰当的说JMM描述的是一组规则,通过这组规则来控制程序中各个变量在共享数据区域和私有数据区域的访问方式。JMM是围绕原子性,有序性,可见性展开的(后面会详细分析)。JMM和java内存区域唯一相似点,都存在共享数据区域和私有数据区域。在JMM中主内存属于共享数据区域,从某个程度上来说应该包括了堆和方法区,而工作内存属于线程私有数据区域,从某个程度来讲应该包括程序计数器,虚拟机栈,本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下。

  • 主内存:注意存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享类的信息,常量和静态变量,由于是共享数据区域,多线程对同一个变量进行访问可能会造成线程安全问题。
  • 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储在主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,就算是两个线程执行的是同一段代码,它们也会在各自的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器,虚拟机栈,本地方法栈等,注意由于工作内存是每个线程的私有数据区域,线程之间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

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

1.3 内存交互操作

在这里插入图片描述
由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。

  • Lock(锁定):作用于主内存的变量,把一个变量标识为一个线程独占状态。
  • Unlock(解锁):作用与主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • Read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便后续的load操作使用。
  • Load(载入):作用于工作内存的变量,他把read操作从主内存中得到的变量值放入线程工作内存的变量副本中。
  • Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个指令。
  • Assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量。每当虚拟机遇到一个需要赋值给变量的字节码指令时将会执行这个指令。
  • Store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中。以便后续的write操作。
  • Write(写入):作用于主内存的变量,它把Sotre操作从工作内存中一个变量的值传送到主内存的变量中。

如果想把一个变量从主内存复制到工作内存中,就需要按顺序的执行readload操作。如果想把变量从工作内存中同步到主内存中,也必须要按顺序执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是说read和load之间,store和write之间是可以插入其他指令的,如对主内存的变量a,b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load,store和write操作之一单独出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生任何assign操作)把数据从工作内存同步到主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须出现。
  • 如果对一个变量进行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load和assign操作初始化变量的值。
  • 如果一个操作事先没有被lock操作锁定,则不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量进行unlock操作之前,必须先将此变量同步到主内存中(执行store和write操作)。

这8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。

2. 硬件内存架构与Java内存模型

2.1 硬件内存架构

在这里插入图片描述

正如上图所示,是经过简化的CPU和内存操作的简易图,实际上没有这么简单,这里是为了大家理解方便,我们省去了南北桥并将三级缓存(L1,L2,L3)统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或者多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组寄存器,寄存器是CPU直接访问和处理的数据,是一个临时存放数据的空间。一般CPU都从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在CPU寄存器和主内存之间添加了CPU缓存,CPU缓存非常小,但访问速度比主内存快得多,如果CPU总是操作主内存中同一地址的数据,很容易影响CPU执行速度,此时CPU缓存就可以把内存中提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中取,无需直接去主内存取,需要注意的是,寄存器并不是每次都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中获取数据,所以并不是每次都得到缓存中的数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取就没命中,可见缓存的命中率高低也会影响CPU的执行性能,这就是CPU,缓存以及主内存间的简要交互过程。

总结:

简单来说当一个CPU访问主内存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据会直接从缓存中取),进而读取CPU缓存到寄存器中,当CPU需要写数据到主存时,同样会先刷新缓存寄存器的数据到缓存中,然后再把缓存中的数据刷新到主内存中。

2.2 Java线程与硬件处理器

了解完硬件的内存结构后,接着了解JVM中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型和硬件内存结构的关系。在windows系统和linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一的线程模型,实际上就是通过语言级别层面的程序间接调用系统内核的线程模型,即我们在使用Java多线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个专业术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(kernel)支持的线程,这种线程是由操作系统内核来完成线程切换的,内核通过操作调度器进而对线程进行调度,并将线程的任务映射到各个处理器之上,每个内核线程可以理解为内核的一个分身,这也就是操作系统可以同时处理多任务的原因,由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也就是我们通常所说的线程,由于每一个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图
在这里插入图片描述

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

2.3 Java内存模型与硬件内存架构的关系

通过对前面的硬件内存结构,Java内存模型和Java多线程的实现原理的理解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存结构并不完全一致,对于硬件内存来说只有寄存器,缓存内存和主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存,方法区)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存和寄存器中,因此总体来说,Java内存模型和计算机硬件内存结构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)
在这里插入图片描述

3. JMM存在的必要性

在明白了Java内存区域,Java内存模型,硬件内存结构和Java多线程的实现原理的具体关系后,接着来谈谈Java内存模型存在的必要性,由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),用于存储线程私有的数据,线程和主内存中的变量必须通过工作内存间接完成,主要过程就是把变量从主内存中拷贝到每个线程各自的工作内存中,然后对变量进行操作,操作完成后再将变量写回主内存中,如果存在两个线程同时对一个主内存中实例对象的变量进行操作,就有可能发生线程安全问题。如下图:就有可能发生线程安全问题
在这里插入图片描述
如上图所示,主内存中存在一个共享变量x=1,现在有线程A,B两条线程分别对该共享变量x=1进行操作,线程A和线程B在各自的工作内存中都存在共享变量副本x,假如现在A线程想要修改x的值为2,而B线程却想要读取x的值。那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是不确定的,即B线程有可能读到A线程更新前的值1,也有可能读到A线程更新后的值2。这是因为工作内存是每个线程的私有数据区域,而线程A修改变量x时,首先将变量x从主内存拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内存中。而对于B线程来说也是类似的步骤,这样就有可能造成主内存和工作内存间数据存在一致性的问题,会出现两种情况:第一种:假设A线程修改为变量x的值,正要将变量写入到主内存中,而B线程正在读取主内存的值,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1。第二种:假设A线程修改为变量x的值,已经将变量写入到主内存中,而B线程正在读取主内存的值,即将x=2拷贝到自己的工作内存中,这样B线程读取到的值就是x=2。但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。

4. Java内存模型的承诺

这里我们先来了解几个概念,即原子性?可见性?有序性?最后再阐明JMM是如何保证这3个特性。

4.1 原子性

原子性是指一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程所影响。比如对于一个静态变量int x,两条线程同时对它赋值,线程A赋值为1,线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2。线程A和线程B之间的操作是没有干扰的,这就是原子性操作,不可中断的特点,JMM中保证的原子性变量操作包括read,load,use,assign,store,write。
有点需要注意的是,对于32位系统来说,long类型和double类型他们的读写并非原子性的,而基本类型char,byte,short,int,float,boolean读写是原子操作的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写操作是存在相互干扰的,因为对于32位虚拟机来说,每次读写是32位的,而long和double类型是64位的存储单元。这样会导致一个线程在写的时候,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到后32位的数据,这样可能会读取到一个既非原值又不是线程修改后值的变量,他可能是"半个变量"的数值,即64位的数据被两个线程分成了两次读取,但是也不必担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。

4.2 重排序

4.2.1 理解重排序

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排,一般分以下三种:

  1. 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性(即后一个执行语句无需依赖前面执行语句的结果)。处理器可以改变语句对应的机器指令的执行顺序。
  3. 内存系统的重排:由于处理器使用缓存和读写缓冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存和缓存的数据同步存在时间差。
    在这里插入图片描述
    其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题

4.2.2 编译器重排

下面我们简单看一个编译器重排的例子:

static int x = 0, y = 0;
static int a = 0, b = 0;
线程 1             线程 2
1:a = 1;    	 3: b = 1;
2:x = b;         4: y = a;

两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,很容易想到这段代码的运行结果x和y的值可能为(1,0)、(0,1)或(1,1),但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况

线程 1             线程 2
1:x = b;         3: y = a;
2:a = 1;    	 4: b = 1;

这种执行顺序下就有可能出现x = 0 和y= 0 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。

4.2.3 JMM的重排序屏障

从Java源代码到最终实际执行的指令序列,会经过三种重排序,但是为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM会根据重排序规则禁止特定类型的编译器重排序,对于处理器排序,JMM会插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器排序。这里讨论JMM对处理器的重排序,为了更深理解JMM对处理器重排序的处理,先来认识一下常见处理器的重排序规则:
在这里插入图片描述
其中的N标识处理器不允许两个操作进行重排序,Y标识运行,
其中Load-Load表示读-读操作、Load-Store表示读-写操作、Store-Store表示写-写操作、Store-Load表示写-读操作。可以看出:常见处理器对写-读操作都是允许重排序的,并且常见的处理器都不允许对存在数据依赖的操作进行重排序(对应上面数据转换那一列,都是N,所以处理器不允许这种重排序)。

那么这个结论对我们有什么作用呢?比如第一点:处理器允许写-读操作两者之间的重排序,那么在并发编程中读线程读到可能是一个未被初始化或者是一个NULL等,出现不可预知的错误,基于这点,JMM会在适当的位置插入内存屏障指令来禁止特定类型的处理器的重排序。内存屏障指令一共有4类:

  • LoadLoad Barriers:确保Load1数据的装载先于Load2以及所有后续装载指令。
  • StoreStore Barriers:确保Store1的数据对其他处理器可见(会使缓存行失效,并刷新到内存中)先于Store2及所有后续存储指令的装载。
  • LoadStore Barriers:确保Load1的数据先于Store2及所有后续存储指令的装载。
  • StoreLoad Barriers:确保Store1的数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行失效)先于Load2以及所有后续装载指令。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。

数据依赖性
根据上面的表格,处理器不会对存在数据依赖的操作进行重排序。这里数据依赖的准确定义是:如果两个操作同时访问一个变量,其中一个操作是写操作,此时这两个操作就构成了数据依赖。常见的具有这个特性的如i++、i—。如果改变了具有数据依赖的两个操作的执行顺序,那么最后的执行结果就会被改变。这也是不能进行重排序的原因。例如:

写后读:a = 1; b = a;
写后写:a = 1; a = 2;
读后写:a = b; b = 1;

重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被处理器和编译器所考虑。

as-if-serial语义
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

4.3 可见性

理解了重排序之后,可见性对我们来说就非常容易了,可见性指的是当一个线程修改了某一个共享变量的值,其他线程是否能够马上得知这个修改的值,对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取到这个变量的值,并且是修改后的新值。但是在多线程环境下可就不一定了,前面我们分析过,由于线程对共享变量的操作是从主内存拷贝到各自的工作内存中进行操作,操作完成后再写回主内存中。这就可能存在一个线程A修改了共享变量x的值,还未写回主内存中,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中的共享变量x对B线程来说是不可见的,这种工作内存和主内存同步延迟现象就造成了可见性问题,另外指令重排和编译器优化也可能导致可见性问题。通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

4.4 有序性

有序性指的是对于单线程执行的代码,我们总是认为代码的执行顺序是按顺序依次执行的,这样的理解对于单线程情况下,并没有什么问题。但是对于多线程环境下,则可能出现乱序问题,因为程序编译成机器指令时可能会出现指令重排现象,重排后的指令和原指令的顺序未必一致,要明白的是,在Java程序中,如果在本线程内,所有操作都视为有序行为,如果是多线程环境下,在一个线程中观察另外一个线程,所有的操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性(as-if-serial)。后半句指的是指令重排和工作内存与主内存同步延迟现象。

4.5 JMM提供的解决方案

在理解了原子性,可见性和有序性的问题后,看看JVM是如何保证的,在Java内存模型中提供一套解决方案供Java开发工程师在开发者使用。

  • 如原子性问题:除了JVM自身提供的对基本数据类型的读写操作的原子性外,对于方法级别和代码块级别的原子性操作,可以使用synchronized关键字和重入锁ReentrantLock来保证程序执行的原子性。
  • 而工作内存和主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字或者ReentrantLock解决,他们都可以使一个线程修改后的变量立马对其他线程可见。
  • 对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,
  • 关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

4.6 先行发生原则(happens-before)

前面所述的内存交互操作必须要满足一定的规则,而happens-before就是定义这些规则的一个等效判断原则。happens-before是JMM定义的两个操作之间的偏序关系,如果操作A先行与操作B,则A产生的影响能被操作B观察到。"影响"包括了修改了内存中共享变量的值,发送了消息,调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作。JVM能给保证操作具有顺序性,此时不能够随意的重排序,否则无论保证顺序性,就能进行指令的重排序。

  • 程序顺序规则(Program Order Rule):在同一个线程中,按照程序代码顺序执行,书写在前面的操作先行发生于书写在后面的操作,准确的来说是程序的控制流顺序,考虑分支和循环等。
  • 管理锁定规则(Monitor Lock Rule):一个unlock操作先行发生与后面(时间上的顺序)对同一个锁的Lock操作。也就是说,如果对于同一个锁解锁后,再加锁,那么加锁的动作必然在解锁动作之后。
  • Volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作。这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读取该变量的值。而当该变量发生变化时,又会强迫将最新的值刷新到主内存中,任何时刻,不同的线程总是能看到该变量的最新值。
  • 线程启动规则(Thread Start Rule):如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作先行与B线程的任意操作。
  • 传递性(Transitivity):如果操作A先行发生与操作B,操作B先行发生于操作C,那么可以得出操作A先行发生与操作B。
  • 线程终止规则(Thread Termination Rule):线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 线程中断规则(Thread interrupt Rule):对线程interrupt()方法的调用先行发生于被中断的线程的代码检测到中断事件的发生。可以通过Thread.isInterrupted()方法检测线程是否中断。
  • 对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生与它的finalize()开始

上述8条规则无需手动添加任何同步手段(synchronized,Lock,volatile等)即可达到效果,不过需要注意的是,不同操作时间先后顺序和先行发生原则之间没有关系,二者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以happens-before原则为准。

下面我们结合前面的案例演示这8条原则如何判断线程是否安全,如下:

class TestOrder{
    int a = 0;
    boolean flag = false;
    public void read(){
        if(flag){
            int i = a + 1;
        }
    }

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

假如存在A和B两条线程,线程A调用实例对象的writer()方法,线程B调用实例对象的read()方法;线程A先启动而线程B后启动。那么线程B读取到i的值是多少呢?现在依据happens-before原则来判断是否发生线程安全问题。

  1. 由于是两个线程同时调用,因此程序顺序原则不适用。
  2. read()方法和write()方法没有使用任何同步手段,所以锁规则也不适用。
  3. 没有使用volatile关键字,没有volatile变量规则也不适用。
  4. 线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。
    虽然线程A和线程B启动有先后,但是线程B执行结果确是不确定,也是说上述代码没有适合8条原则中的任意一条,没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。修复这个问题的方式很简单,要么给writer()方法和read()方法添加同步手段,如synchronized或lock或者给变量flag添加volatile关键字,确保线程A修改的值对线程B总是可见。

5. Volatile关键字

5.1 volatile内存语义

volatile关键字是Java虚拟机提供的轻量级同步机制,volatile关键字有以下两个作用。

  • 保证变量的可见性:保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了被volatile修饰的共享变量的值,新值总是立马可以被其他线程得知。
  • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段。

5.2 volatile的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总是立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述代码所示,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

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

5.3 volatile禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

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

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

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

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

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

  //禁止指令重排优化
  private volatile static DoubleCheckLock instance;

6. 总结

到此为止相信大家应该对Java内存模型和volatile关键字有所了解,总而言之,JMM是一组规范,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值