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

【版权申明】未经博主赞成,谢绝转载!(请尊重原创,博主保留追究权)
http://blog.csdn.net/javazejian/article/details/72772461
出自【zejian的博客】
html

关联文章:java

深刻理解Java类型信息(Class对象)与反射机制web

深刻理解Java枚举类型(enum)编程

深刻理解Java注解类型(@Annotation)数组

深刻理解Java类加载器(ClassLoader)缓存

深刻理解Java并发之synchronized实现原理安全

Java并发编程-无锁CAS与Unsafe类及其并发包Atomicruby

深刻理解Java内存模型(JMM)及volatile关键字性能优化

剖析基于并发AQS的重入锁(ReetrantLock)及其Condition实现原理多线程

剖析基于并发AQS的共享锁的实现(基于信号量Semaphore)

并发之阻塞队列LinkedBlockingQueue与ArrayBlockingQueue

本篇主要结合博主我的对Java内存模型的理解以及相关书籍内容的分析做为前提,对JMM进行较为全面的分析,本篇的写做思路是先阐明Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系,在弄明白它们间的关系后,进一步分析Java内存模型做用以及一些必要的实现手段,如下是本篇主要内容(若有错误,欢迎留言,谢谢!)

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

Java内存区域

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

  • 方法区(Method Area):

    方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区没法知足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各类字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。

  • JVM堆(Java Heap):

    Java 堆也是属于线程共享的内存区域,它在虚拟机启动时建立,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎全部的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,所以不少时候也被称作GC 堆,若是在堆中没有内存完成实例分配,而且堆也没法再扩展时,将会抛出OutOfMemoryError 异常。

  • 程序计数器(Program Counter Register):

    属于线程私有的数据区域,是一小块内存空间,主要表明当前线程所执行的字节码行号指示器。字节码解释器工做时,经过改变这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都须要依赖这个计数器来完成。

  • 虚拟机栈(Java Virtual Machine Stacks):

    属于线程私有的数据区域,与线程同时建立,总数与线程关联,表明Java方法执行的内存模型。每一个方法执行时都会建立一个栈桢来存储方法的的变量表、操做数栈、动态连接方法、返回值、返回地址等信息。每一个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,以下(图有误,应该为栈桢):

  • 本地方法栈(Native Method Stacks):

    本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,通常状况下,咱们无需关心此区域。

这里之因此简要说明这部份内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不一样层次的概念。

Java内存模型概述

Java内存模型(即Java Memory Model,简称JMM)自己是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,经过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。因为JVM运行程序的实体是线程,而每一个线程建立时JVM都会为其建立一个工做内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定全部变量都存储在主内存,主内存是共享内存区域,全部线程均可以访问,但线程对变量的操做(读取赋值等)必须在工做内存中进行,首先要将变量从主内存拷贝的本身的工做内存空间,而后对变量进行操做,操做完成后再将变量写回主内存,不能直接操做主内存中的变量,工做内存中存储着主内存中的变量副本拷贝,前面说过,工做内存是每一个线程的私有数据区域,所以不一样的线程间没法访问对方的工做内存,线程间的通讯(传值)必须经过主内存来完成,其简要访问过程以下图

须要注意的是,JMM与Java内存区域的划分是不一样的概念层次,更恰当说JMM描述的是一组规则,经过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的(稍后会分析)。JMM与Java内存区域惟一类似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工做内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,咱们可能会看见主内存被描述为堆内存,工做内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工做内存说明以下

  • 主内存

    主要存储的是Java实例对象,全部线程建立的实例对象都存放在主内存中,无论该实例对象是成员变量仍是方法中的本地变量(也称局部变量),固然也包括了共享的类信息、常量、静态变量。因为是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工做内存

    主要存储当前方法的全部本地变量信息(工做内存中存储着主内存中的变量副本拷贝),每一个线程只能访问本身的工做内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在本身的工做内存中建立属于当前线程的本地变量,固然也包括了字节码行号指示器、相关Native方法的信息。注意因为工做内存是每一个线程的私有数据,线程间没法相互访问工做内存,所以存储在工做内存的数据不存在线程安全问题。

弄清楚主内存和工做内存后,接了解一下主内存与工做内存的数据存储类型以及操做方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,若是方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工做内存的帧栈结构中,但假若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,无论它是基本数据类型或者包装类型(Integer、Double等)仍是引用类型,都会被存储到堆区。至于static变量以及类自己相关信息将会存储在主内存中。须要注意的是,在主内存中的实例对象能够被多线程共享,假若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操做的数据拷贝一份到本身的工做内存中,执行完成操做后才刷新到主内存,简单示意图以下所示:

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

