java并发编程-JMM与JSR133


Java平台自动集成了线程以及多处理器技术,这种集成程度比Java以前诞生的计算机语言要厉害很多,该语言针对多种异构平台的平台独立性而使用的多线程技术支持也是具有开拓性的一面,有时候在开发Java同步和线程安全要求很严格的程序时,往往容易混淆的一个概念就是内存模型。究竟什么是内存模型?内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节,对象最终是存储在内存里面的,这点没有错,但是编译器、运行库、处理器或者系统缓存可以有特权在变量指定内存位置存储或者取出变量的值。JMM(Java Memory Model的缩写)允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,因此JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

JVM内存模型

对于JVM的内存模型,相信学Java的人并不陌生。JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。JVM是Java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,JVM屏蔽了与具体操作系统平台相关的信息,可以在上面执行Java的字节码程序,在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。JVM的内存模型如下:

  • Java栈:Java虚拟机栈是线程私有的,生命周期与线程相同,每个方法执行时都会创建一个栈帧(Stack Frame),描述的是Java方法执行的内存模型,用于存储局部变量,操作数栈,方法出口等。每个方法的调用都对应的出栈和入栈。虚拟机栈中执行每个方法的时候,都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
  • Java堆:堆是Java对象的存储区域,任何用new字段分配的Java对象实例和数组,都被分配在堆上,Java堆可使用-Xms -Xmx进行内存控制,此区域所有Java线程锁共享的,不是线程安全的,在JVM启动时创建,也是GC管理的主要区域。值得一提的是从JDK1.7版本之后,运行时常量池从方法区移到了堆上。
  • 方法区:它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据,存放了要加载的类的信息(名称、修饰符等)、类中的静态常量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当在程序中通过Class对象的getName.isInterface等方法来获取信息时,这些数据都来源于方法区。方法区是被Java线程锁共享的,不像Java堆中其他部分一样会频繁被GC回收,它存储的信息相对比较稳定,在一定条件下会被GC,当方法区要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。方法区也是堆中的一部分,就是我们通常所说的Java堆中的永久区 Permanet Generation。方法区在JDK1.7版本及以前被称为永久代,从JDK1.8永久代被移除。
  • 本地方法栈:本地方法栈(Native Stack)与Java虚拟机站(Java Stack)所发挥的作用非常相似,他们之间的区别在于虚拟机栈为虚拟机栈执行java方法(也就是字节码)服务,而本地方法栈则为使用到Native方法服务。
  • 程序计数器:严格来说是一个数据结构,用于保存当前正在执行的程序的内存地址,由于Java是支持多线程执行的,所以程序执行的轨迹不可能一直都是线性执行。当有多个线程交叉执行时,被中断的线程的程序当前执行到哪条内存地址必然要保存下来,以便用于被中断的线程恢复执行时再按照被中断时的指令地址继续执行下去。为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存,这在某种程度上有点类似于“ThreadLocal”,是线程安全的。

Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为以上几个不同的数据区域,每个区域都有各自的分工。这是一种整体上的理解,本人把它理解为一种鸟瞰图。就比如你坐在直升机上鸟瞰整个城市,也会看到城市的各个区域,开发区,商业区,居民区等等。但这仅仅只是一种相对静态的结构图,城市中的开发区,商业区,居民区之间肯定会存在交通,经济,贸易的来往。俗话说无规矩不成方圆,城市中各个区域之间的商品,资金流动必然遵循一定的规律,不然整个城市就乱套了。类比到Java虚拟机,自然而然的就会产生出一个问题,现在Java虚拟机的内存区域都划分好了,但各个区域如果需要发生数据交换,交换的规范怎么定义,怎么保证数据的安全性和一致性。这些问题就需要JMM内存模型来解答了。

JMM内存模型

JMM内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问出错。而JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,是使得Java程序能够一次编写,到处运行的基石。逻辑上大致理解如下图:

