之前说到了多线程的创建和一些属性等等,接下来,就来讲讲多线程安全问题。
小编引入这段代码讲解下:
public class Demo13 {
public static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++){
count++;
}
});
Thread t2=new Thread(()->{
for(int i=0;i<5000;i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
代码内容就是,两个线程分别负责计算5000次累加运算,最后得到的结果是10000
三次运行结果如下:
为什么会这样子呢?
这里就涉及到了线程安全问题。
那么小编来讲讲为什么出现这样子吧。
首先,我们得知道线程调度是不确定性,即count++这个操作具有被篡改的可能性
那么为什么又有被篡改的可能性呢?
这是因为count++操作,不是原子性的?
问题又来了,什么又是原子性操作呢?
原子性操作:即是一个操作过程中,执行期间不会被打断,要么全部执行完成,要么全部不执行。
不存在执行一半就不执行了。
回到上一个问题,count++为什么不是原子性操作呢?
因为count++这个过程,在CPU内部操作,是分成了三个指令操作执行。
1.把内存中的数据,读取到CPU的寄存器中 load
2.把CPU寄存器里的数据+1 add
3.把寄存器里的值写回到内存中 save
当然,值得注意的是,load、add、save三个CPU指令,其表示的英文由于不同架构的CPU,所以有着不同的指令集,所以名称也会有所不同。
比如
ARM 架构:
-
LDR
用于加载数据。 -
ADD
用于加法运算。 -
STR
用于存储数据。
这里load、add、save是计算机操作基础,几乎所有的cpu支持此类操作。
三个指令的操作过程
再回到上一个问题
为什么会被篡改呢?
我们可以得知,两个线程中,均有count++操作
也就是说两个线程中执行到count++,都会执行三个指令操作。
此时这个三个指令操作不是原子性的,并且线程的调度是随机的。
即有这样的一种情况:
当线程1执行到load操作的时候,线程2就被调度了,线程1没有被调度,
此时线程2执行load save add一系列操作,此时内存中count的值=1,如何线程1调度回来,执行add、save,那么此时就会出现count++,只被加了一次的情况
值得说明的是,当时两个线程的load的值还是0
图解如下:
所以由于线程调度的不确定性,导致load、add、save,不确定顺序执行情况有多种。
如若按照t1线程load、add、save后,此时内存中count的值变为1
t2之后也是load、add、save执行,此时内存中count的值变为2
当然,两者调换过来也是一样的的
这样才是执行正确了。
即如图下:
到这里,我们就解释了,为什么这个不能得到10000这个结果了
“罪魁祸首”:还是这个线程调度的不确定性。
同时我们发现,这个count变量被两个线程同时进行修改了,所以这也是出现线程安全问题之一。
出现安全问题的原因有很多的,小编接着带大家看看。
代码实例:
public class Demo16 {
public static int n=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while (n==0){
}
System.out.println("t1循环结束");
});
Thread t2=new Thread(()->{
System.out.println("请输入n的值");
Scanner scanner=new Scanner(System.in);
n=scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果
可以看到,按照正常的逻辑看待,如若n被控制台输入不同的值,此时这个t1循环结束就会被打印。
那么为什么会出现这样的问题?
这里就涉及到了内存可见性问题。
那什么又是内存可见性问题呢?
以上面的例子来举例
t1线程中while条件,判断n是否等于0,那么这个操作呢
操作1.先从内存读取到寄存器中
操作2.通过比较指令,比较寄存器和0的值(这个操作执行的非常快)
相比之下,从内存中读取数据到寄存器中的操作,显得会变得很慢。
此时jvm执行代码的时候发现,
由于执行速度较快,在用户未修改n的值的时候,就是这段时间内,每次进行循环执行操作1,使得执行开销较大,所以jvm变得“胆大”,直接把操作1进行优化掉了。
此时就是不会再读取内存中的值,即读取寄存器中的值。这个操作使得执行操作开销变小了
但是,t2线程中,当用户执行输入的时候,n的值改变,这个值是写回到内存中的,t1线程读取n的值,是在寄存器中了,很明显,位置不一样,t1线程感知不到n的变化,那么就无法结束while循环。
所以,这就是内存可见性问题。(一个线程读,一个线程修改)
那为什么呢编译器会做出这个优化操作呢?
本质上也是为了代码编写的更加高效,因为不是每个程序员都能写出效率很高的代码,
所以即使你代码写的一般,也不会落后很多的执行速度。
值得注意的是,这样的优化是基于原有逻辑不变的基础上的。
解决办法:
在n变量加个volatile关键字
volatile:易变的
即告诉编译器这个变量是易变,不要随意优化它,确保这每次循环都能从内存重新读取数据。
代码修正:
public volatile static int n=0;
当然,还有个出现线程安全的例子:
代码实例:
public class Demo22 {
private static boolean flag = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
// 线程 1
Thread t1 = new Thread(() -> {
number = 42; // 1
flag = true; // 2
});
// 线程 2
Thread t2 = new Thread(() -> {
if (flag) { // 3
System.out.println("Number: " + number); // 4
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
代码确实按道理来说也打印了42了,那为什么小编还说这是有问题呢?
我们之前说过线程的调度是具有不确定性的,所以执行的结果如若没有锁的加持下,结果也是不唯一。
那么它的另一种结果会是输出number:0
出现的原因就是指令重排序了。
那么又是指令重排序呢?
在这个例子中,t1线程中
number = 42;
flag = true;
这两个是独立操作,即变量中,没有依赖关系,那么此时,编译器有可能把flag = true,放到numbe = 42之前执行,此时很显然,如若出现这样的情况,那么t2线程中的if语句就会为真,执行打印代码
但是此时的42没有赋值到number中,那么就有可能打印0的值。
所以这就是指令重排序原因。
那为什么运行结果没有出现0呢?
这是跟jvm和操作系统调度,编译器优化等等原因有关。
解决上面的办法可以通过锁或者volatile关键字来进行处理。
ok,那么来总结下内存可见性和指令重排序
内存可见性:内存可见性是指在一个线程对共享变量进行修改后,这些修改对于其他线程是立即可见的。
指令重排序:指令重排序是指编译器或处理器为了优化性能而改变程序中指令的实际执行顺序。
最后总的来说,出现线程安全问题的原因
1.操作系统,针对线程的调度,是具有不确定性(抢占型执行)(根本原因)
2.代码结构,即多个线程同时修改一个变量
3.修改操作不是原子性的。
4.内存可见性
5.指令重排序