线程安全之可见性(一)
一:举个栗子
先举个例子:
上述代码可以看到:在主线程main方法中,启动了一个子线程,子线程所做的事情,就是对i执行++操作,直到flag为false退出循环,输出i的值。主线程做的事情是,启动子线程,休眠3s后,把flag置为false,然后结束。
经过上面的分析,来打印下执行结果:
what?并没有打印出i的值,这是为什么呢?
经过对代码的分析,可以得到应该会打印i的值,可是最后并没有,那么可以判断,子线程在执行的过程中,并没有退出while循环,这是为何?
二:延申分析
1:CPU高速缓存
为了提高CPU的性能,CPU有高速缓存这么个东西,具体的概念可以去百度看下,对此我了解的并不深。
看下上图:线程再去写或者读数据的过程中,会先去高速缓存中读写,在短时间内,主线程和子线程的数据可能会出现不一致的情况,但是呢,不会再3s这么长的时间,所以上面代码出现的问题,并不是高速缓存导致的,高速缓存并不愿意去背锅,那么是什么导致的呢?
2:指令重排
在JVM执行字节码的过程中,会对字节码指令进行重排序,来提高执行的效率,但是要保证单线程内最后的执行结果不能发生改变,在多线程中,可能就会因为指令重排导致某个线程执行的结果发生错误;下面看下例子:
上图中,假设线程1和线程2同时间执行,那么最终的结果是:线程1中:a=b,c=1;线程2中:d=c,e=f/2,假设c的初始值是0,那么线程2中d就是等于0,下面指令重排序后,会发生什么呢?
假设上图是指令重排之后的结果:可以看到线程1进行了重排序,线程2还是原有顺序执行,可以看出,线程2中d的最终结果发生了变化,变成了1,这就是指令重排会导致的问题。
3:脚本代码和编译代码
脚本代码和编译代码是有着区别的,具体是什么呢?
1:脚本代码(解释执行):它会把代码,一行一行的翻译执行;
2:编译代码(编译执行):会把一整块代码,编译后执行。
而Java是介于脚本代码和编译代码之间的。
java源代码会被编译成class文件,用到的是执行前编译器,而运行时编译器,就是常说的JIT编译器,看下图:
可以看到,JIT编译器中有解释执行和编译执行,当一个方法被多次调用或者方法中的循环体被多次循环,那么就会从解释执行转为编译执行。而我们知道在JVM的方法区中,有JIT编译后的代码,他会对这样的代码,进行优化,如上图编译执行指向的代码,这样他就把true这么一个值,缓存在里面了,那么就导致了上面代码没法打印出i的值,这就是上面代码出现问题的根本原因。
怎么解决呢?在flag修饰符中加上volatile就可以解决了。
4:volatile关键字
用volatile修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最的值。
反编译之后可以看到,它有个ACC_VOLATILE标志。
具体怎么实现:
1:禁止缓存,也就是volatile修饰的变量禁止缓存,这样写入的时候就没有缓存了,那么CPU高速缓存导致的一点问题就可以解决了。
2:volatile修饰的,不能进行指令重排序,也就是说上面说的方法区缓存那一部分也没有了,这样就可以保证打印出i的值了:
打印结果: