Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十)

JVM运行时内存结构回顾

在JVM相关的介绍中,有说到JAVA运行时的内存结构,简单回顾下
总体结构以下图所示,大体分为五大块
image_5c6b9b5a_35d
而对于方法区中的数据,是属于全部线程共享的数据结构
image_5c6b9b5a_1104
而对于虚拟机栈中数据结构,则是线程独有的,被保存在线程私有的内存空间中,因此这部分数据不涉及线程安全的问题
image_5c6b9b5a_3920
无论是堆仍是栈,他们都是保存在主内存中的
线程堆栈包含正在执行的每一个方法的全部局部变量(调用堆栈上的全部方法)。线程只能访问它本身的线程堆栈。
由线程建立的局部变量对于建立它的线程之外的全部其余线程是不可见的。
即便两个线程正在执行彻底相同的代码,两个线程仍将在每一个本身的线程堆栈中建立该代码的局部变量。所以,每一个线程都有本身的每一个局部变量的版本。
局部变量能够是基本类型在这种状况下,很显然它彻底保留在线程堆栈上
局部变量也能够是对象的引用,这种状况下,局部变量自己仍旧是在线程堆栈上,可是所指向的对象自己倒是在堆中的
很显然,全部具备对象引用的线程均可以访问堆上的对象,尽管是多个局部变量(引用),可是其实是同一个对象,因此若是这个对象有成员变量,那么将会出现数据安全问题。
image_5c6b9b5a_7e07
如上图所示,两个线程,localVariable1并 localVariable2两个局部变量位于不一样的线程,可是同时指向的是Object3
 
简单说,从上面能够看得出来,在Java中全部实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。
对于多线程的线程安全问题,根本在于共享数据的读写。

JMM(Java内存模型)

Java 内存模型做为JVM的一种抽象内存模型,屏蔽掉各类硬件和操做系统的内存差别,达到跨平台的内存访问效果。
Java语言规范定义了一个统一的内存管理模型JMM(Java Memory Model)
无论是堆仍是栈,数据都是保存在主存中的,整个的内存,都只是物理内存的一部分,也就是操做系统分配给JVM进程的那一部分
这部份内存按照运行区域的划分规则进行了区域划分
运行时内存区域的划分,能够简单理解为空间的分配,好比一个房间多少平,这边用于衣帽间,那边用于卧室,卧室多大,衣帽间多大
而对于内存的访问,规定Java内存模型分为主内存,和工做内存;工做内存就是线程私有的部分,主内存是全部的线程所共享的
每条线程本身的工做内存中保存了被该线程使用到的变量的主内存副本拷贝,全部的工做都是在工做内存这个操做台上,线程并不能直接操做主存,也不能访问其余线程的工做内存
你划分好了区域,好比有的地方用于存放局部变量,有的地方用于存放实例变量,可是这些数据的存取规则是什么?
换句话说,如何正确有效的进行数据的读取?显然光找好地方存是不行的,怎么存?怎么读?怎么共享?这又是另外的一个很复杂的问题
好比上面的两个线程对于Object3的数据读取顺序、限制都是什么样子的?
因此内存区域的分块划分和工做内存与主存的交互访问是两个不一样的维度
文档以下:
 
在对JMM进行介绍以前,先回想下计算机对于数据的读取
数据本质上是存放于主存(最终是存放于磁盘)中的,可是计算却又是在CPU中,很显然他们的速度有天壤之别
因此在计算机硬件的发展中,出现了缓存(一级缓存、二级缓存),借助于缓存与主存进行数据交互,并且现代计算机中已经不只仅只是有一个CPU
一个简单的示意图以下
image_5c6b9b5a_264f
对于访问速度来讲,寄存器--缓存--主存  依次递减,可是空间却依次变大
有了缓存,CPU将再也不须要频繁的直接从主存中读取数据,性能有了很大程度的提升(固然,若是须要的数据不在缓存中,那么仍是须要从主存中去读取数据,是否存在,被称为缓存的命中率,显然,命中率对于CPU效率有很大影响)
在速度提升的同时,很显然,出现了一个问题:
若是两个CPU同时对主存中的一个变量x (值为1)进行处理,假设一个执行x+1 另一个执行x-1
若是其中一个处理后另外一个才开始读取,显然并无什么问题
可是若是最初缓存中都没有数据或者说一个CPU处理过程当中还没来得及将缓存写入主存,另外一个CPU开始进行处理,那么最后的结果将会是不肯定的
这个问题被称为:缓存一致性问题
因此说:对于多个处理器运算任务都涉及同一块主存,须要一种协议能够保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等
 
