《Java并发编程的艺术》第三章——Java内存模型

来源:互联网 发布:铣床和车床编程区别 编辑:程序博客网 时间:2024/06/05 05:20

Java内存模型

知识点:

  1. Java内存模型的基础。
  2. 重排序规则。
  3. Java内存模型的设计。
  4. 同步原语(synchronized、volatile、final)的内存语义。

1.Java内存模型的基础。
在命令式编程中,线程之间的通信方式有两种:
- 共享内存:即线程把数据写到内存,其他线程从内存读取,进行隐式的通信,但共享内存的同步必须依靠程序显式的指定,Java就采取这种方式。
- 消息传递:线程之间发送及接收消息,进行显式的通信。由于消息发送和接收存在顺序关系,所以消息传递的同步的隐式进行的。

这里写图片描述
如上图,线程A与线程B进行通信流程图。线程A把本地内存A中更新过的共享变量刷新到住内存中,线程B到主内存中去读取线程A之前已更新过的共享变量,从而达到线程间通信的目的。

2.重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3中类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器可以将多条指令重叠执行。如果不存在数据依赖性(如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性),处理器可以改变机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能存在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
这里写图片描述
上图1属于编译器重排序,2、3属于处理器重排序。重排序会导致多线程程序出现内存可见性问题。对于编译器,JMM(Java Memory Model)会禁止特定类型的编译器重排序。而处理器重排序,JMM会要求Java编译器在生成指令序列时,插入特定类型的内存屏障,来保证可见性。
JMM把内存屏障指令分为4类:
内存屏障类型
为了保证重排序不会影响到程序的可见性,编译器、runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。
【备注】:这里说的数据依赖性仅针对单个处理器中的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
demo:
这里写图片描述
如上图,A和C之间存在数据依赖性,B和C之间也存在数据依赖关系。因此在执行的指令序列中,C不能被重排序到A和B的前面,但A和B无数据依赖关系,可以被重排序。
as-if-serial语义把单线程程序保护起来,因此会产生单线程是按照程序的顺序来执行的错觉。但同样因为有了as-if-serial语义,我们在单线程里无需担心内存可见性问题。
虽然在单线程中依靠as-if-serial可以保证可见性,但在多线程中,却仍然存在由重排序引起的可见性问题:
这里写图片描述
假设有两个线程A和B,A先执行writer()方法,随后B执行reader()方法,在线程B执行操作4时,却不一定可以看到线程A在操作1对共享变量a的写入。
因为操作1和操作2没有数据依赖关系,所以编译器和处理器可以对这两个操作重排序,同理,操作3和操作4同样可以被重排序。若操作1和操作2发生重排序:
这里写图片描述
很明显,此时多线程程序语义被重排序破坏了!
若操作3和操作4发生重排序:
这里写图片描述
如上图,操作3和操作4存在控制依赖关系。当代码中存在控制依赖时,会影响指令序列执行的并行度。所以,编译器和处理器会采用猜测执行来克服对并行度的影响:以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Recoder Buffer,ROB)的硬件缓存中,当操作3的条件判断为真时,就把该计算结果写入变量i中。
3.JMM的设计
3.1 顺序一致性内存模型
当程序未正确同步时,就可能存在数据竞争。JMM规范对数据竞争的定义为:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
当代码中存在数据竞争时,程序的执行往往产生违反直觉的结果,如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
【备注】:这里的同步是指广义上的同步,包括对常用同步原语(synchronized,volatile和final)的正确使用。
【备注】:顺序一致性内存模型是一个理论参考模型,在设计时,处理器的内存模块和编程语言的内存模型都会以顺序一致性内存模型作为参照。
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,他为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型的视图如下:
这里写图片描述
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。如上图所示,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。
实例:假设有两个线程A和B并发执行。A操作为:A1->A2->A3。B操作为:B1->B2->B3。若这两个线程使用监视器锁来正确同步,A线程执行后释放监视器锁,随后B获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果为:
这里写图片描述
若这两个线程没有做同步,那么程序在顺序一致性模型中的执行效果为:
这里写图片描述
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。因为顺序一致性内存模型保证每个操作必须立即对任意线程可见。但在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。
实例:
这里写图片描述
如上图所示,程序已经正确的同步。假设A线程执行writer()方法后,B线程执行reader()方法。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。该程序在JMM与顺序一致性内存模型的执行时序图如下:
这里写图片描述
在JMM中,writer()与reader()方法内部是可以重排序的,即使发生重排序,在监视器互斥特性的保证下,程序执行结果仍然不会改变。
综上,我们可以得出:JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
【备注】:JMM允许临界区内的代码可以重排序,但不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义。
而对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值,JMM保证线程读操作读取到的值不会无中生有。
未同步程序在JMM与顺序一致性内存模型执行的差异:
- 顺序一致性模型保证单线程内的操作按照程序的顺序执行,但JMM不保证,可能发生重排序。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证。
- JMM不保证对64位long/double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
3.2 happens-before
在设计JMM时需要考虑一下两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。
- 编译器和处理器对内存模型的实现。希望内存模型对他们的舒束缚越少越好,这样就可以尽可能多的优化来提高性能。
这就需要JMM找到一个平衡点:要为程序员提供足够强的内存可见性保证的同时,也要对编译器和处理器的限制尽可能的放松。
JMM的设计示意图如下:
这里写图片描述
JMM对不同性质的重排序,采取不同的策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求,允许重排序。
【备注】:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如:若编译器在细致的分析后,认定一个锁只会被单个线程访问,则可消除这个锁,这种策略对于volatile变量同样适用。
happens-before是JMM最核心的概念。用来指定两个操作之间的执行顺序。这两个操作可以处于不同的线程。也就是说,可以通过happens-before规则来保证跨线程的内存可见性。JMM向程序员提供的happens-before规则向程序员提供了足够强的内存可见性保证。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(JMM对程序员的承诺)
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也会允许。(JMM对编译器和处理器重排序的约束原则)
【备注】:as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁原则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

