在并发编程里,需要处理两个问题:
- 线程之间如何通信
- 线程之间如何同步。
通信指的是线程之间以何种机制来交换信息。在命令式编程里中,线程之间的通信机制有两种:共享内存和消息传递。 Java的并发采用的是共享内存模型。
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读、写共享变量的副本。
从图中可以看到,如果线程A和线程B之间要通信的话,必须经历如下的2步:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去;
- 线程B到主内存中去读取线程A之前已更新过的共享变量;
如图,假设初始时,本地内存A、B以及主内存中X均为0,线程A在执行时,把更新后的x值(假设为1)临时存放在自己的本地内存A中。当线程A和现场B需要通信时,线程A首先会把自己的本次内存中修改的x值刷新到主内存中,此时主内存中的x值变成了1。随后线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存中的x值也变成了1。
我们了解了Java内存模型的抽象结构之后,下面我们来简单聊一下一段Java代码到编译成字节码之后,再到最后处理器运行时进行指令序列的重排序过程。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为以下3种:
- 编译器优化的重排序
- 指令级并行的重排序
- 内存系统的重排序
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图:
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类:
- LoadLoad Barriers:确保Load1的数据的装载优先于Load2以及所有后续装载指令的装载。
- StoreStore Barriers:确保Store1数据对其他处理器可见
- LoadStore Barriers:确保Load1数据装载先于Store2以及所有后续Store指令刷新到内存当中。
- StoreLoad Barriers:确保Store1数据对其他处理器可见。
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这两个操作可以在同一个线程中,也可以在不同的线程中。
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果 A happens-before B,且B happens-before C,则A happens-before C。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖性分为以下三种:
- 写后读
- 写后写
- 读后写
这里所说的数据依赖性仅针对于单个处理器中执行的指令序列和单个线程中执行的操作
as-if-serial的意思是,不管怎么重排序,(单线程)程序的执行结果不能被改变。
所以为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为重排序会改变执行的结果。反之,如果不存在数据依赖关系,这些操作是可以被编译器和处理器重排序的。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。
顺序一致性模型是一个被计算机科学家理想化了的理想参考模型。
JMM对正确同步的多线程程序的内存一致性做了如下的保证:
顺序一致性定义:如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)—— 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
顺序一致性模型具有以下两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程上,同时每一个线程都必须按照程序的顺序来执行内存读/内存写操作。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。未同步程序在JMM内存模型和顺序一致性模型中存在以下几个差异:
- 顺序一致性模型保证了单线程内的操作会按照程序的顺序执行,而JMM不保证单线程内部的操作按照程序的顺序执行(比如指令重排序优化)
- 顺序一致性模型保证了所有线程只能看到一致的操作执行顺序,而JMM内存模型不保证所有线程看到一致性操作的执行顺序。
- JMM不保证对64位的long性和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读操作和内存写操作具有原子性。
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。总线处理具有总线锁定来同步对总线事务操作,在处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写操作。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间,最多只有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
那么为什么JMM不保证对64位的long性和double型变量的写操作具有原子性?
在一些32位的处理器上,如果要求对64位的数据写操作具有原子性,会有较大的开销。当JMM在这种处理器上运行时,可能会把一个64位long/double类型的变量的写操作拆分为两个32位的写操作进行执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这两个64位变量的写操作不具有原子性。
理解volatile特性的一个好方法是对volatile变量的单个读、写,看成是使用同一个锁对这些单个读、写操作做了同步。
简而言之,volatile变量自身具有如下特性:
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后的写入
- 对任意单个volatile变量的读、写具有原子性。但是对于类似于volatile++的复合操作不具备原子性。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个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关系的图形化表现形式如下:
volatile的内存语义的总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
volatile 内存语义通过使用store、write和read、load原子操作指令以及内存屏障来实现。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile关键字与锁的同步策略的优势和劣势:
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。
总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A
对共享变量所做修改的)消息。- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发
送消息。
锁(synchronized重量级锁)内存语义的实现:
synchronized重量级锁主要是通过lock(锁定)、unlock(解锁)原子指令来实现的。lock(锁定)、unlock(解锁)有两个规则:
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作,必须把此变量同步到主内存中(执行store和write操作)
下面我们来看以下代码:
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的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。AQS使用一个整型的volatile变量来维护同步状态。
ReentrantLock分为公平锁和非公平锁。ReentrantLock默认是非公平锁
使用非公平锁时,加锁方法lock()调用轨迹如下。
1)ReentrantLock:lock()。
2)NonfairSync:lock()。
3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
在第3步真正开始加锁,下面是该方法的源代码。
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
该方法以原子操作的方式更新state变量,本文把Java的compareAndSet()方法调用简称为
CAS。JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态
设置为给定的更新值。此操作具有volatile读和写的内存语义。
使用公平锁时,加锁方法lock()调用轨迹如下。
- 1)ReentrantLock:lock()。
- 2)FairSync:lock()。
- 3)AbstractQueuedSynchronizer:acquire(int arg)。
- 4)ReentrantLock:tryAcquire(int acquires)。
在第4步真正开始加锁,下面是该方法的源代码。
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
从上面源代码中我们可以看出,加锁方法首先读volatile变量state。
在使用公平锁时,解锁方法unlock()调用轨迹如下。
- 1)ReentrantLock:unlock()。
- 2)AbstractQueuedSynchronizer:release(int arg)。
- 3)Sync:tryRelease(int releases)。
在第3步真正开始释放锁,下面是该方法的源代码。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
从上面的源代码可以看出,在释放锁的最后写volatile变量state。
公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据
volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。
现在对公平锁和非公平锁的内存语义做个总结:
- 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
- 公平锁获取时,首先会去读volatile变量。
- 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子
方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。
如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。
- 首先,声明共享变量为volatile。
- 然后,使用CAS的原子条件更新来实现线程之间的同步。
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。
示例代码如下:
public clas 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()方法。
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
读者可能会问:为什么final引用不能从构造函数内“溢出”?
在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。
设计意图:
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM对这两种不同性质的重排序,采取了不同的策略,如下。
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种
重排序)。
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。先行发生是Java内存模型中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。
- 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()操作成功返回。
双重检查锁定与延迟初始化在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;
}
}
问题是:上面代码描述的单例模式不是线程安全的。如果有多个线程同时访问时,访问结果是线程不安全的。所以我们只需要添加同步锁synchronized关键字即可
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
问题:由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。为了解决这个问题,人们就提出了使用双重检查锁定来实现延迟初始化的示例代码。
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带来的性能开销。上面代码表面上看起来,似乎两全其美。但是它是错误的,上面代码在执行的过程中,也会造成线程不安全的问题。
简单来说,出现问题的元素就是指令重排序的问题。
前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
上面代码可能会进行重排序:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
- 不允许2和3重排序。
- 允许2和3重排序,但不允许其他线程“看到”这个重排序。
volatile关键字禁止指令重排序。我们可以利用这个特性,使用volatile关键字来解决这个问题:
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;
}
}
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.8.2节中的3行伪代码中的2和3重排序,但不允许非构造线程(这
里指线程B)“看到”这个重排序。
Java虚拟机类加载的条件:
- 1)T是一个类,而且一个T类型的实例被创建。
- 2)T是一个类,且T中声明的一个静态方法被调用。
- 3)T中声明的一个静态字段被赋值。
- 4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
- 5)T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。
Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C
到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且
每个线程至少获取一次锁来确保这个类已经被初始化过了.
第一阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
第5阶段:线程C执行类的初始化的处理。
处理器的内存模型顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参照。
JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存
模型是一个理论参考模型。
按程序类型,Java程序的内存可见性保证可以分为下列3类。
- 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。