在前面的文章中曾详细讲述了在物理机器中CPU和内存之间为了解决内存与处理器的运算能力之间的几个数量级差距的问题,在CPU和内存之间引入了几级高速缓存,而引入高速缓存又带来了缓存一致性的问题。同时为了尽可能的利益CPU中的逻辑运行单元,又引入了CPU的流水线机制和指令乱序执行机制来优化指令执行顺序。缓存一致性问题的解决方案之前说过MESI协议,但实际上除了MESI协议,还有MSI,MOSI及Dragon Protocol等。而指令乱序执行则通过as-if-serial语义保证。如果不同物理架构都使用相同的内存模型,也就没这么多事了,但不同架构(主流的两种架构有两种:x86架构和arm架构)下的物理机拥有不一样的物理内存模型,因此JMM就是对这些底层协议的操作细节进行封装和抽象,以保证JMM在不同架构下,对上层保持一致的视图模型。这对开发人员来说是一种福音,由于有了JMM的承诺,因此开发人员只需要按照JMM提供的内存模型来并发编程,遵循JMM的规则,JMM就能为我们提供代码顺序性、共享变量可见性的保证,从而得到预期的执行结果,而不需要考虑不同平台的兼容性问题。

顺序一致性内存模型

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

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

顺序一致性内存模型为开发者提供的视图如下:

概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

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

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

从上图我们可以看出:常见的 5 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。

JMM的设计

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

1.程序员对内存模型的使用:程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。

2.编译器和处理器对内存模型的实现:编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。另外还要一个特别有意思的事情就是关于重排序问题,更简单的说,重排序可以分为两类,JMM对这两种不同性质的重排序,采取了不同的策略,如下:

重排序类型 JMM的应对策略
会改变程序执行结果的重排序 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
不会改变程序执行结果的重排序 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求

对于开发人员来说,JMM所处的位置:

JMM的承诺

如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent),即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同,这对于开发者来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。既然JMM为开发者们提供了一致的视图,那么JMM是如何保证一致性的呢。这里我们先来了解几个概念,即原子性,可见性,有序性。

原子性(Atomicity)

原子操作是指一个操作不会被线程调度机制打断,一旦开始,就一直运行到结束,中间不会有任何线程切换(context switch)。原子性可以保障读取到的某个属性的值是由一个线程写入的。 变量不会在同一时刻受到多个线程同时写入造成干扰。在Java中基本数据类型byte,short,int,float,boolean,char读写是原子操作,而对于32位系统的来说,long类型数据和double类型数据,它们的读写并非原子性的,如果在32位的JVM中对64位long 或double值的写操作是分成两次相邻的32位值写操作,在多线程的环境下,可能会有线程只读到了前32位,这种操作就是非原子性的,非原子性操作会受到多线程的干扰而产生结果混乱。但也不必太担心,因为读取到半个变量的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。JMM保证的原子性变量操作包括read、load、assign、use、store、write。这六个操作针对的是变量的原子操作,如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求。尽管虚拟机没有把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块synchronized关键字,因此synchronized块之间的操作也具备原子性。JMM内存模型对主内存与工作内存之间的具体交互协议定义的八种操作,具体如下:

  1. lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态。
  2. unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  4. load(载入):作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行此操作。
  6. assign(赋值):作用于工作内存变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量进行赋值的字节码指令时执行此操作。
  7. store(存储):作用于工作内存变量,把工作内存中一个变量的值传递到主内存中,以便后续 write 操作。
  8. write(写入):作用于主内存变量,把 store 操作从工作内存中得到的值放入主内存变量中。

可见性(Visibility)

可见性就是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。多线程环境下影响变量可见性的因素有三个,1.线程调度。2.工作内存和主内存没有及时刷新。3.指令重排序。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了volatile之外,还有Synchronized,Lock,Final也是可以的。

  • Synchronized:在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
  • Lock接口:Lock最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
  • Final关键字:Final的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到初始化了一半的对象),那么在其他线程中就能看见final字段的值。可见性是保障多线程操作中数据一致性和结果正确性的基石,

有序性(Ordering)

