标签:
一概述
本文属于《java并发编程的艺术》读书笔记系列,继续第三章java内存模型。
二重排序
2.1数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2.2as-if-serial语义
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
这里作者举了计算面积的例子:double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
2.3重排序对多线程的影响
作者给了例子,介绍重排序对多线程的影响,这里不贴出来了参照原书,只写结论。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
三顺序一致性
3.1数据竞争与顺序一致性保证
当程序未正确同步时,就会存在数据竞争。java内存模型规范对数据竞争的定义如下:
JMM对正确同步的多线程程序的内存一致性做了如下保证:
3.2顺序一致性内存模型
顺序一致性内存模型有两大特性:
3.3同步程序的顺序一致性效果
下面我们对前面的示例程序ReorderExample用监视器来同步,看看正确同步的程序如何具有顺序一致性。
class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer() {//获取锁 a = 1; flag = true; } //释放锁 public synchronized void reader() {//获取锁 if (flag) { int i = a; …… } } //释放锁 }上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。从这里我们可以看到JMM在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
3.4未同步程序的执行特性
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写具有原子性。当JVM在这种处理器上运行时,会把一个64位long/ double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写将不具有原子性。从JSR -133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/ double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR
-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
四volatile的内存语义
作者给了个例子,假设线程A执行writer()方法之后,线程B执行reader()方法。
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 …… } } }
这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写的内存语义如下:
volatile读的内存语义如下:
线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。在读flag变量后,本地内存B已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值也变成一致的了。
下面对volatile写和volatile读的内存语义做个总结:
前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile重排序规则表:
是否能重排序 | 第二个操作 | ||
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
4.4JSR-133为什么要增强volatile的内存语义
因此在旧的内存模型中 ,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。这里说下使用volatile的条件:
对变量的写不依赖于变量的当前值,访问变量是不需要加锁。
五锁的内存语义
对比锁释放-获取的内存语义与volatile写-读的内存语义相同。不在重复。
锁内存语义的实现:参照我之前整理的reentrantlock。
5.1CAS
本文把java的compareAndSet()方法调用简称为CAS。JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。关于cas也可以参照我之前的整理的。
这里我们分别从编译器和处理器的角度来分析,CAS如何同时具有volatile读和volatile写的内存语义。
前文我们提到过,编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。
下面我们来分析在常见的intel x86处理器中,CAS是如何同时具有volatile读和volatile写的内存语义的。
下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp
intel的手册对lock前缀的说明如下:
上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。
经过上面的这些分析,现在我们终于能明白为什么JDK文档说CAS同时具有volatile读和volatile写的内存语义了。
现在对公平锁和非公平锁的内存语义做个总结:
从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式:
Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:
<java并发编程的艺术>读书笔记-第三章java内存模型(一)
标签:
原文地址:http://blog.csdn.net/bohu83/article/details/51700782