线程安全的考虑方面
线程安全需要考虑3个方面:可见性、有序性、原子性
- 可见性:一个线程对共享变量修改,另一个线程能看到最新结果
- 有序性:一个线程内代码按编写顺序执行
- 原子性:一个线程内多行代码以一个整体运行,期间不能有其它线程的代码插队
volatile能够保证共享变量的可见性与有序性,但不能保证原子性
原子性
public static volatile int i = 5;
/**
伪代码:t1执行i+=5;t2执行i-=5;
理想情况下i==5;
*/
t1:i+=5;
t2:i-=5;
/**
但在jvm底层,i+=5,i-=5会被分解为4步操作
t1:
0:getstatic
3:iconst_5
4:iadd
5:putstatic
t2:
0:getstatic
3:iconst_5
4:isub
5:putstatic
在CPU并发运行时,可能会将指令运行为
t1:
0:getstatic //5
t2:
0:getstatic //5
3:iconst_5 //5
4:isub //0
5:putstatic //0
3:iconst_5 //5
4:iadd //10
5:putstatic //10
得到最终i==10,所以volatile不能保证原子性
*/
可见性
// 可见性例子
// -Xint
public class ForeverLoop {
static volatile boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
get().debug("modify stop to true...");
},"t1").start();
new Thread(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
get().debug("{}", stop);
},"t2").start();
new Thread(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
get().debug("{}", stop);
},"t3").start();
foo();
}
static void foo() {
int i = 0;
while (!stop) {
i++;
}
get().debug("stopped... c:{}", i);
}
}
解释代码并运行
上述代码里创建了3个线程,t1线程负责休眠100ms后修改共享变量stop=true,让主线程中的foo方法里的循环终止。
不处理可见性问题:如上述代码所示假设stop没有添加volatile关键字,那么运行代码得到结果如下:
[DEBUG] 21:37:39.681 [t2] - true
[DEBUG] 21:37:39.681 [t3] - true
[DEBUG] 21:37:39.681 [t1] - modify stop to true...
....// 循环还在继续
**分析结果:**为什么t2、t3线程获取到的stop为true,而主线程却获取不到t1线程更新后的stop变量呢?
解释原因:这是由于Java中的JIT( Just In Time Compiler 即时编译器)对while(!stop)循环做出了编译优化,当它发现while循环获取到的stop变量在一定阈值之内都是false(我电脑上运行100ms循环了2.5千万次)它就会为了减少CPU与物理内存的频繁读取,就会将原来while(!stop)的字节码改为while(!false)的字节码然后编译成机器码储存起来,后面在执行这行代码它就会将这个机器码交于CPU运行,最终导致主线程中的stop变量无法得到更新,但是物理内存中stop的值已经被修改成true,故t2、t3线程能获取到stop的值为true。
任何一条java代码都是翻译为了字节码指令,但是这个字节码指令并不能直接交给CPU执行,还需要一个解释器的组件,这个解释器会将java字节码指令逐行翻译成机器码交给CPU执行。JIT就是对java中热点的代码(频繁调用的方法,反复执行的循环)进行优化。
如何解决:
- 禁止使用JIT(不可取):运行时添加虚拟机参数
-Xint
即可禁用JIT,但是会影响整个程序的运行效率。JIT优化c1层的优化可以提升5倍的优化,c2层编译器可提升10-100倍的优化。故不可取。 - 将变量加上volatile关键字修饰:当JIT发现该变量是由volatile修饰则不会进行优化。
有序性
利用jcstress进行线程压测
情况一:线程不安全。指令重排。
@JCStressTest
@Outcome(id = {"0, 0", "1, 1", "0, 1"}, expect = Expect.ACCEPTABLE, desc = "ACCEPTABLE")// 理想情况就这3种
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "INTERESTING")// 指令重排后的情况
@State
public static class Case1 {
int x;
int y;
@Actor
public void actor1() { // 线程1
x = 1;
y = 1;
}
@Actor
public void actor2(II_Result r) { // 线程2
r.r1 = y;
r.r2 = x;
}
}
当指令重排后代码执行顺序跟改为:
y = 1; y = 1;
r.r1 = y; r.r2 = x;
r.r2 = x; r.r1 = y;
x = 1; x = 1;
这时代码重排序,造成了1,0这种情况。
情况二:给y加上volatile关键字修饰
此时就不会发生指令重排序,因为volatile关键字修饰的变量会在读操作前加上一个内存屏障,在它之前的指令都不可以越过它先执行,在写操作后也加上一个内存屏障,在它之后的指令都不可以越过它先执行。
情况三:给x加上volatile关键字修饰
此时还是会发生指令重排序,因为给x加上volatile关键字修饰后,y = 1;还是可以在x = 1;之前执行,r.r1 = y;还是可以在r.r2 = x;之后运行。下图所示就会出现1,0的情况。