摘录自 极客时间

Java 内存模型的概念

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决这个问题最直接的办法就是禁用缓存和编译优化,但是这样程序性能就崩了。正确的方案是按需禁用。

Java内存模型规范了JVM如何按需提供禁用缓存和编译优化的方法。具体来说包括volatilesynchronizedfinal三个关键字,以及6个Happens-Before规则。

内存模型三个基本原则:原子性、可见性、有序性。

volatile使用困惑

例如声明一个变量volatile int x = 0,它表达的意思是就是告诉编译器,对这个变量的读写,不能使用PCU缓存,必须从内存中读取或者写入。

但是实际使用中可能会被喂屎。

例如如下代码:

如果线程AB执行代码。假设线程A执行write方法,volatile会将v=true写入内存,假设线程B执行reader(),按照volatile语义,线程B会从内存中读取变量V,直觉上看应该是42,实际上在JDK1.5之上一定是42,之下就可能是0或者42了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里 x 会是多少呢?
}
}
}

JDK1.5之下出现0的原因就是可见性问题,Java内存模型在1.5版本对volatile语义进行了增强,通过一项 Happens-Before原则。
要注意的是任意单个volatile变量的读写具有原子性,但是volatile++这种操作除外。因为volatile++要执行三句话。

Happens-Before原则

1 程序顺序性原则

这条规则指的是在一个线程中,按照程序顺序执行。例如上述程序的x=42发生在v=true之前。

这个和编译器优化的调整顺序似乎有点冲突,单其实所谓顺序指的是可以用顺序的方式推演程序的执行,但是程序的执行不一定是完全顺序的,编译器只是保证结果一定是等于顺序方式推演的结果即可。过程并不重要。

2 volatile 变量原则

这条指令指的是对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见。关联3进行理解。

3 传递性

这条指令指的是如果A Happens-Before BB Happens-Before C,那么A Happens-Before C。对下图

p1

从图中可以看到:

  • x=42Happens-Before写变量v=true,这是规则1
  • 写变量v=trueHappens-Before读变量v=true,这是规则2

根据传递性可以得到x=42 Happens-Before读变量v=true,也就是实现了JDK1.5对volatile语义的增强。

4 管程锁

管程是一种通用的同步原语,在Java中指的是synchronized

管程中的锁在Java中是隐式实现的,例如下面代码,在进入同步块之前会自动加锁,在代码块执行完自动释放,这都是编译器帮我们实现。

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

可以这样理解管程中锁的规则:假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,可以看到A对x的写操作,也就是线程B能看到x==12

5 线程 start() 规则

这条规则指主线程A启动子线程B后,子线程B能看到主线程在启动子线程B前的操作。

1
2
3
4
5
6
7
8
9
Thread B = new Thread(()->{
// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
6 线程join() 规则

这条规则有关于线程等待。指的是主线程A等待子线程B的完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中的join()方法返回),主线程能看到子线程的操作。

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关键字

final修饰变量告诉编译器:这个变量不变,随你优化。

jdk1.5之后只要提供正确构造函数没有”逸出”,就不会出现问题。”逸出”不太好理解,下面的例子中,搞糟函数里面将this赋值给了全局变量global.obj,这就是”逸出”,线程通过global.obj读取x有可能读取到0。其实意思就是可能把还没有初始化完的this给丢出去,这样就会出现问题。

1
2
3
4
5
6
7
8
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲 this 逸出,
global.obj = this;
}
  • 当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

  • 对下列代码

    1
    2
    final class A{}
    A x = new A();

    x不是final的,x任然需要同步

思考题

有一个共享变量abc,在一个线程里设置了abc 的值,思考有哪些办法可以让其他线程能够看到abc==3

解答:

  • 使用volatile修饰abc
  • synchronized 代码块中操作abc
  • 线程A操作共享变量abc后start()启动线程B
  • 线程A操作共享变量abc,线程B join A对于线程B可见

  • final int abc = 123