面试突击:线程安全问题是如何产生的?

开发 前端
导致线程安全问题的第一大因素就是多线程抢占式执行,想象一下,如果是单线程执行,或者是多线程有序执行,那就不会出现混乱的情况了,不出现混乱的情况,自然就不会出现非线程安全的问题了。

线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。

举个例子来说,比如银行只有张三一个人来办理业务,这种情况在程序中就叫做单线程执行,而单线程执行是没有问题的,也就是线程安全的。但突然有一天来了很多人同时办理业务,这种情况就叫做多线程执行。如果所有人都一起争抢着办理业务,很有可能会导致错误,而这种错误就叫非线程安全。如果每个人都能有序排队办理业务,且工作人员不会操作失误,我们就把这种情况称之为线程安全的。

问题演示

接下来我们演示一下,程序中非线程安全的示例。我们先创建一个变量 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面试真题解析
相关推荐

2022-04-07 07:40:40

线程安全变量

2022-01-24 07:01:20

安全多线程版本

2024-07-10 15:02:27

2013-12-06 17:12:59

2022-02-28 07:01:22

线程中断interrupt

2022-03-23 08:51:21

线程池Java面试题

2022-09-07 07:05:25

跨域问题安全架构

2024-11-14 14:53:04

2022-03-14 07:32:06

线程池拒绝策略自定义

2022-05-11 07:41:55

死锁运算线程

2018-12-28 09:36:06

网络安全漏洞威胁

2022-04-13 14:43:05

JVM同步锁Monitor 监视

2024-09-17 17:50:28

线程线程安全代码

2011-05-20 11:59:32

2011-03-29 10:41:51

Java线程安全

2018-10-10 21:00:50

2015-04-21 10:23:11

2016-07-29 01:56:39

大数据安全问题

2022-04-18 07:36:37

TimeUnit线程休眠

2022-03-28 08:31:29

线程池定时任务
点赞
收藏

51CTO技术栈公众号