JMM
JMM:Java内存模型。定义了主存(所有线程共享的数据)、工作内存(每个线程对应的私有数据)的抽象概念。
JMM存在以下几个特征
- 原子性:保证指令不会受到线程上下文切换所影响。
- 可见性:保证指令不会被CPU缓存所影响。
- 有序性:保证指令不会被CPU指令并行优化的影响。
可见性
public class test{
static boolean run = true;
public static void main(String[] args){
new Thread(()->{
while(run){
//……
}
}).start();
System.out.println("主线程结束子线程");
run = false;
}
}
主线程结束子线程
理论上来讲,当run改为false后,子线程也会跟着结束并结束程序运行。但是实际并不会结束程序,因此主线程结束并不会让子线程结束while循环。
这就是可见性一个体现,不受CPU缓存影响。从JMM解释来看。
解决方案
将run变量使用volatile(异变)关键词修饰。这样就不会从缓存区获取run值,而是从主存中获取。
volatile关键词用来修饰成员变量与静态成员变量,修饰局部变量没意义,因为局部变量是线程私有的,主存中都没带存的。
或是使用synchroized加锁后来修改run的值。因为synchroized在进入保护的代码前会废弃工作内存重新再去主存中读取。[拓展:synchronized在获取锁之前需要去主存中获取保护代码块所需要的变量存储在工作内存中,当释放锁时会将工作内存中的变量刷新到主存中]。
volatile只能解决可见性问题,并不能解决指令交错的问题,因此只适用于一个线程写多个线程读的场景,比如说两个线程分别进行i++与i--,并不能保证能够正常得出结果。
synchronized虽然可以解决原子性与可见性的问题但是属于重量级操作,性能比较低。
有序性
JVM在不影响程序运行的正确性的前提下,会进行指令重排。在多线程下可能会存在安全隐患。
一般情况下赋值操作,是不在乎谁先谁后,因此可以进行指令重排的。但是在多线程下,有时需要使用这些变量,那么可能会存在安全隐患。
public class demo6 {
static int num;
static boolean ready;
static int result;
public static void main(String[] args) {
//线程1
new Thread(() -> {
if (ready) {
result = num + num;
} else {
result = 1;
}
}).start();
new Thread(() -> {
num = 2;
ready = true;
}).start();
}
}
对以上代码进行分析,原则上num与ready的赋值操作先后顺序是无所谓的。但是此时还存在线程1使用这两个变量,这两个变量的结果会对result的结果产生影响。
- 结果1:result值为1,此结果是因为先对num赋值又或是没有赋值,此时ready为false,走了线程1中的else代码。
- 结果2:result值为4,此结果是因为线程2对两个值进行了赋值后,线程1才进行执行走的是if中的代码。
- 结果3:result值为0,此结果是对ready赋值后,但是num还没有进行赋值去执行线程1中的if中代码,导致result值为0。
以上是指令重排序带来的危害,无法预测程序的运行结果,禁止指令重排只需要对ready变量使用volatile修饰即可。
如何保证可见性
写屏障:在volatile修饰的变量之前包括volatile变量,对于共享变量的变动会同步到主存。
读屏障:对于volatile变量之后的变量读取需要从主存中读取。
如何保证有序性
写屏障:在指令重排序时,不会将写屏障之前的代码排序到写屏障之后。
读屏障:在指令重排序时,不会将读屏障之后的代码排序到读屏障之前。
写屏障只能保证能够读取到最新的数据,并不能解决指令交错的问题。有序性只能保重本线程中的代码不会被指令排序
关于单例模式中双重检查锁出现的问题
通过查看创建对象部分的字节码文件来看
前两行是获取单例对象并进行非空判断的字节码,如果不为空则跳转37处。这是第一层if判断
从6到36部分,是synchronized部分字节码,意思是获取类对象,复制一份存储在字符常量池中加锁,获取单例对象判断是否为空,为空则创建出一个对象,复制一份地址,根据地址调用构造器(21)后对单例对象进行复制后解锁,如果不为空跳转到37处。
问题在于21与24可能通过指令重排序后互换位置。那么在多线程中可能出现以下问题
线程2拿到了没有初始化的值去使用,造成空指针异常。