Java内存模型
概念
Java内存模型(Java Memory Model, JMM),它定义了主存、工作内存的抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。
体现
- 原子性:保证指令不会受到线程上下文切换的影响
- 可见性:保证指令不会受到CPU缓存的影响
- 有序性:保证指令不会受到CPU并行优化的影响
原子性
Monitor主要关注的是访问共享变量时,保证了临界区代码的原子性
可见性
现象:不会停下来的循环
在下面的代码中,变量run
初始值为true
,1秒后,主线程将其修改为false
。在线程t中,只有run
为false
才会停止循环,打印日志。
但实际情况却是,主线程虽然修改了变量run
的值,但是线程t无法停下。
public class UnStoppedLoop {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (run){
}
log.info("已停止");
},"t").start();
Thread.sleep(1000);
log.info("停止线程 t");
run = false;
}
}
形成现象的原因
- 初始状态,线程t刚开始从主内存读取了
run
的值到工作内存;
- 因为线程t要频繁从主内存中读取
run
的值,JIT 编译器会将run
的值缓存至自己工作内存中的高速缓存中,减少对主存中run
的访问,提高效率
- 1 秒之后,主线程修改了
run
的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决方案
在变量run
上加上volatile
关键字。
volatile
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
synchronized也能保证变量的可见性
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。
可见性 与 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况。
用volatile实现两阶段中止模式
之前的写法
使用interrupt()
打断正在执行的线程。
缺点:需要时刻关注InterruptedException
和isInterrupted()
,容易遗漏或出错。
改进写法
使用volatile
关键字自己设置一个打断标记。
public class TwoPhaseTerminationWithVolatile {
public static volatile boolean stopFlag = false;
public static class App{
Thread app;
public void start(){
app = new Thread(() -> {
while (true) {
if (stopFlag) {
log.debug("执行退出逻辑");
break;
}
try {
log.debug("执行任务(上)");
log.debug("陷入阻塞");
TimeUnit.MILLISECONDS.sleep(400);
log.debug("执行任务(下)");
} catch (InterruptedException e) {
log.warn("阻塞时被打断");
}
}
}, "app");
app.start();
}
public void stop(){
stopFlag = true;
// 依然需要使用interrupt()方法用来打断sleep,但是无需设置打断标记
app.interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
App app = new App();
app.start();
TimeUnit.SECONDS.sleep(1);
app.stop();
}
}
犹豫模式
调用方法创建一个线程时,发现另一个线程已经在做同一件事,此时无需再创建线程。
解决方法:思路很简单,只需要加一个变量用来表示线程是否已经被创建即可。
方案1
private volatile boolean staring = false;
public void start(){
if (staring){
return;
}
staring = true;
// 执行创建线程...
}
存在问题:在初始阶段,如果有两个线程同时执行到if (staring)
,它们都会认为staring
为false
并创建线程,与我们的本意不相符。
解决办法:在方法上加上锁,使得每次只有一个线程能够进入方法。
方案2
public synchronized void start(){
if (staring){
return;
}
staring = true;
// 执行创建线程...
}
存在问题:锁的细粒度比较大,实际上,我们只需对读写staring
上锁即可保证线程只被创建1次。
解决办法:将锁放在读写staring
上。
方案3
private volatile boolean staring = false;
public void start(){
synchronized (this) {
if (staring) {
return;
}
staring = true;
}
// 执行创建线程...
System.out.println("创建线程");
}
存在问题:实际上,当staring
为true
时并不会造成多线程安全问题,也没有上锁的必要。
解决办法:可以在加锁之前判断一次staring
的值,只有当它为为false
时才进行加锁和修改操作。
方案4
private volatile boolean staring = false;
public void start(){
if (staring) {
return;
}
synchronized (this) {
if (staring) {
return;
}
staring = true;
}
// 执行创建线程...
System.out.println("创建线程");
}
有序性
指令重排序
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序。
例如下面这段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。
所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为指令重排,多线程下指令重排会影响正确性。
指令重排的意义
CPU级别的指令重排可以提高其运行代码的吞吐量。JVM为了尽可能利用CPU这一特性使得代码运行速度加快,会对JVM指令进行重排。
指令重排带来的问题
由于指令重排并非次次都能够发生,因此需要大量反复运行才能复现。
这里可以使用借助 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
并将ConcurrencyTest .java文件修改为如此下代码
@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) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
以上代码存在两种常见情形:
- 线程1 执行到判断语句时,线程2还没有修改ready,此时ready = false,所以进入 else 分支结果为 1
- 线程1 执行到判断语句时,线程2已经修改了ready,此时ready = true,这回进入 if 分支,结果为 4(因为线程2对 num 的修改已经执行过了)
但是由于指令交错现象的存在,还存在一种及其少见的特殊情形
- 线程1 执行到判断语句时,线程2已经修改了ready,此时ready = true,这回进入 if 分支,但是由于指令交错的存在,线程2还没有修改num的值,此时结果为 0
为了复现这一现象,我们可以通过执行下面的命令来进行测试与验证
mvn clean install
java -jar target/jcstress.jar
jcstress会自动对我们的代码进行测试,并统计各种情形出现的次数,例如下面这组数据。
Observed state Occurrences Expectation Interpretation
0 1,075 ACCEPTABLE_INTERESTING !!!!
1 109,916,722 ACCEPTABLE ok
4 84,383,714 ACCEPTABLE ok
容易看出,相比于结果1和结果4,结果0出现的概率极小。
解决办法
虽然结果0出现的概率极小,但是依然无法避免其出现。而出现结果0的真正原因来源于指令重排。
我们可以使用volatile
来修饰变量,进而禁用指令重排。
我们可以将上面的代码修改为
@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;
volatile boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
然后重新执行,结果如下
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
0 matching test results.
说明已经不会出现结果为0的情况了。
volatile原理
volatile的底层实现原理为内存屏障(Memory Barrier)
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
保证可见性
- 写屏障会保证在该屏障之前对共享变量的改动会同步到主存之中
- 读屏障会保证在该屏障之后对共享变量的读取会加载主存中最新的数据
保证有序性
- 写屏障会保证在指令重排时,写屏障之前的代码不会重排到写屏障之后
- 读屏障会保证在指令重排时,读屏障之后的代码不会重排到读屏障之前
内存屏障无法保证原子性
- 写屏障仅仅保证之后的读能够读到最新结果,但是不能保证读跑到它前面去
- 有序性也只能保证本线程内的代码不被重排序
happens-before规则
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
规则1
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见。
static int x = 0;
static Object m = new Object();
static void rule1(){
new Thread(() -> {
synchronized (m){
x = 10;
}
},"t1").start();
new Thread(() -> {
synchronized (m){
System.out.println("x = " + x);
}
},"t2").start();
}
规则2
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int y = 0;
static void rule2(){
new Thread(() -> y = 10, "t1").start();
new Thread(() -> System.out.println("y = " + y),"t2").start();
}
规则3
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int z;
static void rule3(){
z = 10;
new Thread(() -> System.out.println("z = " + z)).start();
}
规则4
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()
或t1.join()
等待它结束)
static int p;
static void rule4() throws InterruptedException {
Thread t1 = new Thread(() -> p = 10, "t1");
t1.start();
t1.join();
System.out.println("p = " + p);
}
规则5
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted
或t2.isInterrupted
)
static int q;
static void rule5() {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(q);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
q = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(q);
}
规则6
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
规则7
具有传递性,如果 x hb-> y
并且y hb-> z
那么有x hb-> z
,配合 volatile 的防指令重排,有下面的例子
volatile static int m;
static int n;
static void rule7(){
new Thread(() -> {
n = 10;
m = 20;
},"t1").start();
// m=20 对 t2 可见, 同时 n=10 也对 t2 可见
new Thread(() -> System.out.println("m = " + m),"t2").start();
}