上篇文章提到的引起线程不安全问题的原因:(1)抢占式执行,随即调度 (2)多个线程同时修改同一个变量 (3)修改操作不是原子的 (4)内存可见性 (5)指令重排序。对于第一个原因是多线程编程的性质不可改变,第二个原因由于有些特殊场景就是需要多个线程修改同一个变量也无法改变,第三个原因的解决办法是将非原子操作“打包”为原子操作,也就是上锁,本篇文章介绍内存可见性与指令重排序和他们的解决办法。
一、内存可见性问题
当我们写出这样的代码时,t线程的while循环内没有写任何方法,线程启动时当count等于0时会高速循环,t1的方法是改变count的值,从而达到从t2线程修改count使t线程循环结束的效果。
static int count = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(count == 0){
}
});
Thread t1 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
count = scanner.nextInt();
});
t.start();
t1.start();
}
当进程启动时,可以发现:
我们在控制台输入整数修改count的值后并没能使线程结束,如果在while内加上一句输出就会使结果不一样,这就是内存可见性问题。
从指令的角度来理解这个问题:
将这个方法体可以看成两步(1)load 从内存读取数据到寄存器 (2)cmp 比较,条件成立-顺序执行,条件不成立-跳出到另一个地址继续执行。因为当前while循环内为空,循环会高速运转,短时间内造成大量的load-cmp操作反复执行的效果,而load执行的消耗的时间会比cmp大很多倍,执行一次load消耗的时间可以执行几千次上万次cmp操作,此时JVM发现,在t2修改操作之前load每次执行的结果是一样的,于是JVM就把load操作优化掉了,只有第一次的load才是真正的执行了,后续的load都是第一次load读取的值,造成了后续修改操作无法使线程停止的结果。
上述过程多线程确实有锅,但另一方面也是编译器优化的问题,正常来说优化操作是要保证逻辑等价的,遗憾的是优化操作只能在单线程中保证稳定,一旦引入多线程就没那么稳定了。
刚才说如果在循环体内部加上一句输出语句就会使结果不同,因为优化是将速度慢的操作优化掉,使程序执行速度更快,如果循环体内存在IO操作或者sleep等阻塞操作,就会使循环的旋转速度大幅度降低,因为此时线程中最慢的操作就不是load了而是新加入的IO操作,IO操作是不能被优化掉的,刚才load的优化操作被优化掉的前提是反复执行的load运行结果一样,而IO操作的注定反复运行的结果使不同的。那么在while循环体中加入IO操作再试试:
小结:上述操作本质上还是编译器优化导致的,优化掉了t线程的load操作,使t1线程的修改没有t线程感知到——内存可见性问题。
二、如何解决内存可见性问题
针对内存可见性问题可以通过特殊的方式不让它触发优化——volatile关键字,给变量加上这个关键字就像在告诉编译器,这个变量是“变化无常的”,不能按照上述策略进行优化。当编译器不能准确的判断是否该进行优化时,就需要程序员给出一些提示。
static volatile int count = 0;
当给变量加上volatile是告诉编译器不要触发优化,具体在java中,是在javac生成字节码时产生“内存屏障”等相关指令,要注意:volatile是针对内存可见性问题的情景解决问题的,并不能解决两个线程修改一个变量值的问题。
JMM中对于上述问题是这么表述的:当t执行时t要从工作内存来读取count的值而不是主存中,t1修改时先修改工作内存的值然后拷贝到主内存,但由于t线程并没有从主内存重新读取值,导致t没有感知到t1的修改。
主内存(Main Memory):正是平时谈到的内存的英文术语版本。
工作内存:可以翻译成“工作存储区”,与MainMemory进行区分,是缓存和寄存器加在一起的效果,英文术语是cache。
三、指令重排序
程序员写的代码最终会编译成一系列二进制指令,cpu按照编译的顺序一条一条执行,但cpu比较智能,会根据实际情况生成一个二进制指令执行顺序,与你最初写的代码逻辑顺序可能会有所差别,指令重排序的主要目的是提高效率。
重排序的前提是保证逻辑的等价,在单线程模式下,编译器能够准确识别出哪些操作可以进行重排序,多线程模式下判断可能就没那么准确了,重排序后导致逻辑的改变而产生了bug。
从cpu指令的角度来分析指令重排序,先看这个代码块:
class SingleLazy{
private static SingleLazy instance = null;
private static SingleLazy getInstance(){
if(instance == null){
instance = new SingleLazy();
}
return instance;
}
}
当前类的功能是当线程调用时检测当前进程是否已经创建对象,代码的核心功能在于这句,将这句分解为cpu指令来分析大概可以划分为三条指令:
instance = new SingleLazy();
(1)申请内存空间 (2)调用构造方法,为内存空间分配数据 (3)将空间地址赋值给instance引用。在指令重排序时执行顺序不一定是123,也可能是132,这个顺序执行的话问题就会出现:(3)一旦执行完instance就非空了,但由于还未初始化,此时instance指向的数据都是0,多线程模式下,如果有另一个线程调用getInstance就会检测出此时instance非空,那么就会直接把当前数据都为0的instance引用返回,此时就会造成非常严重的bug。
要解决上述问题仍然需要引入volatile,volatile不仅能解决变量的内存可见性问题也能禁止针对这个变量读写操作的指令重排序问题,加上volatile后无论哪个线程调用都会调用到一个完整的构造完成的对象。
上述谈到的指令重排序涉及到的问题本身是一个小概率问题,很难进行验证,加上volatile是万无一失的做法,因为我们也不知道在哪个JVM版本中能更好的处理这个问题,我们能做的就是把volatile该加的地方加好。
多线程编程会引发的线程不安全问题五个要点就已经解释完了,下篇继续更新多线程编程第三篇。
感谢观看
道阻且长,行则将至