线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。
问题演示举个例子来说,比如银行只有张三一个人来办理业务,这种情况在程序中就叫做单线程执行,而单线程执行是没有问题的,也就是线程安全的。但突然有一天来了很多人同时办理业务,这种情况就叫做多线程执行。如果所有人都一起争抢着办理业务,很有可能会导致错误,而这种错误就叫非线程安全。如果每个人都能有序排队办理业务,且工作人员不会操作失误,我们就把这种情况称之为线程安全的。
接下来我们演示一下,程序中非线程安全的示例。我们先创建一个变量 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);
}
}
以上程序的执行结果如下图所示:
从上述结果可以看出,多线程只要不是同时修改同一个变量,也不会出现线程安全问题。
原子性操作是指操作不能再被分隔就叫原子性操作。比如人类吸气或者是呼气这个动作,它是一瞬间一次性完成的,你不可能先吸一半(气),停下来玩会手机,再吸一半(气),这种操作就是原子性操作。而非原子性操作是我现在要去睡觉,但睡觉之前要先上床,再拉被子,再躺下、再入睡等一系列的操作综合在一起组成的,这就是非原子性操作。
非原子性操作是有可以被分隔和打断的,比如要上床之前,发现时间还在,先刷个剧、刷会手机、再玩会游戏,甚至是再吃点小烧烤等等,所以非原子性操作有很多不确定性,而这些不确定性就会造成线程安全问题问题。
像 i++ 和 i-- 这种操作就是非原子的,它在 +1 或 -1 之前,先要查询原变量的值,并不是一次性完成的,所以就会导致线程安全问题。
比如以下操作流程:
以上就是一个经典的错误,number 原本等于 1,线程 1 进行 -1 操作,而线程 2 进行加 1,最终的结果 number 应该还等于 1 才对,但通过上面的执行,number 最终被修改成了 0,这就是非原子性导致的问题。
4.内存可见性问题在 Java 编程中内存分为两种类型:工作内存和主内存,而工作内存使用的是 CPU 寄存器实现的,而主内存是指电脑中的内存,我们知道 CPU 寄存器的操作速度是远大于内存的操作速度的,它们的性能差异如下图所示:
那这和线程安全有什么关系呢?
这是因为在 Java 语言中,为了提高程序的执行速度,所以在操作变量时,会将变量从主内存中复制一份到工作内存,而主内存是所有线程共用的,工作内存是每个线程私有的,这就会导致一个线程已经把主内存中的公共变量修改了,而另一个线程不知道,依旧使用自己工作内存中的变量,这样就导致了问题的产生,也就导致了线程安全问题。
指令重排序是指 Java 程序为了提高程序的执行速度,所以会对一下操作进行合并和优化的操作。
比如说,张三要去图书馆还书,舍友又让张三帮忙借书,那么程序的执行思维是,张三先去图书馆把自己的书还了,再去一趟图书馆帮舍友把书借回来。而指令重排序之后,把两次执行合并了,张三带着自己的书去图书馆把书先还了,再帮舍友把书借出来,整个流程就执行完了,这是正常情况下的指令重排序的好处。
但是指令重排序也有“副作用”,而“副作用”是发生在多线程执行中的,还是以张三借书和帮舍友还书为例,如果张三是一件事做完再做另一件事是没有问题的(也就是单线程执行是没有问题的),但如果是多线程执行,就是两件事由多个人混合着做,比如张三在图书馆遇到了自己的多个同学,于是就把任务分派给多个人一起执行,有人借了几本书、有人借了还了几本书、有人再借了几本书、有人再借了还了几本书,执行的很混乱没有明确的目标,到最后悲剧就发生了,这就是在指令重排序带来的线程安全问题。
线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,反之则为线程安全问题。简单来说所谓的非线程安全是指:在多线程中,程序的执行结果和预期的正确结果不一致的问题。而造成线程安全问题的因素有 5 个:多线程抢占式执行、多线程同时修改同一个变量、非原子性操作、内存可见性和指令重排序。
关注下面二维码,订阅更多精彩内容。是非审之于己,毁誉听之于人,得失安之于数。
公众号:Java面试真题解析
面试合集:https://gitee.com/mydb/interview
关注公众号(加好友):