关于缓存一致性的更多信息能够查阅
百度百科
缓存一致性(Cache Coherency)入门
缓存、缓存算法和缓存框架简介
总之,多个CPU,你们使用同一个主存,可是各自不一样的缓存,天然会有不一致的安全问题。
 
再回到JMM上来,Java Memory Model    
网址:
文中有说到:
The Java memory model specifies how the Java virtual machine works with the computer's memory (RAM). The Java virtual machine is a model of a whole computer so this model naturally includes a memory model - AKA the Java memory model.
Java内存模型指定Java虚拟机如何与计算机内存(RAM)一块儿工做。
Java虚拟机是整个计算机的模型,所以这个模型天然包括一个内存模型——也就是Java内存模型
 
对于多线程场景下,对于线程私有的数据是本地的,这个不容置疑,可是对于共享数据,前面已经提到,也是“私有的”
由于每一个线程对于共享数据,都会读取一份拷贝到本地内存中(也是线程私有的内存),全部的工做都是在本地内存这个操做台上进行的,以下图所示 
这本质就是一种read-modify-write模式,因此必然有线程安全问题的隐患
image_5c6b9b5a_7c72
与计算机硬件对于主存数据的访问是否是很类似?
须要注意的是,此处的主存并非像前面硬件架构中的主存(RAM),是一个泛指,保存共享数据的地方,多是主存也多是缓存,总之是操做系统提供的服务,在JMM中能够统一认为是主存
这里的本地内存,就好似对于CPU来讲的缓存同样,很显然,也会有一致性方面的问题
若是两个线程之间不是串行的,必然对于数据处理后的结果会出现不肯定性
因此JMM规范究竟是什么?
他其实就是JVM内部的内存数据的访问规则,线程进行共享数据读写的一种规则,在JVM内部,多线程就是这么读取数据的
具体的数据是如何设置到上图中“主存”这个概念中的?本地内存如何具体的与主存进行交互的?这都是操做系统以及JVM底层实现层面的问题
单纯的对于多线程编程来讲,就不用管什么RAM、寄存器、缓存一致性等等问题,就只须要知道:
数据分为两部分,共享的位于主存,线程局部的位于私有的工做内存,全部的工做都是在工做内存中进行的,也就意味着有“读取-拷贝-操做-回写”这样一个大体的过程
既然人家叫作JVM  java虚拟机,天然是五脏俱全,并且若是不能作到统一形式的内存访问模型,还叫什么跨平台?
 
若是把线程类比为CPU,工做内存类比寄存器、缓存,主存类比为RAM
那么JMM就至关于解决硬件缓存一致性问题的、相似的一种解决Java多线程读写共享数据的协议规范
因此说,若是要设计正确的并发程序,了解Java内存模型很是重要。Java内存模型指定了不一样线程如何以及什么时候能够看到其余线程写入共享变量的值,以及如何在必要时同步对共享变量的访问
因此再次强调,单纯的从多线程编程的角度看,记住下面这张图就够了!!!
因此再次强调,单纯的从多线程编程的角度看,记住下面这张图就够了!!!
每一个线程局部数据本身独有,共享数据会读取拷贝一份到工做内存,操做后会回写到主存
image_5c6b9b5a_7c72[1]
换一个说法,能够认为JMM的核心就是用于解决线程安全问题的,而线程安全问题根本就是对于共享数据的操做,因此说JMM对于数据操做的规范要求,本质也就是多线程安全问题的解决方案(缓存一致性也是数据安全的解决方案)
因此说理解了可能出现问题的缘由与场景,就了解了线程安全的问题,了解了问题,才能理解解决方案,那多线程到底有哪些主要的安全问题呢?