硬件内存架构

正如上图所示,通过简化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缓存到寄存器,当CPU须要写数据到主存时,一样会先刷新寄存器中的数据到CPU缓存,而后再把数据刷新到主内存中。

Java线程与硬件处理器

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

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

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

经过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,咱们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不彻底一致。对于硬件内存来讲只有寄存器、缓存内存、主内存的概念,并无工做内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并无任何影响,由于JMM只是一种抽象的概念,是一组规则,并不实际存在,无论是工做内存的数据仍是主内存的数据,对于计算机硬件来讲都会存储在计算机主内存中,固然也有可能存储到CPU缓存或者寄存器中,所以整体上来讲,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是一样的道理)

JMM存在的必要性

在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。因为JVM运行程序的实体是线程,而每一个线程建立时JVM都会为其建立一个工做内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操做必须经过工做内存间接完成,主要过程是将变量从主内存拷贝的每一个线程各自的工做内存空间,而后对变量进行操做,操做完成后再将变量写回主内存,若是存在两个线程同时对一个主内存中的实例对象的变量进行操做就有可能诱发线程安全问题。以下图,主内存中存在一个共享变量x,如今有A和B两条线程分别对该变量x=1进行操做,A/B线程各自的工做内存中存在共享变量副本x。假设如今A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2仍是更新前的值1呢?答案是,不肯定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是由于工做内存是每一个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工做内存中,而后对变量进行操做,操做完成后再将变量x写回主内,而对于B线程的也是相似的,这样就有可能形成主内存与工做内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到本身的工做内存中,这样B线程读取到的值就是x=1,但若是A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但究竟是哪一种状况先发生呢?这是不肯定的,这也就是所谓的线程安全问题。

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

Java内存模型的承诺

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

原子性

原子性指的是一个操做是不可中断的,即便是在多线程环境下,一个操做一旦开始就不会被其余线程影响。好比对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,无论线程如何运行,最终x的值要么是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位的数据的读写操做做为原子操做来执行,所以对于这个问题没必要太在乎,知道这么回事便可。

理解指令重排

计算机在执行程序时,为了提升性能,编译器和处理器的经常会对指令作重排,通常分如下3种

  • 编译器优化的重排

    编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。

  • 指令并行的重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器能够改变语句对应的机器指令的执行顺序

  • 内存系统的重排

    因为处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操做看上去多是在乱序执行,由于三级缓存的存在,致使内存与缓存的数据同步存在时间差。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会致使程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题

编译器重排

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

线程 1             线程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

两个线程同时执行,分别有一、二、三、4四段执行代码,其中一、2属于线程1 , 三、4属于线程2 ,从程序的执行顺序上看,彷佛不太可能出现x1 = 1 和x2 = 2 的状况,但实际上这种状况是有可能发现的,由于若是编译器对这段程序代码执行重排优化后,可能出现下列状况

线程 1              线程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的状况,这也就说明在多线程环境下,因为编译器优化重排的存在,两个线程中使用的变量可否保证一致性是没法肯定的。

处理器指令重排

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来讲一条指令能够分为多个步骤完成,以下

  • 取指 IF
  • 译码和取寄存器操做数 ID
  • 执行或者有效地址计算 EX
  • 存储器访问 MEM
  • 写回 WB

CPU在工做时,须要将上述指令分为多个步骤依次执行(注意硬件不一样有可能不同),因为每个步会使用到不一样的硬件操做,好比取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提升硬件利用率,CPU指令是按流水线技术来执行的,以下:

从图中能够看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样作是有好处的,若是每一个步骤花费1ms,那么若是第2条指令须要等待第1条指令执行完成后再执行的话,则须要等待5ms,但若是使用流水线技术的话,指令2只需等待1ms就能够开始执行了,这样就能大大提高CPU的执行性能。虽然流水线技术能够大大提高CPU的性能,但不幸的是一旦出现流水中断,全部硬件设备将会进入一轮停顿期,当再次弥补中断点可能须要几个周期,这样性能损失也会很大,就比如工厂组装手机的流水线,一旦某个零件组装中断,那么该零件日后的工人都有可能进入一轮或者几轮等待组装零件的过程。所以咱们须要尽可能阻止指令中断的状况,指令重排就是其中一种优化中断的手段,咱们经过一个例子来阐明指令重排是如何阻止流水线技术中断的

a = b + c ;
d = e + f ;

