Java并发编程学习——Java如何解决可见性和有序性问题

什么是Java内存模型

导致可见性的原因是缓存,导致有序性的原因是编译优化,那么解决可见性和有序性的最直接的方法就是禁用缓存和编译优化,但是如果禁用那么就会导致程序的性能下降。

所以合理的方案就是按需禁用缓存和编译优化,而何时禁用这件事情是程序员决定的,这时候就应该请出主角Java内存模型了。

Java内存模型是一个很复杂的概念,站在程序员的角度来看,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatilesynchronizedfinal三个关键字以及六项Happens-Before规则

使用volatile的困惑

volatile关键字并不是Java语言的特产,C语言也有,它最原始的意义就是禁用CPU缓存。

例如我们声明一个volatile变量

1
volatile int x = 0;

它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。

例如以下代码,在1.5版本之前打印结果可能为0(因为CPU缓存的原因,线程2读取的是自身缓存的值),在JDK1.5之后肯定为40,因为在1.5版本之后JDK做了volatile的语意增强,这个增强其实就是一项Happens-Before规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Test {

int x = 0;

volatile boolean v = false;

public void write() {
x = 40;
v = true;
}

public void read() {
if (v == true) {
System.out.println(x);
} else {
System.out.println("Noxxxxxxxxxxxxxxxxxxx");
}
}

public static void main(String[] args) throws InterruptedException {
Test test = new Test();
Thread thread1 = new Thread(() -> {
test.write();
});

Thread thread2 = new Thread(() -> {
test.read();
});
thread1.start();
thread2.start();

thread1.join();

thread2.join();

}

}

Happens-Before规则

Happens-Before规则并不是先于发生的意思,而具体意思是指前一个操作的结果对于后一个操作时可见的

程序的顺序性规则

在一个线程中,前面的操作Happens-Before后面的操作。

volatile变量规则

一个对于volatile变量的写操作Happens-Before于后续对这个变量的读操作。

传递性

如果 A Happens-Before B 且 B Happens-Before C 那么 A Happens-Before C

这条规则和volatile变量规则结合起来看前面的代码,其实就是上面代码示例的结果原理,在线程A中x=40的操作happens-before对于volatile变量v的写操作,然后线程A中对于volatile变量v的写操作happens-before于对线程B的对volatile变量v的读操作,所以x = 40happens-before线程B中的对于volatile变量v的读操作,也就是说x = 40对于线程B的那个操作是可见的,而JDK1.5之后就是通过这个原则来增强volatile语意的。

管程中的锁规则

管程是一种通用的同步原语。在Java中指的就是synchronized,synchronized就是java对管程的一种实现。

管程中的锁是java隐式帮我们实现的,在进入同步块代码的时候Java会帮我们上锁,执行完成会帮我们释放锁,这个是由编译器帮我们实现的,为的是防止程序员忘记释放锁。

1
2
3
4
5
6
7
8
9
// 进入同步代码块加锁
synchronized(this) {
if(this.x == 40) {
this.x = 0;
} else {
x = 0;
}
}
// 执行完成释放锁

线程的start()规则

主线程启动子线程B,子线程能看到主线程在启动子线程B之前的操作。换言之就是线程A中调用的线程B的start方法happens-before线程B中的任意操作。

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

线程join规则

主线程等待子线程完成,当子线程完成后,主线程能看到子线程的操作。其实就是,在主线程A中调用了子线程B的join方法,这时候子线程B的任意操作happens-before主线程调用线程B的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

线程中断规则

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

final

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化

总结

  1. 为什么定义Java内存模型?

    现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。

  2. 三个基本原则

    原子性、可见性、有序性。

  3. Java内存模型涉及的几个关键词

    锁、volatile字段、final修饰符与对象的安全发布。其中:第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

-------------本文结束感谢阅读-------------