JAVA并发基础-内存模型

引起并发bug的3个主因

缓存导致的可见性

由于cpu拥有自己独立的缓存空间,多线程+多核CPU并发修改同一个变量的流程是:

  1. 每个线程对应的CPU都加载内存中的变量到自己的缓存中;
  2. 修改变量,存入到CPU中缓存中;
  3. 数据从CPU的缓存输入到,内存中;

由于cpu的缓存是独立的对其他线程不可见,所以就会出现在并发情况下内存中的变量相互覆盖。如图:

avator

线程切换的原子性

1个cpu可以“同时”执行多个进程,这里的“指的是,cpu在执行进程时候是通过在进程直接切换,每个进程执行一小段时候实现的。但是进程之间内存不共享,它们的切换需要修改内存地址,开销比较大。所以操作系统一般改为在一个进程替换切换线程来实现并发,因为线程共享内存,切换不需要修改内存地址。但是线程之间的切换往往会引起并发上的bug。
如:我们在一个进程中的俩个线程都对一个变量做累加1000,往往结果不会等于2000,这就是在一个cpu下,俩个线程在执行上来回切换执行导致。如流程如下图下图:

avator

编译优化的指令顺序

现代的编译器为了性能往往会对指令重排序,这就会导致一些奇怪的bug,如下双重检查的单例模式:

1
2
3
4
5
6
7
8
9
10
11
12
  public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

线程A在执行这段代码的instance = new Singleton();在cpu的指令是3条命令,我们期望的步骤是:

  1. 初始化地址&M
  2. 在&M地址上new Singleton()
  3. &M赋值给instance

而实际上是

  1. 初始化地址&M
  2. &M赋值给instance
  3. 在&M地址上new Singleton()

这样线程B和A同时调用getInstance()的时候,有可能线程B执行第一个判断时候,由于编译器指令优化A已经instance=&M,所以B判断instance不为空直接返回,但是这时候&M还没有new Singleton()导致这空指针异常。

java的内存模型

由于java线程间通信采用的是共享内存的方式,所以遇到上述的指令重排序往往会引起一些bug,在此基础上,java采用了synchronized、volatile、final关键字以及6个Happens-Before规则来约束指令重排序。

happens-before原则

同一线程,前面的操作happens-before于后面的操作

参考代码1,x=42一定happens-before v=true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以下代码来源于【参考 1】
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里 x 会是多少呢?
}
}
}

volatile关键字修饰的变量,写happens-before读操作

结合代码1:如果线程a、b同时执行,线程a调用writer,线程b调用reader,那么v一定等于true

happens-before传递性 a–>b b–>c 则 a–>c

结合这条规则和1、2看,x一定=42。因为x=42–>v=true v=true–>v==true 所以 x=42

synchronized关键字,不同线程 解锁happens-before加锁

线程b,和线程a同时执行下面代码,线程a获取锁之后执行完成并且释放锁,这时候b执行,x的值一定是12。即线程a操作hanppens-before b

1
2
3
4
5
6
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁

线程strat()

线程a调用了线程b.start(),那么a的变量和操作,线程b可见

线程的join()

线程b在线程a中调用了b.join(),那么b里的共享变量和共享操作,a可见

1
2
3
4
5
6
7
8
9
10
11
12
Thread B = new Thread(()->{
// 此处对共享变量 var 修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66

final的应用

说明该变量初始化后即不变,其他线程访问时候也一定是一个完全初始化的变量,这就可以解决前面提到的双重检查单例线程不安全的问题。但是要小心溢出问题。

1
2
3
4
5
6
7
8
9
// 以下代码来源于【参考 1】
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲 this 逸出,
global.obj = this;
}