并发编程的艺术--第三章:Java内存模型

Java内存模型的基础

处理两个关键问题:线程之间如何通信及线程之间如何同步?

这里的线程是指并发执行的活动实体,通信是指线程之间以何种机制来交换信息。

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

在消息传递并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

Java内存模型的抽象结构

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享
(本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方
法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception
Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影

响。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享
变量的写入何时对另一个线程可见。


如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。


从源代码到指令序列的重排序


上述的1属于编译器重排序,2和3属于处理器重排序。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁
止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的
顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此

现代的处理器都会允许对写-读操作进行重排序。


在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关
系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

·程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
·监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
·volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
·传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个
操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一
个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。



重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间

就存在数据依赖性。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器

不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,

不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)

程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial
语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操
作重排序,可能会改变程序的执行结果。

顺序一致性

Java内存模型规范对数据竞争的定义如下:

在一个线程中写一个变量,
在另一个线程读同一个变量,
而且写和读没有通过同步来排序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程
序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

顺序一致性内存模型有两大特性:

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






未同步程序在两个模型中的执行特性有如下几个差异:

1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的

操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。

2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程
能看到一致的操作执行顺序。

3)JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保

证对所有的内存读/写操作都具有原子性。

第3个差异与处理器总线的工作机制密切相关。


volatile的内存语义

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这
些单个读/写操作做了同步。

volatile变量自身具有下列特性:
·可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
·原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和
锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

volatile写的内存语义如下:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主
内存中读取共享变量。



对volatile写和volatile读的内存语义做个总结:
·线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程
发出了(其对共享变量所做修改的)消息。
·线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile
变量之前对共享变量所做修改的)消息。
·线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过
主内存向线程B发送消息。


下面是基于保守策略的JMM内存屏障插入策略:
·在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的后面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。



由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以
确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行

性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎。

锁的内存语义

对锁释放和锁获取的内存语义做个总结。
·线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A
对共享变量所做修改的)消息。
·线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共
享变量所做修改的)消息。
·线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发
送消息。


本文把Java的compareAndSet()方法调用简称为CAS。

JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态

设置为给定的更新值。此操作具有volatile读和写的内存语义。

intel的手册对lock前缀的说明如下:

1)确保对内存的读-改-写操作原子执行。

2)禁止该指令,与之前和之后的读和写指令重排序。
3)把写缓冲区中的所有数据刷新到内存中。

对公平锁和非公平锁的内存语义做个总结:
·公平锁和非公平锁释放时,最后都要写一个volatile变量state。
·公平锁获取时,首先会去读volatile变量。
·非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

锁释放-获取的内存语义的实现至少有下面两种方式:
1)利用volatile变量的写-读所具有的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现
在有了下面4种方式:
1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。
首先,声明共享变量为volatile。
然后,使用CAS的原子条件更新来实现线程之间的同步。
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的
通信。


final域的内存语义

对于final域,编译器和处理器要遵守两个重排序规则。
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用
变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能
重排序。

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:
1)JMM禁止编译器把final域的写重排序到构造函数之外。
2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障
禁止处理器把final域的写重排序到构造函数之外。

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final
域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final
域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经
被A线程初始化过了。

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内

对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值

给一个引用变量,这两个操作之间不能重排序。

happens-before

JMM把happens-before要求禁止的重排序分为了下面两类:

·会改变程序执行结果的重排序。

·不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略,如下:
·对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
·对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。


《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作
可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照
happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系
来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

上面的1)是JMM对程序员的承诺。

上面的2)是JMM对编译器和处理器重排序的约束原则。

因此,happens-before关系本质上和as-if-serial语义是一回事。

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

《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的
ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作
happens-before于线程A从ThreadB.join()操作成功返回


双重检查锁定与延迟初始化

问题:



这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在
A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程
B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访

问到一个还未初始化的对象。


在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化:
1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序。

1)基于volatile的解决方案

把instance声明为volatile型,当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程
环境中将会被禁止。


2)基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。


初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根
据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:
1)T是一个类,而且一个T类型的实例被创建。
2)T是一个类,且T中声明的一个静态方法被调用。
3)T中声明的一个静态字段被赋值。
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
5)T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T
内部被执行。

Java初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部
分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程

分为了5个阶段):

第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始
化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。



第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。


第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。


第4阶段:线程B结束类的初始化处理。



这个happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始
化和初始化类中声明的静态字段),线程B一定能看到

第5阶段:线程C执行类的初始化的处理。


这个happens-before关系将保证:线程A执行类的初始化时的写入操作,线程C一定能看
到。

注意:这里的condition和state标记是本文虚构出来的。Java语言规范并没有硬性规定一

定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。


基于volatile的双重检查锁定的方案有一个额外的优势:
除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。

基于类初始化的方案的实现代码更简洁。

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段
的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程
安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对
态字段
使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

Java内存模型综述

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺
序一致性内存模型为参照。

根据对不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分
为如下几种类型:
·放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(简称为TSO)。
·在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order内存
模型(简称为PSO)。
·在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了Relaxed
Memory Order内存模型(简称为RMO)和PowerPC内存模型。

注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因
为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。


所有处理器内存模型都允许写-读重排序,因为它们都使用了写缓存区。

由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓
存区中的写。

表3-12中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内
存模型设计得会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可
以做尽可能多的优化来提高性能。

由于常见的处理器内存模型比JMM要弱,Java编译器在生成字节码时,会在执行指令序
列的适当位置插入内存屏障来限制处理器的重排序。


JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存
模型是一个理论参考模型。


按程序类型,Java程序的内存可见性保证可以分为下列3类:

1、单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确
保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

2、正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行
结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过
制编译器和处理器的重排序
来为程序员提供内存可见性保证。

3、未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取

到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值

不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。

最小安全性保证对象默认初始化之后(设置成员域为0、null或
false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64位数据的非

原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值