竞争场景

线程安全问题的本质就是共享数据的访问,没有共享就没有安全问题,因此说有时干脆一个类中都没有成员变量,也就避免了线程安全问题,可是很显然,这只是个别场景下适合,若是一味如此,就是因噎废食了
若是对于数据的访问是串行的,也不会出现问题,由于不存在竞争,可是很显然,随着计算机硬件的升级,多核处理器的出现,并发(并行)是必然,你不能为了安全就牺牲掉性能,也是一种因噎废食
因此换一个说法,为什么会有线程安全问题?是由于对于共享数据的竞争访问!
常见的两种竞争场景
  • read-modify-write(读-改-写)
  • check-then-act(检查后行动)

read-modify-write(读-改-写)

read-modify-write(读-改-写)能够简单地分为三个步骤:
  1. 读取数据
  2. 修改数据
  3. 回写数据
很显然,若是多个线程同时进行,将会出现不可预知的后果,假设两个线程,A和B,他们的三个步骤为A1,A2,A3  和 B1,B2,B3
若是按照A1,A2,A3,B1,B2,B3 或者 B1,B2,B3,A1,A2,A3的顺序,并不会出现问题
可是若是是交叉进行,好比A1,A2,B1,B2,B3,A3,那么就会出现问题,B对数据的写入被覆盖了!

check-then-act(检查后行动)

好比
if(x >1){
//do sth....
x--;
}
若是A线程条件知足后,尚未继续进行,此时B线程开始执行,条件判断后知足继续执行,执行后x的值并不知足条件了!
这也是一种常见的线程安全问题
 
很显然,单线程状况下,或者说全部的变量所有都是局部变量的话,不会出现问题,不然就极可能出现问题(线程安全问题并非必然出现的,长时间不出问题也极可能)
 
对于线程安全的问题主要分为三类
  • 原子性
  • 可见性
  • 有序性

原子性

原子 Atomic,意指不可分割,也就是做为一个总体,要么所有执行,要么不会执行
对于共享变量访问的一个操做,若是对于除了当前执行线程之外的任何线程来讲,都是不可分割的,那么就是具备原子性
简言之,对于别的线程而言,他要么看到的是该线程尚未执行的状况,要么就是看到了线程执行后的状况,不会出现执行一半的场景,简言之,其余线程永远不会看到中间结果
生活中有一个典型的例子,就是ATM机取款
尽管中间有不少的工做,好比帐户扣款,ATM吐出钞票等,可是从取钱的角度来看,对于用户倒是不可分割的一个过程
要么,取钱成功了,要么取款失败了,对于共享变量也就是帐户余额来讲,要么会减小,要么不变,不会出现钱去了余额不变或者余额减小,可是却没有看到钱的状况
既然是原子操做,既然是不可分割的,那么就是要么作了,要么没作,不会中间被耽搁,最终的结果看起来就好似串行的执行同样,不会出现线程安全问题
 
Java中有两种方式实现原子性
一种是使用锁机制,锁具备排他性,也就是说它可以保证一个共享变量在任意一个时刻仅仅被一个线程访问,这就消除了竞争;
另一种是借助于处理器提供的专门的CAS指令(compare-and-swap)
在Java中,long和double之外的任何类型的变量的写操做都是原子操做
也就是基础类型(byte int short char float boolean)以及引用类型的变量的写操做都是原子的,由Java语言规范规定,JVM实现
对于long和double,64位长度,若是是在32位机器上,写操做可能分为两个步骤,分别处理高低32位,两个步骤就打破了原子性,可能出现数据安全问题
有一点须要注意的是,原子操做+原子操做,并不是仍旧是原子操做
好比
a=1;
b=1;
很显然,都是原子操做,可是在a=1执行后,若是此时另外的线程过来读取数据,会读取到a=1,而b倒是没设置的中间状态

可见性