4. 同步原语(synchronized、volatile、final)的内存语义。
4.1 volatile
volatile变量具有以下特性:
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
通过volatile变量的写-读可以实现线程之间的通信。从内存语义的角度说,volatile的写/读与锁的释放/获取有相同的内存效果。volatile写和锁的释放有相同的内存语义,volatile读和锁的获取有相同的内存语义。
实例:
这里写图片描述
假设线程A执行writer()方法之后,线程B执行reader()方法。可以建立如下happens-before关系:
- 根据程序次序规则,1 happens-before 2;3 happens-before 4;
- 根据volatile规则,2 happens-befored 3;
- 根据happens-before的传递性规则,1 happens-befored 4;
这里写图片描述
如上图,每一个箭头链接的两个节点,代表一个happens-before 关系,黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens-before 保证。
若A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
volatile内存语义是怎么实现的?
为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。JMM针对编译器制定的volatile重排序规则表如下:
这里写图片描述
根据上图我们可以总结出:
- 当第一个操作是volatile读时,第二个操作无论是什么都不允许重排序。
- 当第一个操作是volatile写时,第二个操作为volatile读时,不能重排序。
- 当第二个操作是volatile写时,第一个操作无论是什么都不允许重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化屏障的总数几乎不可能,所以JMM采取保守策略。
volatile写时插入内存屏障如下:
这里写图片描述
volatile读时插入内存屏障如下:
这里写图片描述
【备注】在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
4.2 synchronized 锁
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
实例:
这里写图片描述
假设线程A执行writer()方法,随后线程B执行reader()方法。则我们可以建立如下happens-before规则:
- 根据程序次序规则:1 happens-before 2,2 happens-before 3;4 happens-before 5;5 happens-before 6;
- 根据监视器锁规则:3 happens-before 4;
- 根据happens-before的传递性,2 happens-before 5;
关系图如下:
这里写图片描述
黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的保证。
在线程A释放锁之后,随后线程B获取同一个锁。通过2 happens-before 5的保证,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。
获取锁的内存语义:当线程获取锁时,JMM会把该线程的本地内存置为无效。从而使得被监视器保护的临界区代码必须从住内存中读取共享变量。
释放锁的内存语义:当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到住内存中。
锁内存语义是怎么实现的?
下面通过分析ReentrantLock源代码来说明锁内存语义的具体实现机制。
这里写图片描述
如上图所示,调用ReentrantLock lock()方法获取锁,调用unlock()方法释放锁。
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(简称 AQS)。AQS使用一个整形的volatile变量state来维护同步状态。
ReentrantLock分为公平所和非公平锁,以公平锁为例,其加锁过程为:
1>ReentrantLock:lock();
2>FairSync:lock();
3>AbstractQueuedSynchronizer:acquire(int arg)。
4>ReentrantLock:tryAcquire(int acquires);
在第4步真正开始加锁,源代码为:
这里写图片描述
而其解锁过程为:
1>ReentrantLock:unlock();
2>AbstractQueuedSynchronizer:release(int arg);
3>Sync:tryRelease(int releases);
在第3步真正开始释放锁,源代码为:
这里写图片描述
通过源代码可以看到,公平锁在获取锁时先读volatile变量,而释放锁时最后写volatile变量。根据happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。(第一个操作为读volatile变量时,无论第二个操作是什么,都不允许重排序。当第二个操作是写volatile变量时,无论第一个操作是什么,都不允许重排序。)
而非公平锁的获取过程为:
1>RenntrantLock:lock();
2>NofairSync:lock();
3>AbstractQueuedSynchronizer:compareAndSetState(int expect,int update);
在第3部开始加锁,源代码为:
这里写图片描述
该方法以原子操作的方式更新state变量。此操作具有volatile读和volatile写的内存语义。
CAS是如何同时具有volatile读和volatile写的内存语义的?
以sun.misc.Unsafe类下compareAndSwapInt(Object o,long offset)方法为例,底层是一个本地方法的调用,程序会根据当前处理器的类型来决定是否为其添加lock前缀。如果程序在多处理器上运行,则添加,否则,省略。而对于lock前缀来讲,有以下作用:
- 确保对内存的读-改-写操作原子执行。
- 禁止该指令,与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
所以CAS同时具有volatile读和volatile写的内存语义。
*【备注】:我们可以总结出,锁释放-获取的内存语义的实现至少有以下两种方式:
- 利用volatile变量的写-读所具有的内存语义。
- 利用CAS所附带的volatile读和volatile写的内存语义。*
【备注】:AQS是很重要的一个东西,concurrent中很多基础类都是以此来实现,后续会开一篇博客,进一步讲解AQS。
附一张concurrent包实现示意图:
这里写图片描述
4.3 final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
实例:
这里写图片描述
假设线程A执行writer()方法,随后另一个线程B执行reader()方法。执行writer()方法包含两个步骤:
- 构造一个FinalExample类型的对象。
- 把这个对象的引用赋值给引用变量obj。
假设线程B读对象引用和读对象的成员域之间没有重排序,则其可能的执行时序为:
这里写图片描述
如上图所示,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
而对于读final域来说,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。因为初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。
假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,则下图是一种可能的执行时序。
这里写图片描述
如上图所示,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被线程A写入,这是一个错误的读取操作。而读final域的重排序规则会保证,对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,所以读final域并不受影响。读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。


