基础概念
在并发编程中,需要处理两个关键的问题:
1. 线程之间如何通讯
2. 线程之间如何同步
在命令式编程中,线程通讯有两种方式:
1. 共享内存
2. 消息传递
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态,进行隐式通讯
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息进行显式通讯
在共享内存模型中,同步是显式进行的。而在消息传递的并发模型中,同步是隐式进行的。
Java中采用的是内存共享的并发模式,线程之间的通讯总是隐式进行。
在java中所有的实例域、静态域、数组元素都存储在堆内存中,堆内存在线程间共享。局部变量、方法参数,异常处理器参数不会在线程中共享。
Java线程之间的通讯由Java内存模型(JMM)控制。JMM定义了线程和主内存之间的关系:
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读/写共享变量的副本。
指令重排序
重排序是指编译器和处理器为了优化程序性能,对编译的指令序列进行重新排序的一种手段。
分为3种:
1. 编译器优化的指令重排序
2.指令级并发的重排序
3.内存系统的重排序
上述的1属于编译器指令重排序,2和3属于处理器重排序
对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
对于处理器重排序,JMM的处理器重排序规则会要求编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止处理器重排序
现代处理器采用写缓冲区来临时保存向内存写入的数据。每个处理器上的写缓冲区,都仅仅对它所在的处理器可见。这个操作会对内存的操作顺序有所影响:
处理器对内存的 读/写 操作的执行顺序不一定与内存实际发生的 读/写 操作一致
JMM把内存屏障指分为4类:
如果两个操作访问同一个变量,且其中有一个是写的操作,那么这两个操作之间就存在这数据依赖性。
编译器和处理器在重排序时,会遵守数据依赖性。不会改变存在数据依赖性的两个操作之间的执行顺序
happens-before
在JMM中,如果一个操作的结果必须对另一个操作可见,那么这两个操作必须符合“happens-before”关系。
与程序员密切相关的“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()操作成功返回
需要注意的是,两个操作之间具有happen-before的关系,并不是指前一个操作必须在后一个操作之前执行。而是指,前一个操作的结果对后一个操作是可见的
double pi = 3.14 //A
double r = 1.0 //B
double area = pi*r*r //C
上面这段代码的happend-before关系为:
A happens-before B
B happens-before C
A Happens-before C
但是只有2和3是必须的,1不是必须的。因此,JMM把Happens-before要求禁止的重排序分为了两类:
1. 会改变程序执行结果的重排序
2. 不会改变程序执行结果的重排序
JMM对这两种不同性质的重排序,采取了不同的策略
1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
2. 对于不会改变程序执行结果的重排序,JMM编译器和处理器不做要求
as-if-serial
as-if-serial:不管怎么重排序,程序的执行结果不能被改变。
为了遵守as-if-serial语义,编译器和处理器不会对有数据依赖关系的操作进行重排序。但是如果两个操作之间没有数据依赖关系,那么编译器和处理器会对这两个操作做重排序
控制依赖性:
public class ReorderExample {
private int a=0;
private boolean flag=false;
public void writer(){
a=1; //1
flag=true; //2
}
public void read(){
if(flag){ //3
int i = a*a; //4
System.out.println(i);
}
}
}
操作1和操作2没有数据依赖,所以会重排序,同样的操作3和操作4也没有数据依赖,所以会重排序。但是在程序中,操作3和操作4存在控制依赖性。当代码中存在控制依赖性时,会影响指令序列的并发执行。为此,编译器和处理器都采用猜测执行来克服控制依赖性对指令序列并发执行的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前处理并计算a*a,然后把计算的结果,临时保存到一个叫做重排序缓存的硬件缓存中。当操作3的判断结果为true时,再把计算的结果,赋值给i。
顺序一致性
Java内存模型对数据竞争的定义:
在一个线程中写一个变量,同时,在另一个线程中读取同一个变量,但是读和写操作,没有通过同步来排序
Java内存模型,对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与顺序一致性内存模型中的执行结果相一致。
顺序一致性内存模型的量大特性:
1. 一个线程中的所有操作,必须安装程序的顺序执行
2. 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。
在顺序一致性模型中,所有操作都必须是原子执行并且是可见的
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过左右摆动的开关可以连接到任意的一个线程,同时每一个线程必须按照程序的顺序来读/写内存。
未同步程序的执行特性
对于未同步或为正确同步的多线程程序,JMM只提供了最先安全性:线程执行时督导的值,要么是之前某个线程写入的值,要么是默认值,JMM保证线程读操作取到的值不会无中生有的冒出来
为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象,JVM内部会同步这两个操作。异常,在清零的内存空间分配对象时,域的默认初始化已经完成了。
未同步程序在两个模型中的执行特性有如下几个差异:
1. 顺序一致性模型保证单线程内的操作会按照程序的顺序执行,而JMM不保证单线程的操作会按程序的顺序执行。
2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有程序能看到一致的操作执行顺序。
3. JMM不保证64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。(与处理器的总线工作机制有关)
处理器的总线处理机制
在计算机中,数据通过总线在处理器和内存中传送。每次处理器和内存之间的数据都是通过一系列步骤来完成的,这一系列步骤成总线事务。总线事务包括读事务和写事务。读事务负责从内存传送数据到处理器,写事务负责从处理器传送数据到内存。
总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和IO设备执行内存的读/写。
由上图可知,处理器A、B、C、D并发向总线发起总线事务,总线事务通过总线仲裁做出裁决,假设处理器A在竞争中获胜,从而继续它的总线事务,但是其他处理器则要阻塞等待处理器A的总线事务完成后才能再次执行内存访问。
这种工作机制保证了所有处理器对内存的访问以串行化的方式来执行。
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,开销比较大。因此当JVM在这种处理器上运行时,会把一个64位的long/double类型变量的写操作拆分位两个32位的写操作来执行。这两个32位的写操作有可能被分配到不同的总线事务中,此时对这个64位变量的写操作就不具有原子性。因此会产生意想不到的后果。
注意的是,JDK5.0之前,在旧的内存模型中,64位long/double类型变量的读/写操作都会拆分成两个32位的读/写操作。在jdk5.0后,只允许写操作拆分位两个32位的写操作。
Volatile的内存语义
语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量副本值刷新到主内存。
JMM针对编译器制定的volatile重排序规则表:
从上表可知:
1. 当第二个操作位volatile写操作,不管第一个操作是什么操作,编译器都不能重排序。这个规则确保了,volatile写之前的操作不会被编译器重排序到volatile写之后。
2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保,volatile读之后的操作不会被编译器重排序到volatile读之前
3. 当第一个操作是volatile写,第二个操作也是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码事,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
JMM基于保守策略来插入内存屏障;具体如下:
1. 在每个volatile写操作的前面插入一个StoreStore屏障
2. 在每个volatile写操作的后面插入一个StoreLoad屏障
3. 在每个volatile读操作的后面插入一个LoadLoad屏障
4. 在每个volatile读操作的后面插入一个LoadStore屏障
保守策略可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义
volatile写插入内存屏障后生成的指令序列示意图:
volatile读插入内存屏障后生成指令序列示意图:
volatile的写-读与锁的获取-释放具有相同的内存语义,那么可以用volatile替换锁嘛?
由于Volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能,volatile更有优势。如果想用volatile代替锁,需要十分慎重。
锁的内存语义
语义:当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量副本刷新到主内存中;当线程获取锁时,JMM会把该线程对应的本地内存中的共享变量副本置为无效。从而使得被监视器保护的临界代码必须从主内存中重新读取共享变量。
ReetrantLock分为公平锁和非公平锁。其内存语义如下:
1. 公平锁和非公平锁释放时,最后都要写一个volatile变量state
2. 公平锁获取时,首先会去读volatile变量
3. 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
锁的释放和获取的内存实现至少有以下两种方式:
1. 利用volatile变量的写-读所具有的内存语义
2. 利用CAS所附带的volatile读和volatile写的内存语义
concurrent包的实现
由于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读和写的内存语义来实现线程之间的通信。
concurrent包的实现示意图如下:
final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则:
1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能被重排序
2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
写Final域的重排序规则
1. JMM禁止编译器把final域的写重排序到构造函数之外
2. 编译器会在final域的之后,构造函数return之前,插入一个StoreStore内存屏障,禁止处理器把final域的写重排序到构造函数之外
如果final域位引用类型,写final的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
读final域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障
双重检查锁定与延迟初始化
延迟初始化是为了降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。
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 问题的根源出在这里
}
}
}
return instance;
}
}
在线程执行到第4行,代码读取到的Instance不为null时,instance引用的对象可能还没有被完成初始化。第7行创建了一个对象,这一行代码可以分为3行伪代码:
memory = allocate();// 1: 分配对象的内存空间
ctorInstance(memory);// 2: 初始化对象
instance = memory; //3: 设置instance指向刚分配的内存地址
在上面的伪代码中,2和3有可能被重排序。假设有两个线程访问,时序表如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:分配对象的内存空间 | |
t2 | A3: 设置instance指向内存空间 | |
t3 | B1:判断instance是否为空 | |
t4 | B2:由于instance不为null,线程B将访问instance引用的对象 | |
t5 | A2:初始化对象 | |
t6 | A4:访问instance引用的对象 |
A2和A3发送了重排序,但是java内存模型的intra-thread semantics将确保A2一定在A3之前执行。但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance的引用对象,此时,线程B将会访问到一个还未初始化的对象。
了解问题发生的根源后,有两个办法来解决线程安全的延迟初始化:
1. 不允许2和3重排序(使用volatile)
2. 允许2和3重排序,但不允许其他线程“看到”这个重排序()
基于volatile的解决方案
代码修改如下:
public class DoubleCheckedLocking{//1
private volatile 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 问题的根源出在这里
}
}
}
return instance;
}
}
基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁(初始化锁)。这个锁可以同步多个线程对同一个类的初始化。
public class InstanceFactory{
private static class InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance; //这里将导致InstanceHolder类被初始化
}
}
假设两个线程并发执行getInstance方法时,线程A先获取锁,然后开始初始化,此时线程B则因为没有获取锁,只能等待线程A执行完成后,释放锁。从而保证了线程安全。
根据JAVA语言规范,在首次发生下列任意一种情况时,一个类或者接口类型T将被立即初始化
1. T是一个类,而且一个T类型的实例被创建
2. T是一个类,且T中声明的一个静态方法被调用
3. T中声明的一个静态字段被赋值
4. T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
5. T是一个顶级类,而且一个断言语句嵌套在T内部执行
在上面的示例代码中,首次执行getInstance方法将导致InstanceHolder被初始化(第4种情况)
基于volatile的双重检查锁的方案有一个额外的优势:除了可以延迟初始化静态字段,还可以对实例字段实现延迟初始化。
在大多数的时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,使用volatile的延迟初始化方案了;如果确实需要对静态字段使用线程安全的延迟初始化,可以基于类的初始化方案。