在多线程环境下,一个线程对某个共享变量进行更新以后,后续访问该变量的线程可能没法马上读取到这个更新的结果,甚至永远也没法读取到这个更新的结果。
这就是线程安全问题的另一个表现形式:可见性(Visibility )
若是一个线程对某个共享变量进行更新以后,后续访问该变量的线程能够读取到该更新的结果,那么就称这个线程对该共享变量的更新对其余线程可见,不然就称这个线程对该共享变量的更新对其余线程不可见。
简言之,若是一个线程对共享数据作出了修改,而另外的线程却并无读取到最新的结果,这是有问题的
多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据,一般也是不被但愿的

为何会出现可见性问题?
由于数据本质是要从主存存取的,可是对于线程来讲,有了工做内存,这个私有的工做台,也就是read-modify-write模式
即便线程正确的处理告终果,可是却没有及时的被其余的线程读取,而别人却读取了错误的结果(旧数据),这是一个很大的问题
因此此处也能够看到,若是仅仅是保障原子性,对于线程安全来讲,彻底是不够的(有些场景可能足够了)
原子性保障了不会读取到中间结果,要么是结束要么是未开始,可是若是操做结束了,这个结果然的就能看到么?因此还须要可见性的保障

有序性

关于有序性,首先要说下重排序的概念,若是未曾有重排序,那么也就不涉及这方面的问题了
好比下面两条语句
a=1;
b=2;
在源代码中是有顺序的,通过编译后造成指令后,也必然是有顺序的
在一个线程中从代码执行的角度来看,也老是有前后顺序的
好比上面两条语句,a的赋值在前,b的赋值在后,可是实际上,这种顺序是没有保障的
处理器可能并不会彻底按照已经造成的指令(目标代码)顺序执行,这种现象就叫作重排序

为何要重排序?

重排序是对内存访问操做的一种优化,他能够在不影响单线程程序正确性的前提下进行必定的调整,进而提升程序的性能
可是对于多线程场景下,就可能产生必定的问题
固然,重排序致使的问题,也不是必然出现的
好比,编译器进行编译时,处理器进行执行时,都有可能发生重排序
先声明几个概念
  • 源代码顺序,很明显字面意思就是源代码的顺序
  • 程序顺序,源码通过处理后的目标代码顺序(解释后或者JIT编译后的目标代码或者干脆理解成源代码解析后的机器指令)
  • 执行顺序,处理器对目标代码执行时的顺序
  • 感知顺序,处理器执行了,可是别人看到的并不必定就是你执行的顺序,由于操做后的数据涉及到数据的回写,可能会通过寄存器、缓存等,即便你先计算的a后计算的b,若是b先被写回呢?这就是感知顺序,简单说就是别人看到的结果
在此基础上,能够将重排序能够分为两种,指令重排序和存储重排序
下图来自《Java多线程编程实战指南-核心篇》
image_5c6b9b5a_3147
编译器可能致使目标代码与源代码顺序不一致;即时编译器JIT和处理器可能致使执行顺序与程序顺序不一致;
缓存、缓冲器可能致使感知顺序不一致

指令重排序

无论是程序顺序与源代码顺序不一致仍是执行顺序与程序顺序不一致,结果都是指令重排序,由于最终的效果就是源代码与最终被执行的指令顺序不一致
以下图所示,无论是哪一段顺序被重拍了,最终的结果都是最终执行的指令乱序了
image_5c6b9b5a_407e
ps:Java有两种编译器,一种是Javac静态编译器,将源文件编译为字节码,代码编译阶段运行;JIT是在运行时,动态的将字节码编译为本地机器码(目标代码)
一般javac不会进行重排序,而JIT则极可能进行重排序
此处不对为何要重排序展开,简单说就是硬件或者编译器等为了可以更好地执行指令,提升性能,所作出的必定程度的优化,重排序也不是随随便便的就改变了顺序的,它具备必定的规则,叫作貌似串行语义As-if-serial Semantics,也就是从单线程的角度保障不会出现问题,可是对于多线程就可能出现问题。
貌似串行语义的规则主要是对于具备数据依赖关系的数据不会进行重排序,没有依赖关系的则可能进行重排序
好比下面的三条语句,c=a+b;依赖a和b,因此不会与他们进行重排序,可是a和b没有依赖关系,就可能发生重排序
a=1;
b=2;
c=a+b;

