目录
1.线程在系统中是随机调度,抢占式执行;
2.多个线程同时修改同一个变量
3.线程对变量的修改操作指令不是“原子”
4.内存可见性,引起的线程不安全
拓展
小结
5.指令重排序,引起的线程不安全
为了可以更好的解释,给大家看一个错误的代码,问题是两个线程修改同一个变量,每个线程每次对变量+1循环50000次;测试发现,结果都是错误的;
public class demo1 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count++;
}
}
};
Thread t2=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count++;
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
}
}
1.线程在系统中是随机调度,抢占式执行;
可以说这是线程出现安全问题的罪魁祸首,但这是我们无法干预的,这是系统规定好的原则;
2.多个线程同时修改同一个变量
无论是一个线程修改一个变量,一个线程读取一个变量,还是多个线程读取同一个变量都没事,都不会出现安全问题,当多个线程同时修改同一个变量就可能会出现安全问题。String对象不可修改,就是要在一定程度上保证String对象的安全;
那我们就可以不要多个线程同时修改一个变量,从而来规避线程不安全的问题,但是有些时候我们就不等不同时用多个线程来修改同一个变量;
3.线程对变量的修改操作指令不是“原子”
就像上面例子中所说的对一个变量进行++修改,这样一个修改操作需要三个指令,load、add、save,三个指令,所以两个线程的指令在系统的执行又是随机调度的,就会产生无数种错误的情况;
针对以上2,3的原因,一般对修改操作采用加锁的策略,对以上代码进行修改,执行结果是100000;
public class demo1 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Object lock=new Object();
Thread t1=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
synchronized (lock){
count++;
}
}
}
};
Thread t2=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
synchronized (lock){
count++;
}
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
}
}
4.内存可见性,引起的线程不安全
先看一个例子
import java.util.Scanner;
public class demo1 {
private static int count=0;
public static void main(String[] args) {
Thread t1=new Thread(){
@Override
public void run() {
while(count==0){
}
}
};
Thread t2=new Thread(){
@Override
public void run() {
Scanner sc=new Scanner(System.in);
count=sc.nextInt();
}
};
t1.start();
t2.start();
}
}
线程t1是循环只要count!=0就结束,t2修改count的值,预期效果:输入非0的值的时候,线程t1就会结束,整个程序也就会结束,但实际当输入1时,线程t1不结束,程序也不结束;原因如下:
while循环的指令可以分成两个:
- load:将count的值从内存加载到cpu寄存器上,
- cmp 将count的值与0作比较
由于指令1是将数据从内存加载到寄存器上,而指令2只是在寄存器上作比较,两个指令的执行速度差了几个数量级;在上述执行过程中,load一次的时间,cmp可以执行成千上万遍,而且jvm还发现每次load的结果都一样,所以,jvm就会将上述代码优化,只有第一次load的时候才是真正从内存加载到寄存器,后面的每一次load都是不是真正的load,而是直接读取第一次load加载到寄存器上的值,使代码执行得更快;
这就导致后续线程2修改了count的值,修改的是内存上count的值,但是线程1已经不会再去内存上读取count,而是直接读取了寄存器上的值,所以线程1依旧在执行while循环;
优化,是为代码执行得更快,但前提是优化前后的逻辑不会改变,值得注意的是在单线程中jvm的优化基本是不会有问题,在多线程中就需要注意了
为了解决上述多线程中的内存可见性完全问题,java引入了volatile关键字,它的作用就是提示编译器这个变量后续可能会进行修改,不需要编译优化;
private static volatile int count=0;
拓展
有趣的是,如果在while循环中打印一句话,代码就会如预期效果一样,原因就是:如果循环体有IO操作或者阻塞,这就会使循环体的执行速度大幅度下降,且这个IO操作的执行速度甚至要比上面的load执行要慢很多个数量级,这时候jvm就会觉得优化掉load没有必要而不去优化掉load指令了;
小结
上述问题的根本还是编译器优化引起的,jvm优化掉load,线程2修改count的值,线程1无法感知,这也就是所谓的 内存可见性问题;
5.指令重排序,引起的线程不安全
指令重排序,顾名思义就是指令重新排序而导致的线程安全,这仍然是一个又编译器优化导致的问题,且在单线程发生指令重排序是安全,但是多线程发生指令重排序可能就会出现问题了;
你要在一个市场里鸡蛋,盐,番茄,猪肉,但是在不同的地方,会发生一下两种情况,无疑发生指令重排序确实是可以优化了购买过程且保证线程是安全的;
直接下看多线程中,指令重排序是如何可能造成线程不安全的,这里需要先去了解一下单例模式中的懒汉模式,我会结合该模式进行分享,两种单例模式(保证线程安全)这是小生写的有关单例模式的博客
在看代码之前,先说明一下,我们把代码 singletonLazy=new SingletonLazy() 大致分成三个指令,其实远不止三个指令,这里是为了更好说明,三个指令分别是:·1.申请内存空间 2.调用构造方法 3.把此时内存空间的地址,赋值给singletonLazy引用;在编译器优化,指令重排序的情况下,上述过程有可能是1 2 3 ,也有可能是1 3 2,但是1一定先执行的,下面例子就按照 1 3 2的可能进行分析:
if(singletonLazy==null){
synchronized (singletonLazy){
if(singletonLazy==null){
singletonLazy=new SingletonLazy();
}
}
}
return singletonLazy;
以上为假设两个线程的代码执行顺序,从左到右,从上到下,在t1线程的指令3执行结束之后,轮到t2执行,由于此时singletonLazy不是null,所以t2线程会直接返回一个指向内存均为0的空间,以至于后面的线程访问该唯一实例化对象是会发生错误,从而引发线程不安全;
其实代码是因为双重否定造成的线程不安全,如果没有外层的if判断,t2执行到锁的时候就会阻塞等待线程1的结束,也就不会有上述线程安全问问题了,但是该单例模式的代码是不能改变的;
解决方法:用volatile关键字修饰改变量,volatile不仅能针对内存可见性的多线程安全问题,还能禁止针对修改变量可能发生的指令重排序而导致线程安全问题,一个代码中会有很多地方发生指令重排序,这是由于编译器优化,volatile只能保证修饰的变量的读写操作的指令不会发生指令重排序;
private static volatile SingletonLazy singletonLazy=null;