上面我们讨论的是final域是基础数据类型,如果final域是引用类型,将会有什么效果?
如下图实例代码:
这里写图片描述
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(上图6和2不能被重排序)
假设首先线程A执行writerOne()方法,执行完后线程B执行writerTwo()方法,执行完后线程C执行reader()方法。则下图是一种可能的线程执行时序:
这里写图片描述
JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。(如果要确保看的到,需要使用同步原语lock或volatile来保证内存可见性)


为什么final引用不能从构造函数内”溢出“?
前面我们提到,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:对象引用不能在构造函数中”溢出“
实例:
这里写图片描述
假设线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使操作2是构造函数的最后一步,且程序中操作2排在操作1后面,执行reader()方法的线程仍然可能无法看到final域被初始化后的值,因为操作1和操作2可能发生重排序。(写final域重排序规则并不能禁止这一点,因为他只会在写final域之后,构造函数return之前,插入一个StoreStrore屏障,这个屏障只能保证final域的写不会重排序到构造函数之外)。下图为可能的执行时序图:
这里写图片描述
如上图,在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到fianl域正确初始化之后的值。

【备注】:本文图片均摘自《Java并发编程的艺术》·方腾飞,若本文有错或不恰当的描述,请各位不吝斧正。谢谢!

阅读全文
0 0
原创粉丝点击