存储重排序

为何会出现执行一种顺序,而结果的写入是另外的一种顺序?
前面说过,对于CPU来讲并非直接跟主存交互的,由于速度有天壤之别,因此有多级缓存,有读缓存,其实也有写缓存
有了缓存,也就意味着这中间就多了一些步骤,那么就可能即便严格按照指令的顺序执行,可是从结果上看起来倒是乱序的
指令重排序是一种动做,实际发生了,而存储重排序则是一种现象,从结果看出来的一种现象,其实自己并无在执行上重拍,可是这也可能引发问题

如何保证顺序?

貌似串行语义As-if-serial Semantics,只是保障单线程不会出问题,因此有序性保障,能够理解为,将貌似貌似串行语义As-if-serial Semantics扩展到多线程,在多线程中也不会出现问题
换句话说,有序性的保障,就是貌似串行语义在逻辑上看起来,有些必要的地方禁止重排序
从底层的角度来看,是借助于处理器提供的相关指令内存屏障来实现的
对于Java语言自己来讲,Java已经帮咱们与底层打交道,咱们不会直接接触内存屏障指令,java提供的关键字synchronized和volatile,能够达到这个效果,保障有序性(借助于显式锁Lock也是同样的,Lock逻辑与synchronized一致)

happens-before 原则

关键字volatile和synchronized均可以保证有序性,他们都会告知底层,相关的处理须要保障有序,可是很显然,若是全部的处理都须要主动地去借助于这两个关键字去维护有序,这将是一件繁琐痛苦的事情,并且,也说到了重排序也并非随意的
Java有一个内置的有序规则,也就是说,对于重排序有一个内置的规则实现,你不须要本身去动脑子思考,动手去写代码,有一些有序的保障Java自然存在,简化了你对重排序的设计与思考
这个规则就叫作happens-before 原则
若是能够从这个原则中推测出来顺序,那么将会对他们进行有序性保障;若是不能推导出来,换句话说不与这些要求相违背,那么就可能会被重排序,JVM不会对有序性进行保障。
程序次序规则(Program Order Rule)
在一个线程内,按照程序代码顺序,书写在前面的操做先行发生于书写在后面的操做。准确地说,应该是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构,只要确保在一个线程内最终的结果和代码顺序执行的结果一致便可,仍旧可能发生重排序,可是得保证这个前提
管程锁定规则(Monitor Lock Rule)
一个unlock操做先行发生于后面对同一个锁的 lock操做。这里必须强调的是同一个锁,而“后面”是指时间上的前后顺序
volatile变量规则(Volatile Variable Rule)
对一个volatile变量的写操做先行发生于后面对这个变量的读操做,这里的“后面”一样是指时间上的前后顺序。
线程启动规则(Thread Start Rule)
Thread对象的start()方法先行发生于此线程的每个动做。你必须得先启动一个线程才能有后续
线程终止规则(Thread Termination Rule)
线程中的全部操做都先行发生于对此线程的终止检测,也就是说全部的操做确定是要在线程终止以前的,终止以后就不能有操做了,能够经过Thread.join()方法结束、Thread. isAlive()的返回值等手段检测到线程已经终止执行。
线程中断规则(Thread Interruption Rule)
对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,也就是你得先调用方法,才会产生中断,你不能别人发现中断信号了,你居然你都还没调用interrupt方法,能够经过Thread.isinterrupted ()方法检测到是否有中断发生。
对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalizeO方法的开始,先生后死,这个是必须的
传递性(Transitivity)
若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。
 
再次强调:对于happens-before规则,不须要作任何的同步限制,Java是自然支持的
《深刻理解Java虚拟机:JVM高级特性与最佳实践》中有一个例子对于理解该原则有所帮助

private int value = 0; html

 

public int getValue() { java

return value; 程序员

} 算法

 

public void setValue(int value) { 编程

this.value = value; 数组

}缓存

