很多人将Java内存结构
与Java内存模型
傻傻分不清,Java内存模型
是Java memory model(JMM)
的意思。简单地说,JMM
定义了一套在多线程的环境下读写共享数据(比如成员变量、数组
)时,对数据的可见性
、有序性
和原子性
的规则和保障。所以他跟Java内存结构
是没有什么关系。
原子性
问题分析
两个线程对初始值为0的静态变量一个做自增,一个做自检,各做50000次,结果是0吗?答案是:结果不一定是0。
public class Test {static int i = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; j ++) {i ++;}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; j ++) {i --;}});t1.start();t2.start();t1.join();// join()方法的作用就是让主线程等待子线程执行结束之后再运行主线程。t2.join();System.out.println(i);}
}
运行后就出现各种结果,有时出现负数,有时出现正数,当然有时也会输出为0。这是因为Java中对静态变量的自增、自减并不是原子操作,即多线程时他们会被CPU交错执行。而所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何context switch(切换到另一个线程
)。
例如对于i++
而言(i为静态变量
),实际会产生如下的JVM字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法(局部变量的i++调用的是iinc,直接在局部变量槽上执行,而静态变量是在操作数栈上执行。)
putstatic i // 将修改后的值存入静态变量i(在操作数栈加完后再put回静态变量)
而对应i--
也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
而Java的内存模型如下图(图片取自网络黑马,以下同):
内存模型由两部分组成,一部分叫主内存
,一部分叫工作内存
。但需要注意的是这里的主内存
、工作内存
不能和堆栈
混淆起来,像堆、栈
这样的是在Java内存结构上的说法,而这里的主内存
、工作内存
是指JMM里的说法。虽然名称有点相似,但是不要混淆。
像i
这样的静态变量(换句话说共享的变量信息
)他们是放在主内存
中的,而线程是在工作内存
中的。所以假如要完成上面的四行字节码,他的执行需要在主内存
和工作内存
中需要数据的交换。即getstatic
是把i
的值从主内存
中读到工作内存
的线程中,然后在工作内存
中完成了加法后,他又得把结果写会主存
中去。
如果是在单线程下,执行以上8行代码是顺序执行(不会交错
)就没有问题:
getstatic i // 线程1-获取静态变量i的值,线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
getstatic i // 线程1-获取静态变量i的值 线程内i=1
iconst_1 // 线程1-准备常量1
isub // 线程1-自减,线程内i=0
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0
但在多线程下,这8行代码可能交错执行。出现交错的原因是Java的线程模型(乃至整个操作系统的线程模型
)是一种抢先式多任务系统,就是线程呢会轮流拿到cpu的使用权,cpu会以时间片为单位,比如在时间片1把使用权交给线程1使用,在时间片2再把时间分给线程2执行,也就是多个线程轮流使用cpu。
比如出现负数的情况(假设i初始值为0,同下
):
getstatic i // 线程1-获取静态变量i的值,线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减,线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
比如线程1获取到了i
的值为0
(getstatic
),但是他恰巧在这个时刻他的时间片用完了,cpu就把他踢出去了,踢出去以后cpu开始执行线程2的代码,线程2的代码执行的还是getstatic
,他也获取了静态变量i
的值,也是0
,因为线程1还没来得及修改。假设之后CPU又切换回了线程1,线程1准备了常量并执行了加法(iconst_1 iadd
),然后将相加后的结果写回静态变量(putstatic
),所以静态变量变成了1
。这时cpu又把时间片分给了线程2,线程2也准备常量1(iconst_1
),然后做了减法(isub
),但线程2读到的i
是0
,所以减的结果是-1
,然后写回静态变量(putstatic
)。所以虽然两个线程各进行了加一和减一,但结果却是-1
,因为线程2的结果覆盖了线程1加完后的结果。
也可能会出现正数,比如:
getstatic i // 线程1-获取静态变量i的值,线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减,线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
虽然这八个字节码本意是执行一次加法和一次减法,但却出现了正数结果。
以上是在多线程的情况下,由于指令交错而产生的问题的分析。
解决方法
在Java内存模型中,通过synchronized
关键字来保证原子性。语法如下:
synchronized(对象) {要作为原子操作代码
}
这样写的话,比如线程1来了,他就会被“对象”加锁,加锁以后,他可以安全的去执行同步代码块儿内的代码。这时如果有线程2过来想执行同步代码块儿内的代码的话,他就执行不了了,他就会等待线程1释放“对象”所加的锁,也就是说线程1把同步块儿内的代码都运行完毕,线程1就会把这个锁释放开,那其他的线程才会有机会去争抢“对象”的锁。即同一时刻,只有一个线程能进入同步代码块儿,这样就保证了同步代码块儿内的这些代码的原子性。
public class Test {static int i = 0;// 定义一个静态Object对象static Object obj = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; j ++) {synchronized(obj) {i ++;}}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; j ++) {synchronized(obj) {i --;}}});t1.start();t2.start();t1.join();// join()方法的作用就是让主线程等待子线程执行结束之后再运行主线程。t2.join();System.out.println(i);}
}
这样做的话,i++
相关的那四个指令就会以一个正体来运行,i--
相关指令亦是如此。比如,线程1来了后,他进入同步代码块儿以后(比如i++那里
),他就会在obj上加上锁了,如果这时候线程2来要执行i--
那边,他就执行不了了,他只能等待i++
这个部分执行完毕以后,锁释放开了,那线程2才有机会获得锁去执行i--
操作。这样就保障了i++
和i--
相关指令都是以整体来执行的。
可以做一些比喻,你可以把obj
想象成一个房间,这个房间只能有1个人进入,synchronized
关键字就是你其中一个线程进入房间以后,给他做一个加锁的操作(网友1:上厕所),即进入房间后把这个门给反锁了,那在你解开这个锁之前,其他的线程只能在门外等待(网友1:obj就是钥匙),当第一个线程执行完了以后,他会解开这个锁,从房间内出来,此时房间空了,那其他的线程才有机会进入这个房间,重复刚刚的过程。
红色的大圈是这个对象(网友1:是obj吗?网友2:obj不是monitor,obj里面有个指向monitor的引用)的monitor区
,即每个对象他都有自己的一个monitor区
,即监视器
。monitor
是在我们利用了synchronized
这种同步关键字以后他才会生效,对没有加同步关键字的对象或方法时,他根本就不会考虑monitor
。这个monitor
可以再把它划分成3块儿,第一块儿(蓝色
)可以叫做owner
,owner
表示monitor
监视器的所有者,同一时刻只能有一个线程成为owner
。黄色的一块儿可以把它叫做EntryList
,而绿色的叫WaitSet
。那比如线程1来了,然后他发现这个monitor
中的owner
是空的,并没有其他线程所占据,那么此时,线程1就会成为owner
。并且他会相当于比如JVM指令中的monitor enter
来对这个monitor
进行一个锁定。那如果有线程2来了,线程2发现线程1已经成为owner
了,并且用monitor enter
指令,把monitor
锁住了,所以线程2就不能成为owner
,但他可以进入EntryList
,这是一个排队等候区,他先在这里等待,这个等待专业的叫法是阻塞
,也就是线程2会被阻塞
住,他不会占用cpu时间,只有当线程1执行完毕了,线程2才有机会成为owner
,线程1执行完毕后就会执行虚拟机指令monitor exit
,这个指令就会通知EntryList
里面这些正在等待的线程“owner已经空出来了,你们可以来争抢了”,此时EntryList
中线程2(因为这个例子中就他一个人
)就可以去成为新的owner
,同样线程2也会执行monitor enter
命令,锁住整个对象的monitor区
,防止其他线程对他的干扰。这是从专业的角度去解释同步代码块儿内这些个概念。当然,若在EntryList
里面有多个线程的话,那等线程1执行完毕后,他们就会去争抢成为owner
。
可见性
退不出的循环
看下面的一个现象(退不出的循环
),main线程对run
变量的修改对于t线程
不可见,导致了t线程
无法停止:
public class Test {static boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(()->{while(run) {// ...}});t.start();Thread.sleep(1000);run = false;// 是为了让t线程退出while循环后停下来,所以主线程里改了该值。}
}
但运行后,可以发现1秒钟后虽然run
变成了false
,但线程t
并不会停下来,所以程序一直在循环中。出现这种情况是因为:
刚开始初始状态时,t线程
从主内存
读取静态变量run
的值为true
,读到了自己的工作内存
中,读进来以后,他发现run
是true
,所以就开始不断的循环,但他每循环一次,他都要到去主内存
中取这个静态变量,即相当于很频繁的从主内存中取读取run
的值,所以JIT即时编译器
他就会认为,循环超了一定的次数,那我就要做一些优化了,优化后如下:
他就会把这个主存中的run
值缓存在工作内存
中高速缓存
里,相当于我们把这个true
读进来了,读进来后,你反复反复的用,那我为了提高效率,从等价的Java代码的角度来说,你可以认为他就把他变成了一个临时变量即从static变成了一个局部的变量,那下次你再循环就不用到主存
中去找了,你直接到局部变量这儿去找就行(这是为了便于理解,所以描述成了局部变量,实际并不是这样;实际上他是把run放入到了工作内存中的高速缓存里
),这主要是为了提高效率。
但是问题来了,1秒睡眠之后,主线程
读到了主内存
中的run
的值,把这个run
改成了false
,并写回了主存
,那么实际上此时主存
上的run
已经变成了false
,但是t线程
由于刚才已经做了优化,t
还是源源不断的从自己的高速缓存
中去读取run
的值,那肯定读到的是旧值。这就是一个退不出的循环问题。
解决方法
解决方法是要引入一个关键字,就是volatile
(易变关键字
)。他可以用来修饰成员变量和静态成员变量,他的作用其实很简单,就是避免线程从自己的工作内存
的高速缓存
中查找变量值,即用volatile
修饰变量,每次他都会到主存
中查找变量的最新值。即改成如下:
...
volatile static boolean run = true;
...
这时候,运行的话,一秒之后,就程序结束了。因为volatile
修饰的变量,他的读取是每次都到主存
中去读取的,这样就保证了读取的这个线程他看到的是最新的结果。
volatile
体现的是可见性
这个特性,他保证的是在多个线程之间,一个线程对volatile
变量的修改对另一个线程是可见的,即上面例子中主线程
对volatile
变量写入了false
,t线程
就可以看到他的写入,而不是看到他的旧值。但volatile
他仅仅保证的是可见性
,但他不能保证原子性
,所以他适用的场景是“一个线程写,多个线程读”的情况。
注意
synchronized
语句块既可以保证代码块儿的原子性
,也同时保证代码块儿内变量的可见性
。但缺点是synchronized
是属于重量级操作
,性能相对更低。而volatile
只能保证可见性
,不能保证原子性
。
小实验
如果在前面示例的死循环中加入System.out.println()
会发现即使不加volatile
修饰符,线程t
也能正确看到对run
变量的修改了,这是为何?比如:
public class Test {static boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(()->{while(run) {// ...System.out.println(1);}});t.start();Thread.sleep(1000);run = false;// 是为了让t线程退出while循环后停下来,所以主线程里改了该值。}
}
就写了个System.out.println(1)
,但运行后会发现程序会1秒后结束,这说明t线程
读到了run
,这是因为底层是synchronized关键字
起到的作用,比如println()方法
源码如下:
PrintStream.java
public void println(int x) {synchronized(this) {print(x);newLine();}
}
可以看到这里加了synchronized
关键字,要对PrintStream.java
(打印输出流
)做一个同步,同步关键字也可以防止当前线程从高速缓存
中获取值,即他也是强制让你的当前线程(也就是t
)去读取主存
中的run
值,也就是破坏了JIT优化
。因此,只要涉及到了synchronized
关键字,他既可以保证可见性
也可以保证原子性
,而volatile
只能保证可见性
。
有序性
诡异的结果
int num = 0;
boolean ready = false;// 线程1执行此方法
public void actor1(I_Result r) {if (ready) {r.r1 = num + num;}else {r.r1 = 1;}
}// 线程2执行此方法
public void actor2(I_Result r) {num = 2;ready = true;
}
这里有两个变量,一个是int
的num
,一个是bool
的ready
。现在有两个方法被多个线程并发执行,方法actor1
是判断ready
是否真,若真就num
翻倍,并赋值给I_Result对象
的r1属性
,这里的I_Result
可以当做是一个用来保存结果的对象,其中的r1
用它存储结果,若ready
是假,直接给r1
赋值1。而线程2
执行的actor2
方法是直接给num
赋值2
,然后给ready
赋值true
。有的人把结果可能如下分析:
情况1:
线程1
先执行,这时ready=false
,说以进入else分支结果为1
情况2:
线程2
先执行num=2
,但没来得及执行ready=true
,线程1
执行,还是进入else分支,结果为1
情况3:
线程2
执行到ready=true
,线程1
执行,这回进入if
分支,结果为4
(因为num已经被线程2执行为赋值3了
)
但是,这两个线程并发执行,还有一种情况,结果还有可能是0
。即如下:
情况4:
线程2
执行ready=true
,切换到线程1
,进入if
分支,相加为0
,再切回线程2
执行num=2
。(网友1:晕了!网友2:指令重排序。)
在Java内存模型中,这种现象称之为“指令重排
”,也是JIT编译器
在运行时的一些优化,这个现象需要通过大量的线程进行大批量的测试才能复现,所以甚至有些人认为这是假的。
测试需要借助Java并发压测工具jcstress
(https://wiki.openjdk.java.net/display/CodeTools/jcstress)来完成这个测试。
1)利用下面命令创建maven骨架项目(项目名jcstress
)
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype
-DgroupId=org.sample -DartifactId=test -Dversion=1.0
2)编写测试类
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;@Actorpublic void actor1(I_Result r) {if(ready) {r.r1 = num + num;}else {r.r1 = 1;}}@Actorpublic void actor2(I_Result r) {num = 2;ready = true;}
}
测试类的代码和上面提出问题时的代码一样,只是在被测的两个方法上因为它两是将来要通过不同的线程来测试他的并发,所以这两个方法上加了@Actor
标签。然后比较重要的是@Outcome
注解,@Outcome
就是去检查我感兴趣的一些结果,比如我们的结果保存在了I_Result
对象的r1
属性中,那我们在@Outcome
里你就把这个结果进行分类,比如说我的结果是1
和4
的话,那我就把他分到一个叫Expect.ACCEPTABLE
(即可接受的
)分类里,desc = "ok"
表示这个结果是我意料之中的。还有一种情况就是刚才说‘(我们认为
)不可能发生的’结果,即相加的结果是0
,这种情况就把他归类到Expect.ACCEPTABLE_INTERESTING
(即我感兴趣
)的结果里,也给了描述desc
是“!!!!”
。这样写完以后,就可以执行压测了。
但他执行压测,稍微复杂一些,我们需要用maven命令来执行他。在项目名称里点击右键->Open in Terminal,然后执行如下:
mvn clean install
先把他清除一下并重新编译,编译之后他就会生成一些jar包,在target目录下可以看到生成了两个jar包:
jcstress-1.0-SNAPSHOT.jar 这是源码的jar包
jcstress.jar 这是压测的入口jar包
然后用Java命令去运行这个jar包:
java -jar target/\jcstress.jar
执行后,从控制台输出信息中可以看到他会进行大量的测试,比如其输出信息中有每秒钟执行1.5*10个7次方
这种描述。
测试结束后,控制台中输出的结果内容如下:
...
*** INTERESTING tests // 这里是你感兴趣的结果,也就是刚才的注解,比如他会把出现0这个结果的记录给我们统计出来
...
(JVM args: [-XX: -TieredCompilation]) // 一个是带了JVM参数,关闭了分层编译。
Observed state Occurrences Expectation Interpretation0 1,703 ACCEPTABLE_INTERESTING !!!!1 47,088,060 ACCEPTABLE ok4 6,445,628 ACCEPTABLE ok[OK] test.ConcurrencyTest(JVM args: [])// 还有一个是没带任何JVM参数的。(这两种都出现了ACCEPTABLE_INTERESTING这种结果)Observed state Occurrences Expectation Interpretation0 2,009 ACCEPTABLE_INTERESTING !!!!1 58,884,709 ACCEPTABLE ok4 8,091,363 ACCEPTABLE ok
从47,088,060
等数字可以看得出总的测试数次非常多,比如47,088,060
表示做了四千多万次的方法的并发调用,那么可以看到大部分的情况都出现了结果1
,也有相当一部分情况下出现了4
,至于0
这个结果虽然少,但也出现了一千次以上,没带JVM参数时出现了两千多次,虽然出现0
的次数占的比例很少,不是经常出现,但毕竟他也是出现了,这说明指令重排
的问题确实存在。
解决方法
解决方法是可以用volatile
修饰变量,这个修饰的变量,可以禁用指令重排
。刚才的boolean ready = false;
改为volatile boolean ready = false
,这样的话,比如线程2
去执行actor2
去往变量ready
写true
时(由于禁用了指令重排,所以这时已经确定执行完num=2了?),那么线程1
执行actor1
时从volatile
变量去读,那么这个读写操作就不会受到指令重排的影响了,修改maven工程的代码后,重新测试。结果输出内容中可以看到:
...
*** INTERESTING tests0 matching test results.
...
即你感兴趣的结果只有0次匹配,也就是说没有出现结果为0的情况了。
有序性理解
那为何会发生指令重排呢?这就牵扯到Java内存模型中的有序性的理解。比如下面一段代码:
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;// 较为耗时的操作
j = ...;
这段代码可能要对i
和j
做赋值操作,但i
的赋值可能要做一些计算,所以比较耗时,但j
马上就会运算完毕,这种情况下JVM就会对这种指令进行调整,他会认为你这个i
的操作比较耗时,那可不可以先把i
排后,先把j
的操作排在前面,因为它两之间没有任何交叉,至于先执行i
还是j
,对最终结果不会产生影响,那么他就可能会做一些优化,比如执行顺序是先给i
赋值然后给j
赋值,或先给j
赋值然后给i
赋值,即对他指定的顺序做了一个调整。那么在同一个线程内,这个调整是没问题的,不会影响到最终结果的正确性。但是在多线程的情况下,这个指令重排就会产生一些问题,比如上面已经简单说过了。
那么除此以外,在多线程下的指令重排还有一个重要的案例,就是写单例模式
时,著名的double-checked locking模式
实现的单例模式:
public final class Singleton {private Singleton() {}private static Singleton INSTANCE = null;public static Singleton getInstance() {// 实例没创建,才会进入内部的synchronized代码块儿if (INSTANCE == null) {synchronized (Singleton.class) {// 也许有其他线程已经创建实例,所以再判断一次if (INSTANCE == null) {INSTANCE = new Singleton();}}}return INSTANCE;}
}
这个单例类实现一个懒惰初始化,首先把构造方法设为私有,然后有静态成员变量INSTACNE
,刚开始没值,接下来用getInstance
方法来获得单例对象,然后先看这个单例有没有创建,没有创建我才去new
单例对象,若已经创建了,就直接拿到上次创建好的单例对象并return
。
但为了实现这个懒惰初始化,也得考虑线程安全问题,若多个线程并发调用getInstance
,就有可能造成单例对象被创建多次,有一种方法是在方法上直接加锁,但这样加锁范围太大,每次调用方法都得加锁,实际上对象还没被创建时加锁就行了,所以后续只要对象创建出来了,获取这个对象时是不需要加锁的。所以过程是,先判断单例对象是否为空,是空的话说明他还没被创建,此时再加锁,加锁以后继续判断他是否为空,若仍然为空就创建对象。
为何加锁后还要做一次判断呢,比如场景如下,线程1
来了,发现对象没被创建,就进入同步代码块儿,锁住了类对象,然后去执行下面的代码,但如果线程2
在同一时刻也来调用getInstance
方法,那么第二个线程肯定被挡在了synchronized
之外,会等待线程1
去执行完里面的代码,线程1
把对象创建好了以后,就退出了同步代码块儿,然后锁解开了,所以线程2
就进入了同步代码块儿,这是两个线程首次调用getInstance
时就会出现这种情况。所以线程2
进来以后,若没有里面的第二次判断的话,线程2
还得去new
对象,这就与单例的含义不符了,所以线程2
进来时还要加一个if
判断。这样两次检查单例对象是否为空,就是double-checked
的名字由来,即双重检查锁
。
这种方式看起来完美,但却是有问题的,因为它没考虑指令重排
的问题,当然指令重排
问题仅仅是在多线程环境下
才有问题。比如,在多线程环境下,上面的INSTANCE = new Singleton()
对应的字节码为:
0: new #2 // class com.cnm.Singleton 用new关键字先给Singleton对象分配空间
3: dup // 上面的执行结果是把对象的引用放入操作数栈,然后操作数栈把这个对象引用复制了一份儿,就相当于栈顶有两个对象的引用
4: invokespecial #3 // Method "<init>":()V 第一个对象的引用交给了构造方法
7: putstatic #4 // Field INSTANCE:Lcom/cnm/Signleton;第二个对象的引用交给了putstatic,就是给静态变量赋值了
问题就处在4
和7
,这两行代码有可能发生指令重排问题,即他的执行顺序是不固定的,因为你到底是先new
构造,还是先给静态变量赋值,那么JVM就会认为他谁先执行谁后执行对结果没啥影响。下面是其中的一种可能,比如有两个线程t1 t2
,若按如下时间序列执行:
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0处)
时间3 t1 线程将引用地址赋值给INSTANCE,这时INSTANCE !=null(7处)
时间4 t2 线程进入getInstance()方法,发现INSTANCE != null(synchronized块外),直接返回INSTANCE
时间5 t1 线程执行Singleton的构造方法(4处)
但是可以想,如果构造方法内的代码比较多,即构造过程比较复杂的话,那么t2线程
拿到的INSTANCE
是一个不完整的对象实例,因为t1
的构造方法还没执行完,其中有的属性赋值好了但有的属性还没来得及赋值,此时,t2线程
拿到的就是一个没有经过完全构造完成的单例对象,去使用时就可能出现问题。
这说明双重检查锁
可能有这种指令重排
问题,但几率非常小。
那如何解决呢?很简单,给INSTANCE
变量多加一个volatile
修饰符即可。这样可以禁用指令重排
,但要注意在JDK5以上的版本的volatil
e才会真正有效。
happens-before
happens-before
规定了哪些写操作
可以对其他线程的读操作
可见,他其实就是可见性
与有序性
的一套规则:
【例子1】一个线程对volatile变量的写操作,对接下来其他线程对该变量是读可见的
volatile static int x;new Thread(()->{x = 10;
},"t1").start();new Thread(()->{System.out.println(x);
},"t2").start();
比如有两个线程t1 t2
,还有一个volatile
修饰的共享变量x
,假设t1
是先运行的,那他对这个共享变量做了一个写操作
,假如t2线程
是后运行的,那他要对这个共享变量做一个读操作
,那他肯定能读到刚才x
的最新的值10
。(其实就是一个可见性的体现
)
【例子2】线程在解锁m对象之前对变量的写操作,对于接下来对m加锁的其他线程对该变量的读是可见的
static int x;
static Object m = new Object();new Thread(()->{synchronized(m){x = 10;}
},"t1").start();new Thread(()->{synchronized(m){System.out.println(x);}
},"t2").start();
m
是作为一个对象锁,那假设也是t1
先执行t2
后执行,t1
先对m
对象加锁,他在同步代码块儿内对x
变量进行了写操作
,接下来等锁释放开t2线程
就可以获取这个锁了,获得锁之后,他对x
的读操作
肯定是读到最新值的。
【例子3】线程start前对变量的写操作,对该线程开始后对该变量的读是可见的
static int x;x = 10;new Thread(()->{System.out.println(x);
},"t2").start();
比如说有共享变量x
,那在t2线程start
之前,对x
进行了赋值,等你线程t2
运行了以后,再去读x
是肯定能读到最新值。
【例子4】t1线程结束前对变量的写操作,对其他线程得知t1结束后对x的读操作是可见的(比如其他线程调用t1.isAlive()或t1.join()等待他结束)
static int x;Thread t1 = new Thread(()->{x = 10;
},"t1");
t1.start();t1.join();
System.out.println(x);
比如现在有线程t1
开始运行,然后在他结束之前给x
赋值10
,那么主线程
调用t1.join()
即等待t1结束
,那等t1
结束以后,再去读x
肯定是读到t1
结束前对他的写操作
的最新值。
【例子5】线程t1打断t2(interrupt方法可以打断线程)前对变量的写操作,对于其他线程得知t2被打断以后对该变量的读是可见的(通过t2.interrupted或t2.isInterrupted)
static int x;public static void main(String[] args) {Thread t2 = new Thread(()->{while(true) {if(Thread.currentThread().isInterrupted()) {System.out.println(x);break;}}},"t2");t2.start();new Thread(()->{try {Thread.sleep(1000);}catch(InterruptedException e) {e.printStackTrace();}x = 10;t2.interrupt();},"t1").start();while(!t2.isInterrupted()) {Thread.yield();}System.out.println(x);
}
比如有t2线程
,他有个while(true)
循环,即不断运行,先不看while
里面的if
。然后下面有个t1线程
,t1线程
先睡了1秒中,1秒后,他对共享x
变量做写操作
,赋值10
,然后他把t2线程
打断t2.interrupt();
了,即t2线程
在1秒之后被t1
给打断了。还有主线程
也在不断循环while(!t2.isInterrupted())
,看看t2
有没有被打断,如果没有被打断,就不断循环。那1秒之后,t1线程
把t2线程
打断了,打断以后,主线程的while
条件就不成立了,那他就退出这个循环,主线程再去拿x
的值,输出,这时他肯定能拿到x
的最新值。因为这个x
的写操作是在打断t2
前写的,那么等你主线程
得知他打断了以后,你再去读x
的值,那肯定是能够拿到。(网友1:这个相当于一种范式,告诉你这样可以保证顺序性,理解就好了。)当然,上面的t2
里面的if
跟主线程里面while
的判断条件是类似的,t2
里的if
判断是为了让t2
里面的while
循环优雅的退出,即在他t2
被打断了以后,他还会继续循环,所谓的打断就是设置一个线程的打断标记,他不会影响这个线程t2
的继续运行,那等t2
被打断以后,下次再循环进入if
判断时,这个条件就成立了,然后就break
了,当然他得知自己被打断后,他再去读x
的值,肯定是能读到其他线程对在打断前对他的一个变量(x
)的写操作。
【例子6】对变量默认值(0,false,null)的写,对其他线程对该变量的读可见。即默认值优先于其他线程对该变量的读。
【例子7】happens-before具有传递性,如果x hb->y,y又 hb->z,那么就会有x hb -> z。
以上happens-before
例子中的变量都是指共享的变量,也就是指成员变量或静态变量。以上是对happens-before
规则的解读,他其实就是描述了哪些写操作
对其他线程的读操作
是可见
的。