面试突击36:线程安全问题是怎么产生的?
线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。
举个例子来说,比如银行只有张三一个人来办理业务,这种情况在程序中就叫做单线程执行,而单线程执行是没有问题的,也就是线程安全的。但突然有一天来了很多人同时办理业务,这种情况就叫做多线程执行。如果所有人都一起争抢着办理业务,很有可能会导致错误,而这种错误就叫非线程安全。如果每个人都能有序排队办理业务,且工作人员不会操作失误,我们就把这种情况称之为线程安全的。
问题演示
接下来我们演示一下,程序中非线程安全的示例。我们先创建一个变量 number 等于 0,然后开启线程 1 执行 100 万次 number++ 操作,同时再开启线程 2 执行 100 万次 number-- 操作,等待线程 1 和线程 2 都执行完,正确的结果 number 应该还是 0,但不加干预的多线程执行结果却与预期的正确结果不一致,如下代码所示:
public class ThreadSafeTest {
// 全局变量
private static int number = 0;
// 循环次数(100W)
private static final int COUNT = 1_000_000;
public static void main(String[] args) throws InterruptedException {
// 线程1:执行 100W 次 number+1 操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number++;
}
});
t1.start();
// 线程2:执行 100W 次 number-1 操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number--;
}
});
t2.start();
// 等待线程 1 和线程 2,执行完,打印 number 最终的结果
t1.join();
t2.join();
System.out.println("number 最终结果:" + number);
}
}
以上程序的执行结果如下图所示:
从上述执行结果可以看出,number 变量最终的结果并不是 0,和我们预期的正确结果是不相符的,这就是多线程中的线程安全问题。
产生原因
导致线程安全问题的因素有以下 5 个:
- 多线程抢占式执行。
- 多线程同时修改同一个变量。
- 非原子性操作。
- 内存可见性。
- 指令重排序。
接下来我们分别来看这 5 个因素的具体含义。
1.多线程抢占式执行
导致线程安全问题的第一大因素就是多线程抢占式执行,想象一下,如果是单线程执行,或者是多线程有序执行,那就不会出现混乱的情况了,不出现混乱的情况,自然就不会出现非线程安全的问题了。
2.多线程同时修改同一个变量
如果是多线程同时修改不同的变量(每个线程只修改自己的变量),也是不会出现非线程安全的问题了,比如以下代码,线程 1 修改 number1 变量,而线程 2 修改 number2 变量,最终两个线程执行完之后的结果如下:
public class ThreadSafe {
// 全局变量
private static int number = 0;
// 循环次数(100W)
private static final int COUNT = 1_000_000;
// 线程 1 操作的变量 number1
private static int number1 = 0;
// 线程 2 操作的变量 number2
private static int number2 = 0;
public static void main(String[] args) throws InterruptedException {
// 线程1:执行 100W 次 number+1 操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number1++;
}
});
t1.start();
// 线程2:执行 100W 次 number-1 操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number2--;
}
});
t2.start();
// 等待线程 1 和线程 2,执行完,打印 number 最终的结果
t1.join();
t2.join();
number = number1 + number2;
System.out.println("number=number1+number2 最终结果:" + number);
}
}
以上程序的执行结果如下图所示:
从上述结果可以看出,多线程只要不是同时修改同一个变量,也不会出现线程安全问题。
3.非原子性操作
原子性操作是指操作不能再被分隔就叫原子性操作。比如人类吸气或者是呼气这个动作,它是一瞬间一次性完成的,你不可能先吸一半(气),停下来玩会手机,再吸一半(气),这种操作就是原子性操作。而非原子性操作是我现在要去睡觉,但睡觉之前要先上床,再拉被子,再躺下、再入睡等一系列的操作综合在一起组成的,这就是非原子性操作。
非原子性操作是有可以被分隔和打断的,比如要上床之前,发现时间还在,先刷个剧、刷会手机、再玩会游戏,甚至是再吃点小烧烤等等,所以非原子性操作有很多不确定性,而这些不确定性就会造成线程安全问题问题。
像 i++ 和 i-- 这种操作就是非原子的,它在 +1 或 -1 之前,先要查询原变量的值,并不是一次性完成的,所以就会导致线程安全问题。
比如以下操作流程:
操作步骤 | 线程1 | 线程2 |
---|---|---|
T1 | 读取到 number=1,准备执行 number-1 的操作,但还没有执行,时间片就用完了。 | |
T2 | 读取到 number=1,并且执行 number+1 操作,将 number 修改成了 2。 | |
T3 | 恢复执行,因为之前已经读取了 number=1,所以直接执行 -1 操作,将 number 变成了 0。 |
以上就是一个经典的错误,number 原本等于 1,线程 1 进行 -1 操作,而线程 2 进行加 1,最终的结果 number 应该还等于 1 才对,但通过上面的执行,number 最终被修改成了 0,这就是非原子性导致的问题。
4.内存可见性问题
在 Java 编程中内存分为两种类型:工作内存和主内存,而工作内存使用的是 CPU 寄存器实现的,而主内存是指电脑中的内存,我们知道 CPU 寄存器的操作速度是远大于内存的操作速度的,它们的性能差异如下图所示:
那这和线程安全有什么关系呢?
这是因为在 Java 语言中,为了提高程序的执行速度,所以在操作变量时,会将变量从主内存中复制一份到工作内存,而主内存是所有线程共用的,工作内存是每个线程私有的,这就会导致一个线程已经把主内存中的公共变量修改了,而另一个线程不知道,依旧使用自己工作内存中的变量,这样就导致了问题的产生,也就导致了线程安全问题。
5.指令重排序
指令重排序是指 Java 程序为了提高程序的执行速度,所以会对一下操作进行合并和优化的操作。
比如说,张三要去图书馆还书,舍友又让张三帮忙借书,那么程序的执行思维是,张三先去图书馆把自己的书还了,再去一趟图书馆帮舍友把书借回来。而指令重排序之后,把两次执行合并了,张三带着自己的书去图书馆把书先还了,再帮舍友把书借出来,整个流程就执行完了,这是正常情况下的指令重排序的好处。
但是指令重排序也有“副作用”,而“副作用”是发生在多线程执行中的,还是以张三借书和帮舍友还书为例,如果张三是一件事做完再做另一件事是没有问题的(也就是单线程执行是没有问题的),但如果是多线程执行,就是两件事由多个人混合着做,比如张三在图书馆遇到了自己的多个同学,于是就把任务分派给多个人一起执行,有人借了几本书、有人借了还了几本书、有人再借了几本书、有人再借了还了几本书,执行的很混乱没有明确的目标,到最后悲剧就发生了,这就是在指令重排序带来的线程安全问题。
总结
线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,反之则为线程安全问题。简单来说所谓的非线程安全是指:在多线程中,程序的执行结果和预期的正确结果不一致的问题。而造成线程安全问题的因素有 5 个:多线程抢占式执行、多线程同时修改同一个变量、非原子性操作、内存可见性和指令重排序。
是非审之于己,毁誉听之于人,得失安之于数。
公众号:Java面试真题解析