一、何为线程安全问题
我们在详细了解线程安全问题前,我们首先要了解的就是所谓的线程安全问题是什么,是什么原因造成了线程安全问题。
在这里,全部的万恶之源,罪魁祸首都来自于一点——多线程的抢占式执行。
如果没有多线程,此时程序的运行只能是固定的,代码的运行顺序固定,程序的结果就是固定的。
但是在多线程,抢占式执行下,此时代码就会出现很多的变数,**代码的执行顺序就成单一情况变成了无数种情况!**因此要保证这种情况下代码的运行正确就非常困难,只要在许多种情况中,有一种情况代码的结果不正确,就是视为线程不安全!
二、解释线程安全问题——抢占式执行
1. 通过代码展示
单纯的文字解释并不能很好的理解问题的本质,所以,我通过下面的代码来进行更加详细的解释。
class Counter{
public int count = 0;
public void increase(){
count++;
}
}
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
//启动两个线程
t1.start();
t2.start();
//让主线程等待线程的执行
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果如下:
从上面的两次结果我们不难发现,不但每次的计算数字不同,而且,计算的值与预期完全不符,预期为 10W 但是完全达不到。
2.通过计算机底层逻辑解释
要更好的解释这个问题我们就不得不说到 “count++” 这个操作的底层逻辑了。
- load:先将内存中的值,读取到 CPU 寄存器中。
- add:将 CPU 寄存器中的数值 +1 运算。
- save:将得到的结果写入内存中。
下面通过画图来简单解释一下:
情况1:
上面是两种最理想的情况,两个线程正好都可以完整的执行一个周期。
底层逻辑图示:
情况2:
上图是两种一般情况(一般情况有很多种,这里就简单举出两个例子),可以看出,因为抢占式执行的原因,每个线程都不能做到完整的一个周期,这就是出错的根本来源!
底层逻辑图示
不难理解,正是因为这些一般情况,最终的导致计算结果出现很大的偏差。
三、出现线程安全问题的原因
我们肯定会有疑问,到底什么情况下会出现线程安全问题?是所有涉及到多线程的代码都会有线程安全问题吗?
整体上来看,最典型的有下面5种:
-
抢占式执行,随机调度。(根本原因)
这一点在前面已经进行了详细的解释。 -
代码结构:多个线程共同修改同一个变量
这个问题可以通过调整代码结构来规避,但是并不是所有的问题都可以调整代码结构来解决,这种方法的普适性较低。 -
原子性:以上面提到的 “count++” 拆分出的三个操作来说,其中的单个指令已经不能再拆分了,这就是原子性。
针对线程安全问题,让每个线程执行的操作原子化,即就是加锁操作将非原子转换为原子。 -
内存可见性问题: 一个线程针对一个变量进行读取,同时另一个线程针对这个变量进行修改,此时读到的值不一定就是修改后的值。 这个读线程没有感知到变量的变化。(这个原因会在后面的文章中进行详细的介绍)
-
指令重排序:本质上就是编译器优化出现 bug
编译器为了加快执行效率,在保证逻辑不改变的情况下,将代码自作主张的进行了调整。