下面经过汇编指令展现了上述代码在CPU执行的处理过程

  • LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
  • LW R2,c 表示把c的值加载到寄存器R2中
  • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  • SW 表示 store 即将 R3寄存器的值保持到变量a中
  • LW R4,e 表示把e的值加载到寄存器R4中
  • LW R5,f 表示把f的值加载到寄存器R5中
  • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  • SW d,R6 表示将R6寄存器的值保持到变量d中

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

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

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代码,同时存在线程A和线程B对该实例对象进行操做,其中A线程调用写入方法,而B线程调用读取方法,因为指令重排等缘由,可能致使程序执行顺序变为以下:

线程A                    线程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //误读
                          3: i = 1 ;

因为指令重排的缘由,线程A的flag置为true被提早执行了,而a赋值为1的程序还未执行完,此时线程B,刚好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操做,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排致使的程序乱序执行的结果。所以,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。

可见性

理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其余线程是否可以立刻得知这个修改的值。对于串行程序来讲,可见性是不存在的,由于咱们在任何一个操做中修改了某个变量的值,后续的操做中都能读取这个变量值,而且是修改过的新值。但在多线程环境中可就不必定了,前面咱们分析过,因为线程对共享变量的操做都是线程拷贝到各自的工做内存进行操做后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另一个线程B又对主内存中同一个共享变量x进行操做,但此时A线程工做内存中共享变量x对线程B来讲并不可见,这种工做内存与主内存同步延迟现象就形成了可见性问题,另外指令重排以及编译器优化也可能致使可见性问题,经过前面的分析,咱们知道不管是编译器优化仍是处理器优化的重排现象,在多线程环境下,确实会致使程序轮序执行的问题,从而也就致使可见性问题。

有序性

有序性是指对于单线程的执行代码,咱们老是认为代码的执行是按顺序依次执行的,这样的理解并无毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,由于程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,假若在本线程内,全部操做都视为有序行为,若是是多线程环境下,一个线程中观察另一个线程,全部操做都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工做内存与主内存同步延迟现象。

JMM提供的解决方案

在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操做的原子性外,对于方法级别或者代码块级别的原子性操做,可使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性,关于synchronized的详解,看博主另一篇文章( 深刻理解Java并发之synchronized实现原理)。而工做内存与主内存同步延迟现象致使的可见性问题,可使用synchronized关键字或者volatile关键字解决,它们均可以使一个线程修改后的变量当即对其余线程可见。对于指令重排致使的可见性问题和有序性问题,则能够利用volatile关键字解决,由于volatile的另一个做用就是禁止重排序优化,关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操做间的原子性、可见性以及有序性。

理解JMM中的happens-before 原则

假若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在Java内存模型中,还提供了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()方法

上述8条原则无需手动添加任何同步手段(synchronized|volatile)便可达到效果,下面咱们结合前面的案例演示这8条原则如何判断线程是否安全,以下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

一样的道理,存在两条线程A和B,线程A调用实例对象的writer()方法,而线程B调用实例对象的read()方法,线程A先启动而线程B后启动,那么线程B读取到的i值是多少呢?如今依据8条原则,因为存在两条线程同时调用,所以程序次序原则不合适。writer()方法和read()方法都没有使用同步手段,锁规则也不合适。没有使用volatile关键字,volatile变量原则不适应。线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。线程A和线程B的启动时间虽然有前后,但线程B执行结果倒是不肯定,也是说上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,因此上述的操做是线程不安全的,所以线程B读取的值天然也是不肯定的。修复这个问题的方式很简单,要么给writer()方法和read()方法添加同步手段,如synchronized或者给变量flag添加volatile关键字,确保线程A修改的值对线程B老是可见。

volatile内存语义

volatile在并发编程中很常见,但也容易被滥用,如今咱们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有以下两个做用

  • 保证被volatile修饰的共享gong’x变量对全部线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数能够被其余线程当即得知。

  • 禁止指令重排序优化。

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变量正是经过这种写-读方式实现对其余线程可见(但其内存语义实现则是经过内存屏障,稍后会说明)。

volatile禁止重排优化

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

/** * Created by zejian on 2017/6/11. * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创] */
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;

ok~,到此相信咱们对Java内存模型和volatile应该都有了比较全面的认识,总而言之,咱们应该清楚知道,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可以使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

若有错误,欢迎留言,谢谢!

参考资料:
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
http://blog.csdn.net/iter_zc/article/details/41843595
http://ifeve.com/wp-content/uploads/2014/03/JSR133%E4%B8%AD%E6%96%87%E7%89%881.pdf

《深刻理解JVM虚拟机》 《Java高并发程序设计》