单核处理器也支持多线程多线程编码,CPU通过给每个线程分配CPU时间片来实现该机制。(一般为即使毫秒)
CPU通过时间片分配算法循环执行任务,当前任务执行一个时间片后会切换到下一个任务。切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态。
因此
任务从保存到在加载的过程,就是一个词上下文切换。
答案是不一定。
package com.test;
public class ConcurrencyTest {
private static final long count = 100000l;
public static void main(String[] args) throws InterruptedException{
concurrency();
serial();
}
private static void concurrency() throws InterruptedException{
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for(long i = 0;i < count; i++){
a += 5;
}
}
});
thread.start();
int b = 0;
for(long i = 0;i < count;i++){
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time + " ms,b =" + b);
}
private static void serial(){
long start = System.currentTimeMillis();
int a = 0;
for(long i = 0;i < count; i++){
a += 5;
}
int b = 0;
for(long i = 0;i < count;i++){
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial :" + time + " ms,b =" + b + ",a = " + a);
}
}
我们用如上的代码进行测试。并改变count 的值,会发现并发并不是总快于串行。(当然这也与电脑有关,比如多核CPU)
耗时的地方主要就是在线程的创建以及上下文切换上。
以两个线程为例。彼此都握有一个锁,彼此又都需要对方的锁,同时又不释放自己的锁。导致程序执行不下去了。
资源限制是指在进行并发编程时,程序执行速度受限制于计算机资源或软件资源。
如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候,程序不仅不会加快执行,反而会更慢,上下文切换使得时长变多。
Java代码在编译后会变为Jaa字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要转化为汇编指令在CPU上执行。
Java中所使用的并发机制依赖于JVM的实现和CPU指令。
volatile 是轻量级的synchronized,它在处理器开发中保证了共享变量的可见性。
同时它与synchronized也都保证了JMM的部分特性,见第三部分JMM。
Java语言规范第3版中对volatile的定义如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
伪共享:不是共享的数据,却是共享的数据的特性(同时只有一个线程可修改)。
原因:
可以参考如下文章进一步理解
缓存行,你听说过吗?
如下代码
instance = new Singleton();//instance 由 volatile 修饰
对应的汇编代码如下,
movb $0x0,0x1104800(%esi)
lock addl $0x0,(%esp)
发生了两件事
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。
当我们对volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
但就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。
因此,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了
当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
原理详解
lock
详情见 2.3 原子操作的实现原理 部分
嗅探
- IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。
- 处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充
JDK 7的并发包里新增一个队列集合类Linked-TransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。LinkedTransferQueue的代码如下。
/** 队列中的头部节点 */
private transient final PaddedAtomicReference<QNode> head;
/** 队列中的尾部节点 */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference T>{
// 使用很多4个字节的引用追加到64个字节
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
public class AtomicReference <V> implements java.io.Serializable {
private volatile V value;
// 省略其他代码
}
LinkedTransferQueue这个类使用一个内部类类型来定义队列头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64字节。
我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。
因为对于英特尔酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器的L1、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行。
这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中。
在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。
使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。
不是的。在两种场景下不应该
不过这种追加字节的方式在Java 7下可能不生效,因为Java 7变得更加智慧,它会淘汰或重新排列无用字段,需要使用其他追加字节的方式。
1.6 之前 synchronized 被称为重量级锁。但在这之后,通过锁升级的方式,让它变得不那么重了。
它是非公平锁,也即是不是先到先得,而是谁抢到谁得到。
利用synchronized 实现同步的基础:Java中的每一个对象都可以作为锁。 具体表现为一下3种形式。
当一个线程试图访问同步代码块时,他首先必须得到锁,推出或抛出异常时,必须释放锁。
JVM基于进入和退出Monitor对象,来实现方法同步和代码块同步,但两者实现细节不一样。代码同步时使用的monitorenter和monitorexit指令实现的,而方法同步时使用的另外一种方式实现的。JVM规范中并没有详细说明,但是,方法的同步同样可以用这两个指令来实现。
当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
synchronized用的锁是存在Java对象头里的。
在32位虚拟机中,1字宽等于4字节,即32bit,,如图。
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如图。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据。也即是当前锁的等级(自旋锁是一个类似循环的东西,本身还是轻量级锁)。
GC标记,也即是用于标记回收时使用的。
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如图。
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。
如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
它需要等待(有锁的线程到达)全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(有没有可能使用这个锁),
如果线程不处于活动状态,则将对象头设置成无锁状态(以便让暂停线程获取)
如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word(这个是用来判断对象适不适合作为偏向锁)
对于a.class类,有一个计数器和初始epoch值 (00)。 当进入t2线程,前19个都是升级为轻量锁。 当第20个时,此时计数器达到20,JVM认为这个类频繁升级有问题,将会对epoch值+1 (01)。
最后唤醒暂停的线程。暂停是需要时间的,也许抵达安全点的时候,线程就正好要释放锁了,因此有了重新偏向和无锁的情况。
图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:
-XX:BiasedLockingStartupDelay=0。
如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁,那么程序默认会进入轻量级锁状态。
-XX:-UseBiasedLocking=false
在线程执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储记录的空间,并将对象头中的MarkWorld复制到锁记录中,官方称为Displace Mark Word。
然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。
如果失败(此处失败是因为锁已经升级为了重量级锁,和原来的不一样),表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是两个线程同时争夺锁,导致锁膨胀的流程图。
会不停的空转,尝试获取锁,如果一直获取不到就会返回失败。
随着程序运行和性能监控信息的不断完善,虚拟机对于程序锁的状况预测越来越准确,于是虚拟机就更加“聪明”,就有了自适应的自旋锁。
自适应的自旋锁:
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了)
一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意
为“不可被中断的一个或一系列操作”。
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。
首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写
入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节
的内存地址。
Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的。
但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。
需要通过处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如图所示。
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入
系统内存中。
那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个
LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
缺点:
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁。
在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。
所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。
因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
在上图所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。
例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。
自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<Thread>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/** * 使用CAS实现线程安全计数器 */
private void safeCount() {
for (;;) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
i++;
}
}
1. ABA问题。
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新。
但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。
Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference, // 预期引用
V newReference, // 更新后的引用
int expectedStamp, // 预期标志
int newStamp // 更新后的标志
)
2. 循环时间长开销大。
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:
第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3. 只能保证一个共享变量的原子操作。
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性.
比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。
从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。
JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。
有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁
Java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰Java程序员。
JMM是语言级别上的内存模型,为保证多线程并发执行时的正确性,需要保证三个特性:
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。通过对内存的嗅探完成。
原子性:一步操作不可以被分割,也就是原子操作。对与一个线程想要修改一个变量,要么在它被其他线程修改前去做,要么在它被其他线程修改后去做。
顺序性:禁止进行部分指令重排序,即阻止特别的编译器、处理器对代码的优化导致的在多线程执行时,结果的不同。
在并发编程中,需要处理两个关键问题,这里的线程是指并发执行的活动实体:
通信
指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
同步
指程序中用于控制不同线程间操作发生相对顺序的机制。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明(不可见)。
如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
在Java中
共享的数据——堆内存:包含实例域、静态域、数组元素都存储在其中。
堆内存在线程之间共享(本部分用“共享变量”这个术语代指实例域,静态域和数组元素)。
不共享数据——栈:包含局部变量(Local Variables),方法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)
不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
注意,我们new一个局部变量,其实例是在堆中,而引用是在栈中。
Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意如图所示。
如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
如图上所示,本地内存A和本地内存B由主内存中共享变量x的副本。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
这些重排序可能会导致多线程程序出现内存可见性问题。
因此,
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
现代的处理器使用写缓冲区临时保存向内存写入的数据。
写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。
这个特性会对内存操作的执行顺序产生重要的影响:
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。
具体流程如下:
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。
当以这种时序执行时,程序就可以得到x=y=0的结果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。此时,处理器A的内存操作顺序被重排序了。
这里的关键是,由于写缓冲区仅对自己的处理器可见(可见性问题),它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致(导致顺序性问题)。
由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。
因此,这个问题会非常常见。我们在单线程中不会遇到也不会考虑的问题,在多线程中将需要我们注意。
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类。
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。
现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
从JDK 5开始,Java使用新的JSR-133内存模型(除非特别说明,书中针对的都是JSR-133内存模型,因此笔记也是这样)。
JSR-133使用happens-before的概念来阐述操作之间的内存可见性。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
规则
与程序员密切相关的happens-before规则如下。
此外,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。
happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
happens-before的定义很微妙,后文会具体说明happens-before为什么要这么定义。
happens-before与JMM的关系如图
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。
对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型。
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
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
遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:
根据happens-before的程序顺序规则,上面计算圆的面积的示例代码存在3个happens-
before关系。
这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。
这里A happens-before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执
行顺序)。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。
编译器和处理器遵从这一目标,从happens-before的定义我们可以看出,JMM同样遵从这一目标。
现在有如下代码
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
...
}
}
}
flag变量是个标记,用来标识变量a是否已被写入。
这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
过程解释
当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图。
操作1和操作2做了重排序。
程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了。
当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序执行的时序图。
在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。
为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。
以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。
猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义。
综上,
happens-before:
as-if-serial:
就宏观来看:
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
当程序未正确同步时,就可能会存在数据竞争。
Java内存模型规范对数据竞争的定义如下:
在一个线程中写一个变量
在另一个线程读同一个变量
而且写和读没有通过同步来排序
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(之前的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM对正确同步的多线程程序的内存一致性做了如下保证。
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。
顺序一致性内存模型有两大特性:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。
从示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。
当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。
假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是:
A 1 → A 2 → A 3 A1→A2→A3 A1→A2→A3
B线程也有3个操作,它们在程序中的顺序是:
B 1 → B 2 → B 3 。 B1→B2→B3。 B1→B2→B3。
假设这两个线程使用监视器锁来正确同步:
A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图所示。
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性内存模型中的执行示意图。
此处顺序不变,是指只看线程自己的执行顺序。
但是,在JMM中就没有这个保证(指顺序一致性内存模型的保证)。
未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。是否同步,顺序是否是串行化,都在于程序员自己对于同步原语的使用。
比如,
在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见
从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行
只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。
在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
下面,对前面的示例程序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规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。
如图。
线程A操作writer后线程B操作read。A先加锁,A解锁后B加锁,是happens-before的监视器锁规则。
此时,A操作对B操作就有了可见性。即当B获取锁时,也能立即获得A写的结果,即新的共享变量flag与a。
当然,原理上就是因为A上锁了,B获取不到,于是A先执行,B等着。同时因为内存更新,通过嗅探的方式,让共享内存及时更新。
顺序一致性模型中,所有操作完全按程序的顺序串行执行。
而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两
个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。
虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性 :
线程执行时读取到的值:
JMM保证线程读操作读取到的值 不会无中生有(Out Of Thin Air)的冒出来。
为了实现最小安全性,JVM在堆上分配对象时
因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证 未同步程序的执行结果与该程序在顺序一致性模型中的执行 结果一致 。
因为
未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。
第3个差异与处理器总线的工作机制密切相关。
在计算机中,数据通过总线在处理器和内存之间传递。
每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。
总线事务包括
读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。
这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。
由图可知,假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。
此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。
假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。
为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。
当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。
这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。
于是就有可能发生如下情况,由于32位寄存器无法一次性写64位,又不以事务方式进行。于是高低位出现了问题。
注意,在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。
从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
当声明共享变量为volatile后,对这个变量的读/写将会很特别。
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。
class VolatileFeaturesExample {
volatile long vl = 0L; // 使用volatile声明64位的long型变量
public void set(long l) {
vl = l; // 单个volatile变量的写
}
public void getAndIncrement () {
vl++; // 复合(多个)volatile变量的读/写
}
public long get() {
return vl; // 单个volatile变量的读
}
}
假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通变量
public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
vl = l;
}
public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}
public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
return vl;
}
}
如上面示例程序所示,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile变量自身具有下列特性。
从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:
volatile写和锁的释放有相同的内存语义;
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()方法。
根据happens-before规则,这个过程建立的happens-before关系可以分为3类:
在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。
黑色箭头表示程序顺序规则
橙色箭头表示volatile规则
蓝色箭头表示组合这些规则后提供的happens-before保证
这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写的内存语义如下。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
以上面示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。
下图是线程A执行volatile写后,共享变量的状态示意图。
如图,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。
volatile读的内存语义如下。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
如图所示,在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。
如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。
线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
下面来看看JMM如何实现volatile写/读的内存语义。
前文提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
表是JMM针对编译器制定的volatile重排序规则表。
第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
这里比较有意思的是,volatile写后面的StoreLoad屏障。
此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。
为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:
从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。
因为volatile写-读内存语义的常见使用模式是:
保守策略下的
上述volatile写和volatile读的内存屏障插入策略非常保守。
在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
… // 其他方法
}
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图中除最后的StoreLoad屏障外,其他的屏障都会被省略。
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。
在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行,如图所示。
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。
其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此,在旧的内存模型中,volatile的写/读没有锁的释放/获取所具有的内存语义。
为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:
严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写/读和锁的释放/获取具有相同的内存语义。
从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
因此,
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
class MonitorExample {
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
...
} // 6
}
假设线程A执行writer()方法,随后线程B执行reader()方法。
根据happens-before规则,这个过程包含的happens-before关系可以分为3类。
在图3-24中,每一个箭头链接的两个节点,代表了一个happens-before关系。
黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的happens-before保证。
图中表示在线程A释放了锁之后,随后线程B获取同一个锁。
在上图中,2 happens-before5。因此,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的MonitorExample程序为例,A线程释放锁后,共享数据的状态示意图如图
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:
下面对锁释放和锁获取的内存语义做个总结。
书借助的是ReentrantLock的源代码,来分析锁内存语义的具体实现机制。
以下列代码为例
class ReentrantLockExample {
int a = 0;
ReentrantLock lock = new ReentrantLock();
public void writer() {
lock.lock(); // 获取锁
try {
a++;
} finally {
lock.unlock(); // 释放锁
}
}
public void reader () {
lock.lock(); // 获取锁
try {
int i = a;
...
} finally {
lock.unlock(); // 释放锁
}
}
}
在ReentrantLock中,
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。
AQS使用一个整型的volatile变量(命名为state)来维护同步状态,马上我们会看到,这个volatile变量是ReentrantLock内存语义实现的关键。
是ReentrantLock的类图(仅画出与本文相关的部分)
ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。
使用公平锁时,加锁方法lock()调用轨迹如下。
在第4步真正开始加锁,下面是该方法的源代码。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); // 获取锁的开始,首先读volatile变量state
if (c == 0) {//0 状态,无人获取
// 自己是同步队列中的第一个吗同时尝试获取锁
if (isFirst(current) && compareAndSetState(0, acquires)) {
// 设置拥有者线程,当前线程
setExclusiveOwnerThread(current);
return true;
}
}
// 自己是当前锁的拥有者吗(重入锁)
else if (current == getExclusiveOwnerThread()) {
// 是的话就+1
int nextc = c + acquires;
// 小于则说明超过了最大整型,重入次数太多了
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
// 设置状态,此时是重入次数
setState(nextc);
return true;
}
return false;
}
从上面源代码中我们可以看出,加锁方法首先读volatile变量state。
在使用公平锁时,解锁方法unlock()调用轨迹如下。
在第3步真正开始释放锁,下面是该方法的源代码。
protected final boolean tryRelease(int releases) {
// 当前状态 - 释放数目
int c = getState() - releases;
// 如拥有者不是当前线程,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果减到0,说明成功释放
if (c == 0) {
free = true;
// 设置拥有者为空
setExclusiveOwnerThread(null);
}
// 设置状态
setState(c); // 释放锁的最后,写volatile变量state
// 返回结果
return free;
}
从上面的源代码可以看出,在释放锁的最后写volatile变量state。
公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。
根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。
现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下。
在第3步真正开始加锁,下面是该方法的源代码。
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
该方法以原子操作的方式更新state变量,本文把Java的compareAndSet()方法调用简称为CAS。
JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。
这里我们分别从编译器和处理器的角度来分析,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 和 atomic_windows_x86.inline.hpp。
这个本地方法的最终实现在openjdk的如下位置:
openjdk-7-fcs-src-b147-27_jun_2011\openjdk\hotspot\src\os_cpu\windows_x86\vm\atomic_windows_x86.inline.hpp(对应于Windows操作系统,X86处理器)。
下面是对应于intel X86(32位处理器)处理器的源代码的片段。
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest,jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
//汇编代码
__asm {
mov edx, dest // dest值移入edx
mov ecx, exchange_value//exchange_value值移入ecx
mov eax, compare_value//compare_value值移入eax
LOCK_IF_MP(mp)//如果是多处理器加锁
cmpxchg dword ptr [edx], ecx
//比较交换:以edx寄存器的值为首地址的双字长度(32位)与EAX比较交换
//成功则将ecx值写入edx寄存器的值为首地址的双字长度
//失败则将ecx写入eax
}
}
如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。
如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。
反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。
lock的作用前面出现过很多次了,此处就不再写入书中的内容。
volatile 在汇编里面的指令只是lock写或者lock读,cas在汇编里面直接用指令lock比较+写。
现在对公平锁和非公平锁的内存语义做个总结。
从书中对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。
由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面4种方式。
Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。
同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。
从整体来看,concurrent包的实现示意图如3-28所示。
与前面介绍的锁和volatile相比,对final域的读和写更像是普通的变量访问。下面将介绍final域的内存语义。
对于final域,编译器和处理器要遵守两个重排序规则。
即保证不需要用同步,就能保证初始化顺利完成,避免初始化之前就被其他线程读取。
public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public FinalExample () { // 构造函数
i = 1; // 写普通域
j = 2; // 写final域
}
public static void writer () { // 写线程A执行
obj = new FinalExample ();
}
public static void reader () { // 读线程B执行
FinalExample object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读final域
}
}
这里假设一个线程A执行writer()方法,随后另一个线程B执行reader()方法。下面我们通过这两个线程的交互来说明这两个规则。
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面。
现在让我们分析writer()方法。writer()方法只包含一行代码:
obj = new FinalExample()。
这行代码包含两个步骤,如下。
假设线程B读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序。
在图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。
而写final域的操作,被写final域的重排序规则 限定 在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。
写final域的重排序规则可以确保:
以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值1还没有写入普通域i)。
读final域的重排序规则是
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。
由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。
但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器的。
reader()方法包含3个操作。
读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。
而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。
读final域的重排序规则可以确保:
在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。
上面我们看到的final域是基础数据类型,如果final域是引用类型,将会有什么效果?请看下列示例代码。
public class FinalReferenceExample {
final int[] intArray; // final是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample () { // 构造函数
intArray = new int[1]; // 1
intArray[0] = 1; // 2
}
public static void writerOne () { // 写线程A执行
obj = new FinalReferenceExample (); // 3
}
public static void writerTwo () { // 写线程B执行
obj.intArray[0] = 2; // 4
}
public static void reader () { // 读线程C执行
if (obj != null) { // 5
int temp1 = obj.intArray[0]; // 6
}
}
}
本例final域为一个引用类型,它引用一个int型的数组对象。
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:
对上面的示例程序,假设首先线程A执行writerOne()方法,执行完后线程B执行writerTwo()方法,执行完后线程C执行reader()方法。
下图是一种可能的线程执行时序。
这里除了前面提到的1不能和3重排序外,2和3也不能重排序。
JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。
即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。
JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。
前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample () {
i = 1; // 1 写final域
obj = this; // 2 this引用在此"逸出"
}
public static void writer() {
new FinalReferenceEscapeExample ();
}
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
假设一个线程A执行writer()方法,另一个线程B执行reader()方法。
这里的操作2使得对象还未完成构造前就为线程B可见。
即使这里的操作2是构造函数的最后一步,且在程序中操作2排在操作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。
实际的执行时序可能如图所示。
从图中可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。
在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。
现在我们以X86处理器为例,说明final语义在处理器中的具体实现。
上面我们提到,写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。
读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。
由于X86处理器不会对写-写操作做重排序,所以在X86处理器中,写final域需要的StoreStore障屏会被省略掉。
同样,由于X86处理器不会对存在间接依赖关系的操作做重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。
也就是说,在X86处理器中,final域的读/写不会插入任何内存屏障。
在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变。
比如,一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final域的值时,却发现值变为1(被某个线程初始化之后的值)。
最常见的例子就是在旧的Java内存模型中,String的值可能会改变。
为了修补这个漏洞,JSR-133专家组增强了final的语义。通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:
happens-before是JMM最核心的概念。对应Java程序员来说,理解happens-before是理解JMM的关键
首先,让我们来看JMM的设计意图。从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素。
由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:
下面让我们来看JSR-133是如何实现这一目标的。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上面计算圆的面积的示例代码存在3个happens-before关系,如下。
JMM对这两种不同性质的重排序,采取了不同的策略,如下。
JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens-before B)。
JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:
例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。
再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。
这些优化既不会改变程序的执行结果,又能提高程序的执行效率
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。
因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下。
上面的1.是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证。
上面的2.是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:
《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。
在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。
双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。书中分析了双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。
这里就牵涉到我们使用单例的时候,写的懒汉和饿汉方法加载。
在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。
但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) // 1:A线程执行
instance = new Instance(); // 2:B线程执行
return instance;
}
}
在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。
对于UnsafeLazyInitialization类,我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化。示例代码如下。
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
在早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
public class DoubleCheckedLocking { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
前面的双重检查锁定示例代码的第7行
instance=new Singleton();
创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
上面3行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,详情见参考文献1的“Out-of-order writes”部分)。2和3之间重排序之后的执行时序如下。
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
根据《The Java Language Specification,Java SE 7 Edition》(后文简称为Java语言规范),所有线程在执行Java程序时必须要遵守intra-thread semantics。
intra-thread semantics保证重排序不会改变单线程内的程序执行结果。
换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。
这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。
为了更好地理解intra-thread semantics,请看如图所示的示意图(假设一个线程A在构造对象后,立即访问这个对象)。
如图所示,只要保证2排在4的前面,即使2和3之间重排序了,也不会违反intra-threadsemantics。
下面,再让我们查看多线程并发执行的情况。
由于单线程内要遵守intra-thread semantics,从而能保证A线程的执行结果不会被改变。但是,当线程A和B按图3-38的时序执行时,B线程将看到一个还没有被初始化的对象。
回到本文的主题,DoubleCheckedLocking示例代码的第7行
instance=new Singleton();
如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化。
下表是这个场景的具体执行时序
这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
这也是我们现在使用懒汉方式。
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance(); // instance为volatile,现在没问题了
}
}
return instance;
}
}
这个解决方案需要JDK 5或更高版本(因为从JDK 5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。
这个方案本质上是通过禁止图中的2和3之间的重排序,来保证线程安全的延迟初始化。
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
}
}
这个方案的实质是:允许上面的3行伪代码中的2和3重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序。
初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
在InstanceFactory示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被
初始化(符合情况4)。
由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用getInstance()方法来初始化InstanceHolder类)。
因此,在Java中初始化一个类或者接口时,需要做细致的同步处理。
Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java语言规范允许JVM的具体实现在这里做一些优化,见后文的说明)。
对于类或接口的初始化,Java语言规范制定了精巧而复杂的类初始化处理过程。Java初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,书中人为的把类初始化的处理过程分为了5个阶段)。
通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;
线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类。
根据Java内存模型规范的锁规则,这里将存在如下的happens-before关系。这个happens-before关系将保证:
线程C执行类的初始化的处理。
在第3阶段之后,类已经完成了初始化。因此线程C在第5阶段的类初始化处理过程相对简单一些
(前面的线程A和B的类初始化处理过程都经历了两次锁获取-锁释放,而线程C的类初始化处理只需要经历一次锁获取-锁释放)。
线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放锁
线程C在第5阶段的C1获取同一个锁,并在在第5阶段的C4之后才开始访问这个类。
根据Java内存模型规范的锁规则,将存在如下的happens-before关系。这个happens-before关系将保证:
《Java并发编程的艺术》方腾飞 魏鹏 程晓明