《Java并发编程的艺术》读书笔记
并发编程,需要处理两个关键问题:
「通信」是指线程之间以何种机制来交换信息,线程之间的通信机制有两种:
「同步」是指程序中用于控制不同线程间操作发生相对顺序的机制
关于Java
并发:采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明,此外,它的同步是显式进行的。
我们先约定下「共享变量」的含义:包括了实例字段、静态字段和构成数组对象的元素
不包括局部变量、方法参数、异常处理器参数,因为它们是线程私有的,不会被线程共享,不存在竞争问题,与并发的关系不大
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。JMM规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下:
主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,工作内存可能会优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
Java线程之间的通信由JMM控制,例如线程A要与线程B通信,要经历下面两个步骤
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,JMM中定义了以下8种操作来完成。JVM实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作;如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,JMM只要求上述两个操作必须按顺序执行,但不要求是连续执行。
JMM规定了在执行上述8种基本操作时必须满足如下规则:
这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。但是这种定义极为烦琐,对于我们,可以利用后面会介绍的happens-before
原则,用来确定一个操作在并发环境下是否是安全的。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分3种类型
编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序
重排序可能会导致多线程程序出现内存可见性问题。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
Java编译器在生成指令序列的适当位置会插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序,JMM把内存屏障指令分为4类,如下
StoreLoad Barriers是一个全能型的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。
重排序的好处:提高程序的并行度,提高程序的性能。具体可以查看文章 为什么要指令重排序?.
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型:
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
as-if-serial
语义的意思:不管怎么重排序,(单线程)程序的执行结果不能被改变。
为了遵守as-if-serial
语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial
语义把单线程程序保护了起来,为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial
语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
书P28的例子很棒!
happens-before
的概念用于阐述操作之间的内存可见性,被阐述的两个操作既可以是在一个线程之内,也可以是在不同线程之间,happens-before
性质如下
happens-before
另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前happens-before
关系,并不意味着Java
平台的具体实现必须要按照happens-before
关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before
关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)
- 上面的第1点是
JMM
对程序员的承诺。从程序员的角度来说,可以这样理解happens-before
关系:如果A happens-before B
,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!- 上面的第2点是
JMM
对编译器和处理器重排序的约束原则。正如前面所言,JMM
其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before
关系本质上和as-if-serial
语义是一回事。
和happens-before
语义的对比:
as-if-serial
语义保证单线程内程序的执行结果不被改变,happens-before
关系保证正确同步的多线程程序的执行结果不被改变。as-if-serial
语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before
关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before
指定的顺序来执行的。as-if-serial
语义和happens-before
这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
从程序员的角度来看,具有happens-before
关系的两个操作,不会被重排序,但实际上,JMM把happens-before
要求禁止的重排序分为了下面两类
JMM对这两种不同性质的重排序,采取了不同的策略,如下
上面的做法,为程序员提供了足够强的内存可见性保证,在不改变程序执行结果的前提下,编译器和处理器可以做一些优化,这些优化提高了程序的执行效率。
happens-before
规则:
关于每个规则的解释与运用,参见书本P65
顺序一致性内存模型是一个理想化的理论参考模型,它提供内存可见性保证,它有两大特性
在顺序一致性内存模型中,一个多线程程序不管是不是同步的,所有线程都只能看到一个一致的整体执行顺序,这是因为「顺序一致性内存模型」的第2个特性「每个操作都必须原子执行且立刻对所有线程可见」。
在JMM中,正确同步的多线程程序的执行具有顺序一致性,即程序的执行结果与该程序在「顺序一致性内存模型」的执行结果相同;而未同步的程序在JMM中没有顺序一致性的保证,因此未同步程序的每个操作不一定立刻对所有的线程可见,导致所有线程看到的操作执行顺序也可能不一致,比如,一个线程把当前写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见,从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
那么,JMM如何区分一个程序是否正确同步呢?关键在于是否有数据竞争,JMM对数据竞争的定义如下
在一个线程中写一个变量, 在另一个线程读同一个变量, 而且写和读没有通过同步来排序。
若一个多线程程序没有数据竞争,那么就表明该多线程程序正确同步;若一个多线程程序有数据竞争,那么就表明该多线程程序未同步或者未正确同步。
这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用
下面是一个正确同步的程序
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执行write
方法后,线程B执行reader
方法,根据JMM规范,正确同步的多线程程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。该程序在两个内存模型中的执行时序对比图如下
顺序一致性模型中,所有操作完全按程序的顺序串行执行;在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码"逸出"到临界区之外)。
JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
volatile变量自身具有下列特性
由于有「原子性」,所以即使是64位的long型和double型变量,它的读/写也有原子性。
volatile变量写-读内存语义
对于下面的程序
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首先执行writer()方法,随后线程B执行reader()方法。在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。
volatile变量写-读的内存语义可以实现「线程之间通信」
重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型,下面是JMM针对编译器制定的volatile重排序规则表
从表中可以看出
回顾一下内存屏障的相关知识:Java编译器在生成指令序列的适当位置会插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序,JMM把内存屏障指令分为4类,如下
StoreLoad Barriers是一个全能型的屏障,它同时具有其他3个屏障的效果。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略,如下
在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
在 JSR-133 中,对volatile
的内存语义进行了增强。
在旧的JMM中,虽然不允许volatile
变量之间重排序,但是允许volatile
变量与普通变量的重排序,所以在旧的JMM中,volatile
的写-读没有锁的释放-获所具有的内存语义,为了提供一种比锁更轻量级的线程之间通信的机制,在 JSR-133 中,通过严格限制编译器和处理器对volatile
变量与普通变量的重排序,对volatile
的内存语义进行了增强,确保volatile
的写-读和锁的释放-获取具有相同的内存语义。
在现在的JMM中,只要volatile
变量与普通变量之间的重排序可能会破坏volatile
的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
volatile
与锁的对比:由于volatile
仅仅保证对单个volatile
变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile
更强大;在执行性能上,volatile
更有优势。
锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息,「发送消息」的操作主要是依靠锁的内存语义来实现,锁的内存语义如下
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结:
以ReentrantLock
为例,分析锁内存语义的具体实现机制。
ReentrantLock
的实现依赖于AQS
,AQS
使用一个整型的volatile
变量(命名为state
)来维护同步状态。ReentrantLock
分为公平锁和非公平锁,它们的内存语义如下:
volatile
变量state
。volatile
变量。CAS
更新volatile
变量,这个操作同时具有volatile
读和volatile
写的内存语义。CAS是CompareAndSwap,比较并交换,它是一种思想,一种算法。
基本思想:在CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,修改后的新值B,更新一个变量时,只有当变量的预期值A与地址V中的实际值一致,才会将地址V对应的值修改为B。
CAS是原子操作,基于CPU提供的原子操作指令实现。
CAS 的特点是避免使用互斥锁,当多个线程同时使用 CAS 更新同一个变量时,只有其中一个线程能够操作成功,而其他线程都会更新失败。不过和同步互斥锁不同的是,更新失败的线程并不会被阻塞,而是被告知这次由于竞争而导致的操作失败,但还可以再次尝试,也就是自旋。
查看Unsafe::compareAndSwapInt
方法
/** * Atomically updates Java variable to {@code x} if it is currently * holding {@code expected}. * * <p>This operation has memory semantics of a {@code volatile} read * and write. Corresponds to C11 atomic_compare_exchange_strong. * * @return {@code true} if successful */ @ForceInline public final boolean compareAndSwapInt(Object o, long offset, int expected, int x) { return theInternalUnsafe.compareAndSetInt(o, offset, expected, x); }
它的作用:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。
因为CAS
同时实现了volatile
读和volatile
写的内存语义,意味着编译器不能对CAS
与CAS
前面和后面的任意内存操作重排序。
为什么CAS同时具有volatile读和写的内存语义?
CAS
的实现原理:如果程序在多处理器上运行,那么cmpxchg
指令会被加上lock
前缀,lock
前缀的作用
lock
前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。现在的处理器,使用缓存锁定来保证指令执行的原子性。降低lock
前缀指令的执行开销。上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile
读和volatile
写的内存语义。
从对ReentrantLock
的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式
在1.7
版本AtomicInteger::getAndIncrement
就利用了CAS自旋volatile
变量进行值的更新:
public final int getAndIncrement() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return current; } }
其中get
方法为
public final int get() { return value; } private volatile int value;
所以AtomicInteger::getAndIncrement
逻辑如下:
在getAndIncrement
方法中,它的做法是:先获取到当前的 value
属性值,然后将 value
加 1
,赋值给一个局部的 next
变量,然而,这两步都是非线程安全的,但是内部有一个死循环,不断去做compareAndSet
操作,直到成功为止,也就是修改的根本在compareAndSet
方法里面。
在compareAndSet
方法中调用的是sun.misc.Unsafe.compareAndSwapInt(Object obj, long valueOffset, int expect, int update)
方法。compareAndSwapInt
基于的是CPU
的CAS
指令来实现的。所以基于 CAS
的操作可认为是无阻塞的,并且由于 CAS
操作是 CPU
原语,所以性能比较好。
value
变量使用volatile
修饰,就保证了线程间对value
变量的可见性,某个线程对value
的变量都可以及时地被其他线程看到。
AtomicInteger::getAndIncrement
方法保证了他和其他函数对 value
值得更新都是有效的,他所利用的是基于冲突检测的乐观并发策略。 可以想象,这种乐观在线程数目非常多的情况下,失败的概率会指数型增加。
public class AtomicStampedReference<V> { private static class Pair<T> { final T reference; final int stamp; private Pair(T reference, int stamp) { this.reference = reference; this.stamp = stamp; } static <T> Pair<T> of(T reference, int stamp) { return new Pair<T>(reference, stamp); } } private volatile Pair<V> pair; ... }
什么时候使用CAS
自旋volatile
共享变量来完成共享变量的更新?
CAS
通常比锁快得多(CAS通过硬件层次来体现,其效率和性能相比阻塞性的synchronized
和lock
来说更加的快),但是它取决于共享变量的争用程度,如果很多线程并发地对共享变量进行修改,那么线程可以会在自旋中进行无效地等待,降低了CPU的利用率,另外,要正确的使用CAS
进行编程,其难度要高于使用锁。
可以这样,当同步锁的性能还不是系统性能瓶颈的时候,可以先考虑使用同步锁synchronized
和lock
,但是当同步锁的性能已经是系统瓶颈,那就要开始考虑使用CAS+volatile
的非阻塞乐观锁的方式来降低同步锁带来的阻塞性能的问题。
volatile变量的读/写和CAS可以实现线程之间的通信,是整个concurrent包得以实现的基石
对于final域,编译器和处理器要遵守两个重排序规则
---自己的理解
第1点:由下面的写
final
域的重排序规则可以得出,编译器会在final
域的写之后、构造函数return
之前,插入StoreStore屏障,因为final
域的写和引用赋值都是store
操作,在屏障的作用下,它们不会被重排序第2点:由下面的读
final
域的重排序规则可以得出
写final域的重排序规则:禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中"逸出"。
读final域的重排序规则:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门用来针对这种处理器的。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
如果final域是引用类型:
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
JSR-133对JDK 5之前的旧内存模型的修补主要有两个
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性
原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。如果应用场景需要一个更大范围的原子性保证,可以使用synchronized
,synchronized
块之间的操作具备原子性。
可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。在多线程环境下通过volatile关键字保证变量的可见性,除此之外还能通过synchronized和final关键字实现可见性。
有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内表现为串行的语义"(Within-Thread As-If-Serial Semantics),后半句是指"指令重排序"现象和“工作内存与主内存同步延迟”现象。