首先我们来看一下这样一个代码:
public class ThreadJoin {
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();
Thread.sleep(8000);
System.out.println(count);
}
}
8秒的时间足够两个线程执行5000次++
操作,我们的预期结果是两个线程同时对变量count
进行++
5000次,最后得到的结果为10000,但我们运行之后发现结果并不是我们想要的结果,这就是线程不安全的所导致的,如何导致的呢我们进行分析。
首先++
操作我们可以分解为三个CPU指令:
1、load 把内存中的数据读取到CPU寄存器中
2、add 就是把寄存器中的值进行 +1 操作
3、save 把寄存器中的值写回内存当中
那么在多线程操作时,就有可能出现下面的情况,此时线程1从内存中读取数据到CPU寄存器中后,没有等到add、saved等操作执行完,线程2就又从内存中向CPU寄存器中读取了一次数据。
此时的这样的操作虽然造成了执行两次++
操作,但是实际上内存中的count
变量相当于只增加了一次,最终导致了最后的结果与预期的不相同,这就是线程的不安全性。
还有一些线程不安全的情况,例如下面的几个例子:
import java.util.Scanner;
public class ThreadDemo {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
System.out.println("flag已更改");
});
t1.start();
t2.start();
}
}
上述代码的逻辑当我们在t2线程中输入值更改flag
的值,此时t1线程应该结束循环并且线程执行完毕,但实际上我们更改了flag
的值后,t1线程并没有结束,这是为什么呢?这是内存可见性的问题,判断flag == 0
时会有两个操作:
1、load 从内存读取数据到CPU寄存器
2、cmp 比较寄存器里的值是否为 0
我们都知道,内存的读写速度比硬盘快几千倍,而寄存器的读写速度又比内存快几千倍,所以load操作消耗的时间远远超过了cmp的操作,并且编译器发现了,每次load的结果都一样,此时编译器做了一个大胆的决定,优化了load,只进行一次load剩下的循环都只cmp,不进行load。
因为内存可见性这个原因,虽然我们修改了内存中flag的值,但是在进行判断时并没有重现从内存中拿到修改过的flag所以造成了BUG的出现。
我们分析一下上述情况造成线程不安全的原因有哪些:
1、首先就是线程的调度是无序的,是随机的,我们成为抢占式执行。
2、使用多个线程修改同一个变量。
3、修改的操作不是原子的,原子就是不可再分的,向上述的++
操作,还可以分为load add save
等CPU指令,单个的指令对于CPU来说就是原子的。
4、内存可见性:多线程环境下,编译器对于代码的优化产生了误判,从而引起了一些BUG。
5、指令重排序,也是编译器优化的一种策略,调整代码的顺序,保证最后结果不变的情况下,让程序更高效。
知道了造成线程不安全的原因,我们就需要解决,我们可以直接使用join()
方法,让程序最后获得正确的结果,这样只是让两个线程完整的进行串行,我们的目标是并发的完成任务,这个时候我们就可以使用锁synchronized
。
public class ThreadJoin {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
for(int i = 0;i < 5000;i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for(int i = 0;i < 5000;i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
Thread.sleep(8000);
System.out.println(count);
}
}
为什么加锁就可以解决线程不安全问题呢?因为加锁可以让++
操作变为原子的。这个操作就像上厕所,++
操作就是上厕所的人,只有一个厕所必须等上一个人上完打开锁,第二个人才能进去。此时给++
操作上锁,t1抢到了锁,那么t2就必须等着,等t1的++
操作的三个CPU指令load add save
执行完毕,t2才能有机会抢到锁,执行++
操作,这样就解决了问题。
需要有几点注意:
锁对象必须是一个Object
对象,锁对象就好像是厕所,当很多个人需要上厕所,但是只有一个厕所时才会发生竞争,同理,多个线程中只有锁对象相同才会发生锁竞争。
至于内存可见性和指令重排序造成的多线程不安全的问题,我们可以使用volatile
解决问题。
import java.util.Scanner;
public class ThreadDemo {
volatile public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
System.out.println("flag已更改");
});
t1.start();
t2.start();
}
}