Java并发编程的艺术-学习笔记-3

Java内存模型

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

在共享内存的并发模型里,线程之间共享程序的公共状态,通过读一写内存中的公共状态进行隐式传递。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

Java的并发采用的共享内存模型。

Java内存模型的基础

内存模型的抽象结构

JMM通过控制主内存与每个线程的本地内存之间进行交互,提供内存可见性的保证。

抽象地看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓存区、寄存器与其他的硬件和编译器优化。

在Java中,所有实例域、静态域和数组元素都储存在堆内存中,堆内存在线程之间共享。

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

为了提高性能,编译器和处理器会对指令进行一共三种重排序。

a. 编译器优化的重排序(不改变单线程程序语义的前提下)

b. 指令级并行的重排序(不存在数据依赖的前提下)

c. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看起来像是在乱序执行。

重排序会导致多线程出现内存不可见性问题。
对于编译器,JMM的编译器重排序规则会禁止特定类型的重排序
对于处理器,JMM的处理器要求编译器生成指令序列时,插入特定类型的内存屏障。

volatile的内存语义

编译器不会对volatile读与volatile读后面的任意内存操作重排序;(volatile读先)
编译器不会对volatile写与volatile写前面的任意内存操作重排序。(volatile写后)
组合两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。

volatile变量的特性:

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

Java锁的内存语义

锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行之外,还可以让释放锁的线程获取同一个锁的线程发送消息。

锁释放与volatile的读写有着相同的内存语义

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

底层实现

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变量

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-写-改操作,是顺序计算图灵机的异步等价机器。volatile变量的读/写和CAS可以实现线程之间的通信。这些特性形成了concurreent包得以实现的基石。

点击加载

final域的内存语义

final域的重排序规则

a. 在构造函数内对一个final域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
b. 初次读一个final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FinalTest {
int i;
final int j;
static FinalTest obj;
public FinalTest(){ //构造函数
i = 1; //写普通域
j = 2; //写final域
}
public static void writer(){ //写线程A的执行
obj = new FinalTest();
}
public static void reader(){ //读线程B的执行
FinalTest object = obj; //读对象的引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}

这里假设一个线程A执行writer()方法,随后另一个线程B执行reader()方法。下面我们通过这两个线程的交互来说明重排序规则。

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。实现包括

  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

在示例代码中,由于线程A,B竞争,由于线程B”看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值1还没有写入普通域)。
而写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域没有这个保障

读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

final域为引用类型

对于final域为引用类型,写final域的重排序规则对编译器和处理器增加了约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造函数对象的引用赋值给一个引用变量,这两个操作不能重排序。

比如,引用的final域是int数组,该规则保证了,数组元素再被使用之前,已经被初始化了。

final引用不能从构造函数中”溢出”

写final域引用的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中”溢出”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalTest {
final int i;
static FinalTest obj;
public FinalTest(){ //构造函数
i = 1; //1 写final域, 此时初始化未完成
obj = this; //2 this引用在此"溢出"
}
public static void writer(){
new FinalTest();
}
public static void reader(){
if(obj != null){ //3 由于"溢出"(obj != null)
int t = obj.i; //4 读取未被初始化i
}
}
}

有了重排序规则可以确保:在构造函数返回之前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回之后,任意线程都将保证能看到final域正确初始化之后的值。

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

单例模式下的懒汉模式,在多线程的的条件下可能会出现问题。

基于voltatile的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Instance{
}
public class SafeDoubleCheckedLocking { //1
private volatile static Instance instance; //2
public static Instance getInstance(){ //3
if(instance == null){ //4 第一次检查
synchronized (SafeDoubleCheckedLocking.class){ //5 加锁
if(instance == null){ //6 第二次检查
instance = new Instance(); //7 可能会出现问题的地方
}
}
}
return instance;
}
}

问题根源:如上,在线程执行到第4行,当instance!=null的时候,实际上instance引用的对象初始化可能还没有完成。

解决思路是,使用volatile禁止上面2,3行代码的重排序,来保证线程安全的延迟初始化。

1
2
3
4
5
6
instance = new Instance();
//新建一个对象Instance可分解为如下三行伪代码
memory = allocate(); //1 分配对象的内存空间
ctorInstance(memory); //2 初始化对象
instance = memory; //3 设置Instance指向分配的空间

实际上,上面的伪代码操作2,3可能会重排序,这是不改变单线程程序执行结果的重排序。
但是在多线程,instance可能会先指向一个未被初始化的空间,调用者使用这个未初始化的内存空间,bug就出现了。