Java并发编程之美03
- 1. 共享模型之内存
- 1.1.Java内存模型
- 1.2.可见性
- 1.2.1.可见性问题分析
- 1.2.2.解决方法
- 1.2.3.可见性和原子性
- 1.2.4.设计模式-两阶段终止模式
- 1.2.5.设计模式-同步模式之Balking
- 1.3.有序性
- 1.3.1.指令重排序优化
- 1.3.2.支持流水线的处理器
- 1.3.3.有序性问题
- 1.4.Volatile原理
- 1.4.1.如何保证可见性
- 1.4.2.如何保证有序性
- 1.4.3.double-checked-locking(DCL)问题
- 1.4.4.double-checked-locking(DCL)解决
1. 共享模型之内存
<Java并发编程02>说的Monitor主要关注的是访问共享变量时,保证临界区代码的原子性.接下里我们需要说的是多线程间的可见性问题和多条指令执行的有序性.
1.1.Java内存模型
JMM即Java Memory Model,它定义了主存(所有线程的共享数据),工作内存(所有线程的私有数据)抽象概念,底层对应着CPU寄存器,缓存,硬件内存,CPU指令优化等;
JMM体现在以下几个方面:
- 原子性-保证指令不会受到线程上下文切换的影响;
- 可见性-保证指令不会受到CPU缓存的影响;
- 有序性-保证指令不会受到CPU指令并行优化的影响;
1.2.可见性
1.2.1.可见性问题分析
@Slf4j(topic = "DemoTest")
public class DemoTest {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (run){
}
});
t.start();
TimeUnit.SECONDS.sleep(1);
run = false;
}
}
线程并不会如我们预想的那样停下来.分析:
- 初始状态,t线程刚开始从主内存读取了run的值到工作内存
- 因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少主存中的run的访问,提高效率.
- 1秒之后,main线程修改了run的值,并同步至主存,而t是从自己的工作内存中的高速缓存中读取这个变量的值,结果永远是旧值;
1.2.2.解决方法
volatile可以用来修饰成员变量和静态成员变量,他可以避免从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存;
1.2.3.可见性和原子性
前面体现的实际就是可见性,它保证的是多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况;
synchronized语句块既可以保证代码的原子性,也同时保证代码块内变量的可见性,但缺点是sychronized是属于重量级操作,性能相对较低;
1.2.4.设计模式-两阶段终止模式
@Slf4j(topic = "DemoTest")
public class DemoTest {
private volatile boolean flag = true;
public void start(){
Thread monitor = new Thread(()->{
while (true){
if(!flag){
log.info("料理后事");
break;
}
log.info("正在执行监控");
}
});
monitor.start();
}
public void stop(){
flag = false;
}
}
1.2.5.设计模式-同步模式之Balking
Balking(犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回.
@Slf4j(topic = "bulking")
public class Balking {
/**
* 判断是否执行过start方法
*/
private boolean starting = false;
private Thread monitorThread;
public void start() {
synchronized (this) {
if (starting) {
return;
}
starting = true;
monitorThread = new Thread(() -> {
log.info("正在监控");
}, "monitor");
monitorThread.start();
}
}
}
Balking模式还用在实现线程安全的单例
public final class BalkingSingleton {
private BalkingSingleton(){}
private static BalkingSingleton INSTANCE = null;
public static synchronized BalkingSingleton getInstance(){
if(INSTANCE != null){
return INSTANCE;
}
INSTANCE = new BalkingSingleton();
return INSTANCE;
}
}
1.3.有序性
JVM会在不影响正确性的前提下,可以调整语句的执行顺序.这种特性称之为指令重排,多线程下指令重排会影响正确性,为什么要有指令重排这项优化了?我们可以从CPU执行指令的原理来理解.
1.3.1.指令重排序优化
事实上,现代处理器会设计一个时钟周期完成一条执行时间最长的CPU指令,指令还可以划分成一个个更小的阶段.例如,每条指令都可以分为:取指令->指令译码->执行指令->内存访问->数据写回
,这5个阶段;
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行
1.3.2.支持流水线的处理器
现在CPU支持多级指令流水线,例如支持同时执行取指令->指令译码->执行指令->内存访问->数据写回
的处理器,就可以称之为五级指令流水线.这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC=1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率.
1.3.3.有序性问题
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;
}
情况1:线程1先执行,这时ready=false,所以进入else分支结果为1;
情况2:线程2先执行num=2,但还没来得及执行ready=true,还是进入else分支,结果为1;
情况3:线程2执行到ready=true,线程1执行,这回进入if分支,结果为4;
情况4:线程2执行ready =true,切换到线程1,进入if分支,相加为0;
1.4.Volatile原理
volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence);
- 对volatile变量的写指令后会加入写屏障;
- 对volatile变量的读指令前会加入读屏障;
int num = 0;
volatile 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;
}
1.4.1.如何保证可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到内存中;
public void actor2(I_Result r){
num = 2;
ready = true;//ready是volatile赋值带写屏障
//写屏障
}
- 读屏障(lfence)保证在屏障之后,对共享变量的读取,加载的是主存中的最新数据;
public void actor1(I_Result r){
//读屏障
//ready是volatile读取值带读屏障
if(ready){
r.r1 = num+num;
}else{
r.r1 = 1;
}
}
1.4.2.如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后;
public void actor2(I_Result r){
num = 2;
ready = true;//ready是volatile赋值带写屏障
//写屏障
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前;
public void actor1(I_Result r){
//读屏障
//ready是volatile读取值带读屏障
if(ready){
r.r1 = num+num;
}else{
r.r1 = 1;
}
}
但是并不能解决指令交错(下图所示):
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去;
- 而有序新的保证也只是保证了本线程内的相关代码不被重新排序;
1.4.3.double-checked-locking(DCL)问题
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化;
- 首次使用
getInstance()
才使用synchronized加锁,后续使用无需加锁; - 第一个if使用了INSTANCE变量,是在同步代码块之外,但在多线程环境下,上面的代码是由问题的.
getInstance方法对应的字节码为:
- 17表示创建对象,将对象应用入栈; //new Singleton;
- 20表示复制一份对象引用 //引用地址
- 21表示利用一个对象引用,调用构造方法;
- 24表示利用一个对象引用,赋值给static INSTANCE
也许JVM会优化为先执行24,再执行21.如果两个线程t1,t2按如下时间序列执行:
关键就在于t1还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到将是一个未初始化完毕的单例;对INSTANCE使用volatile修饰即可,可以禁用指令重排,但要注意的是在JDK5以上的版本的volatile才会真正有效;
1.4.4.double-checked-locking(DCL)解决
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) {
// 实例没有创建,才会进入内部的synchronized代码锁;
synchronized(Singleton.class) {
//也许有其他线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}