有序性是指对于单线程的执行代码。JMM的有序性表现为:如果在本线程内观察本线程的操作,所有的操作都是有序的;如果在一个线程中观察另一个线程的操作,所有的操作都是无序的。前半句指的是单线程内保证串行语义执行的一致性(as-if-serial),后半句指的是指令重排序和普通变量的工作内存与主内存同步延迟的现象。指令重排分为三种:

  • 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令流水线的重排:处理器采用了指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统的重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为高速缓存的存在,导致内存与缓存的数据同步存在时间差。

1.as-if-serial语义

不管怎么重排序,单线程下程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。一定注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中的代码执行操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。在 Java 里可以通过 volatile 关键字保证一定的有序性,还可以通过 synchronized、Lock 来保证有序性,因为 synchronized、Lock 保证了每一时刻只有一个线程执行同步代码相当于单线程执行,所以自然不会有有序性的问题;除此之外 Java 内存模型通过 happens-before 原则如果能推导出来两个操作的执行顺序就能先天保证有序性。

2.happens-before 原则

在JMM模型中除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。先行发生原则( happens-before )是JMM用来规定两个操作之间的偏序关系,这两个操作是可以跨线程的。happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有兴趣的可以google一下。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。happens-before中确定了8条规则,如果如果两个操作之间的关系可以从下列规则推导出来说明两个操作是有序的。happens-before并不限定指令重排序,如果如果重排序之后的执行结果与按happens-before关系来执行的结果一致,那么JVM允许这种重排序。happens-before原则保证了前后两个操作间不会被重排序且后者对前者的内存是可见的。happens-before的八条规则:

  1. 程序次序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作(一个线程内保证语义的串行性)。
  2. 锁定规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:volatile变量的写操作happens-before于后面对这个变量的读操作。
  4. 传递规则:如果A happens-before B且Bhappens-before C,那么A happens-before C。
  5. 线程启动规则:Thread对象的start()方法happens-before于此线程的每个一动作。
  6. 线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
  7. 线程终结规则:线程中所有的操作都happens-before于线程的终止。
  8. 对象终结规则:一个对象的初始化完成happens-before于他的finalize()方法的开始。

上面八条规则是对于Java语法层面的具体表现,对于happens-before的简单理解其实就两点。(1). 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。这一点是最好理解的,从字面意思就能看出来。(2). 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。也就是说,JMM允许这种重排序。换种说法,happens-before保证的是结果的一致性。

3. as-if-serial VS happens-before

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

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

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

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

JSR 133的目标包含了:

  • 保留已经存在的安全保证(像类型安全)以及强化其他的安全保证。例如,变量值不能凭空创建:线程观察到的每个变量的值必须是被其他线程合理的设置的。

  • 正确同步的程序的语义应该尽量简单和直观。

  • 应该定义未完成或者未正确同步的程序的语义,主要是为了把潜在的安全危害降到最低。

  • 程序员应该能够自信的推断多线程程序如何同内存进行交互的。

  • 能够在现在许多流行的硬件架构中设计正确以及高性能的JVM实现。

  • 应该能提供 安全地初始化的保证。如果一个对象正确的构建了(意思是它的引用没有在构建的时候逸出,那么所有能够看到这个对象的引用的线程,在不进行同步的情况下,也将能看到在构造方法中中设置的final字段的值。

  • 应该尽量不影响现有的代码。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
java并发编程-JVM架构与GC java并发编程-JVM架构与GC
作为一名Java开发者,掌握JVM的体系结构也是很有必要的,了解底层的东西,有助于更好的理解和掌握程序运行中的原理。JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的
2019-04-13
Next 
java并发编程-cpu的流水线 java并发编程-cpu的流水线
作为程序员,CPU在我们的工作中扮演了核心角色,因此了解处理器内部的工作方式对程序员来说不无裨益。CPU是如何工作的呢?一条指令执行需要多长时间?当我们讨论某个新款处理器拥有12级流水线还是18级流水线,甚至是更深的31级流水线时,这到些都
2019-03-30
  TOC