Java并发(一)-Java内存模型

概述

Java内存模型(JMM)通过在执行指令序列的适当位置插入内存屏障来限制处理器的重排序,屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上提供一个一致的内存模型。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。而正是由于重排序的影响可能导致多线程程序的执行结果。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {
int a = 0;
boolean flag = false;

public void write() {
a = 1; // 1
flag = true; // 2
}

public void read() {
if (flag) {
int i = a;
}
}
}

在程序执行过程中,线程A执行write()方法,线程B执行read()方法。由于步骤1、2没有数据依赖性,编译器或处理器可能会把1、2步骤进行重排序,这样就操作数据i的错误。

内存可见性

现代处理器会使用写缓冲区(L1、L2、L3)临时保存处理器向内存中写入的数据。由于每个处理器都有写缓冲区,这就导致了部分数据可能仅仅只对该处理器可见,也就是说处理器对内存的读/写操作的执行顺序并不一定和内存实际发生的读/写顺序一致。如下所示:

处理器 ProcessorA ProcessorB
a = 1; //A1 b = 2; //B1
x = b; //A2 y = a; //B2

初始状态:a = b = 0 处理结果:x = y = 0

会出现上诉结果主要是由于内存可见性的问题:处理器A和处理器B同时把共享变量写入自己的写缓冲区(A1,B1)中,然后从主内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的数据刷新到内存中(A3,B3)。

图来自《Java并发编程的艺术》

我们可以看出正是由于多处理器的写缓冲区的存在造成了部分数据的不可见性,在JMM中采用内存屏障保证的数据的内存可见性。

happends-before

Java内存模型(JMM)使用happends-before的概念来描述操作之间的内存可见性。在JMM中,如果一个操作的执行结果总是对另一个操作可见,那么这两个操作之间存在happends-before关系。下面是JMM中常见的happends-before规则:

  • 程序顺序执行规则:一个线程的每个操作,happends-before于该线程中的任意后续操作。
  • 监视器锁规则:一个锁的解锁,happends-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happends-before于任意后续对这个volatile域的读。
  • 传递性:如果A happends-before B,B happends-before C,那么A happends-before C。

图来自《Java并发编程的艺术》

如图所示,JMM通过内存屏障屏蔽某些重排序,呈现出简单且易于理解的happends-before规则。

volatile

volatile的特性

  • 内存可见性:对一个volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对于任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
  • volatile变量可以多线程的读,但只能单线程的写

volatile的内存可见性

JMM通过对volatile变量建立happends-before关系保证了volatile变量的内存可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileExample {
private volatile int a = 0;
private volatile boolean flag = false;

void write() {
this.a = 1; // 1
this.flag = true; // 2
}

void read() {
while (flag) { // 3
int i = a; // 4
System.out.println(i);
}
}
}

如果线程A执行write()方法,线程B执行read()方法。根据happends-before规则:B线程总能进入循环并且i的值始终是1。

  • 程序顺序规则:1 happens-before 2;3 happens-before 4
  • volatile变量规则:2 happens-before 3
  • 传递规则:1 happens-before 4

由于我们可以看出volatile变量的读-写的内存语义:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。注意:JMM会把本地内存中的所有共享变量刷新到主内存中,也就是说在volatile变量之前的所有变量都会刷新到主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来会从主内存中读取共享变量。

volatile的应用

在锁的实现中,我们需要保证内存可见性同步互斥性操作,在ReentranLock中就是使用volatile实现的内存可见性。

##应用

Java中实现单例模式有许多种方式,各种方式有不同的优缺点,在实现单例时尤其需要注意并发安全,下面介绍两种实现单例的例子。

双重检查锁实现单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {

private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

创建对象主要有下面三个步骤,instance = new Singleton()

  1. 创建对象空间,实例化域初始化为零值。
  2. 初始化对象。
  3. 将引用指向该对象地址。

在Java创建对象中导致2、3步骤的重排序导致instance指向没有初始化的对象,所以使用volatile关键字,使得创建对象的内存可见性。

内部类实现单例

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {

private Singleton() {}

public static Singleton instance() {
return SingletonHolder.instance;
}

private static final class SingletonHolder {
private static Singleton instance = new Singleton();
}
}

由于Java在类的初始化时会自动加锁,所以在第一次加载SingletonHolder类时会自动加锁防止并发访问。

Reference

《Java并发编程的艺术》