共享模型之内存
Java内存模型(Java Memory Model,JMM),它定义了主存(共享数据)和工作内存(私有数据)的抽象概念,底层对应着CPU寄存器、缓存、硬件内容和CPU指令优化等。
JMM体现在以下几个方面:
- 原子性,保证指令不会受到线程上下文切换的影响
- 可见性,保证指令不会受CPU缓存的影响
- 有序性,保证指令不会受CPU指令并行优化的影响
JMM是JVM的一种规范,定义了JVM的内存模型,屏蔽了各种硬件和操作系统的访问差异。
1. 可见性
一个线程对主存中的变量修改,另一个线程不可见。
解决方法:
- 对变量添加
volatile
关键字,使得线程去主存中读取而不是缓存。它可以修饰成员变量和静态成员变量。 -
volatile
只保证可见性,不保证原子性。 - 使用synchronized也可以解决。解锁前会将本地的修改刷新到主存中,确保了共享变量的值是最新的。
- synchronized语句块可以保证代码块的原子性,也同时保证代码块内变量的可见性,但缺点是它属于重量级锁,性能相对较低。
- 另外,在线程中使用
System.out.print()
也会保证变量的可见性,是因为这个方法会刷新缓存,同时,在方法中有使用synchronized锁。
1.1 同步模式之Balking
用在一个线程发信啊另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。
注意区分
volatile
和synchronized的区别
单例初始化可以使用Balking
2. 有序性
JVM会再不影响正确性的前提下,可以调整语句的执行顺序,这种特性称之为 指令重排。多线程下的指令重排会影响正确性。
为什么要有指令重排这项优呢?
在不改变程序结果的前提下,指令的各个阶段可以痛殴该国重排序和组合来实现指令级并行。它虽然不能缩短单个指令执行时间,但可以提高指令吞吐量。
JVM也有指令重排,它是JIT编译器在运行时的一些优化。
JIT,just-in-time compilation。java程序最初是通过解释器进行解释执行的,当JVM发现某个方法或代码块运行特别频繁时,就会把这些代码认定为 热点代码。为了提高热点代码的执行效率,在运行时,JVM会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。
并不是所有的代码都会被JIT编译,否则会发生 代码爆炸。对一般的Java方法而言,编译后的代码大小相对于字节码大小,膨胀比达到10x是很正常的。
使用Java并发压测工具,jcstress,观察指令重排序现象
创建jcstress测试项目,
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=cn.itcast -DartifactId=ordering -Dversion=1.0
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
// Put the code for first thread here
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
// Put the code for second thread here
num = 2;
ready = true;
}
}
结果
Observed state Occurrences Expectation Interpretation
0 4,143 ACCEPTABLE_INTERESTING !!!!
1 38,574,093 ACCEPTABLE ok
4 68,104,735 ACCEPTABLE ok
出现结果0,是因为num = 2
和ready = true
两个语句发生重排序,后者放在了前面。
给变量ready
添加volatile
关键字可以防止结果0的出现
对含有volatile
关键字变量操作的前面的语句,不会发生重排序。
3. volatile原理
volatile的底层实现原理时内存屏障,Memory Barrier(Memory Fence)。
- 对volatile变量的写指令的 后面 会加入写屏障
- 对volatile变量的度指令的 前面 会加入读屏障
3.1 如何保证可见性
写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
// Put the code for second thread here
num = 2; // num也会被同步到主存当中
ready = true; // ready是volatile赋值带写屏障
// 写屏障
}
读屏障保证在该屏障之后,对共享变量的读取,加载的是主存当中最新的数据。
@Actor
public void actor1(I_Result r) {
// Put the code for first thread here
// 读屏障
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
3.2 如何保证有序性
写屏障会保证在指令重排序时,不会将写屏障之前的代码排在写屏障之后;
读屏障会保证在指令重排序时,不会将读屏障之后的代码排在读屏障之前。
3.3 double-checked locking 问题
double-checked locking单例模式:
public class Singleton {
private static Singleton INSTANCE = null;
private Singleton() { }
public static Singleton geteInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
其特点是:
- 懒惰化实现
- 首次使用getInstance()才使用synchronized加锁,后续使用无需加锁
- 第一个if使用了INSTANCE变量,是在同步块之外
在多线程环境下,上面代码是有问题的。
new对象时,可以有三步(简化):
- 分配内存;
- 初始化;
- 内存地址复制给变量INSTANCE。
步骤2和步骤3可能会发生重排序,多个线程操作时,可能会将未初始化的单例返回。
synchronized的有序性并不能防止指令重排的发生,而violate可以。
在INSTANCE变量上加入violate
关键字,确保new对象中的步骤1、2和3有序进行,解决该问题。
4. happens-before 规则
happens-before规定了对共享变量的写操作对其他线程读操作可见。它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,其他线程对该共享变量的读可见。
- 对象锁解锁前对变量的写,对于接下来同一个对象锁的其他线程对该变量的读可见
- 线程对volatile变量的写,对接下来其他线程对该变量的读可见
- 线程start前对变量的写,在该线程开始后对该变量的读可见
- 线程结束前对变量的写,其他线程在得知它结束后的读可见
- 线程t1打断t2前对变量的写,对于其他线程在得知t2被打断后的读可见
- 对变量默认值的写,其他线程对该变量可见
- 具有传递性,如果x hb-> y,y hb-> z,那么有x hb-> z,配合volatile防止指令重排