假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用getValue方法,那么B的返回值是多少?
对照着hp原则
不是同一个线程,因此不涉及:程序次序规则
不涉及同步,因此不涉及:管程锁定规则
没有volatile关键字,因此不涉及:volatile变量规则
没有线程的启动,中断,终止,因此不涉及:线程启动规则,线程终止规则,线程中断规则
没有对象的建立于终结,因此不涉及:对象终结规则
更没有涉及到传递性
因此一条规则都不知足,因此,尽管线程A在时间上与线程B具备前后顺序,可是,却并不涉及hp原则,也就是有序性并不会保障,因此线程B的数据获取是不安全的!!
好比的确是先执行了,可是没有及时写入呢?
简言之,时间上的前后顺序,并不表明真正的先行发生(hp),并且,先行发生(hp)也并不能说明时间上的前后顺序是什么
这也说明,不要被时间前后迷惑,只有真正的有序了,才能保障安全
也就是要么知足hp原则了(自然就支持有序了),或者借助于volatile或者synchronized关键字或者显式锁Lock对他们进行保障(显式手动控制有序),才能保障有序
 
happens-before是JMM的一个核心概念,由于对于程序员来讲,但愿一个简单高效最重要的是要易用的,易于理解的编程模型,可是反过来讲从编译器和处理器执行的角度来看,天然是但愿约束越少越好,没有约束,那么就能够高度优化,很显然二者是矛盾的,一个但愿严格、简单、易用,另外一个则但愿尽量少的约束;
happens-before则至关于一个折中后的方案,两者的一个权衡,以上是基本大体的的一个规范,有兴趣的能够深刻研究happens-before原则  
原子性、可见性、有序性
前面说过,原子性保障了要么执行要么不执行,不会出现中间结果,可是即便原子了,不可分割了,可是是否对另一个可见,是没法保障的,因此须要可见性
而有序性则是另外的线程对当前线程执行看起来的顺序,因此若是都不可见,何谈有序性,因此可见性是有序性的基础
另外,有序性对于可见性是有影响的,好比某些操做原本在前,结果是可见的,可是重排序后,被排序到了后面,这就可能致使不可见,好比父线程的操做对子线程是可见的,可是若是有些位置顺序调整了呢?   

总结

Java内存区域的划分是对于主存的一种划分,存储的划分,而这个主存则是分配给JVM进程的内存空间,而JVM的这部份内存只是物理内存的一部分
这部份内存有共享的主存储空间,还有一部分是线程私有的本地内存空间
线程所用到的全部的变量都位于线程的本地内存中,局部变量自己就在本地内存,而共享变量则会持有一份私有拷贝
线程的操做台就是这个本地内存,既不能直接访问主存也不能访问其余线程本地内存,只能借助于主存进行交互
JMM模型则是对于JVM对于内存访问的一种规范,多线程工做内存与主内存之间的交互原则进行了指示,他是独立于具体物理机器的一种内存存取模型
 
对于多线程的数据安全问题,三个方面,原子性、可见性、有序性是三个相互协做的方面,不是说保障了任何一个就万事大吉了,另外也并不必定是全部的场景都须要所有都保障才可以线程安全
好比volatile关键字只能保障可见性和有序性以及自身修饰变量的原子性,可是若是是一个代码段却并不能保障原子性,因此是一种弱的同步,而synchronized则能够从三个维度进行保障
这三个特性也是JMM的核心,对相关的原则进行了规范,因此归纳的说什么是JMM?他就只是一个规范概念
Java经过提供同步机制(synchronized、volatile)关键字借助于编译器、JVM实现,依赖于底层操做系统,对这些规范进行了实现,提供了对于这些特性的一个保障
反复提到的下面的这个图就是JMM的基础结构,而延展出来的规范特性,就是基于这个结构,而且针对于多线程安全问题提出的一些解决方案
只要正确的使用提供的同步机制,就可以开发出正确的并发程序
如下图为结构基础,定义的线程私有数据空间与主存之间的交互原则
image_5c6b9b5a_7c72[2]
相关文章
相关标签/搜索