深入分析线程安全问题的本质
深入分析线程安全问题的本质
线程安全问题是并发编程中常见的问题,主要表现为原子性问题、可见性问题和重排序问题。本文将深入分析这些问题的本质,并提供相应的解决方案。
1. 并发编程背后的性能博弈
随着科技的进步,CPU、内存和I/O设备的性能不断提升,但它们之间的速度差异仍是计算机设计的核心问题。
简单来说,CPU在运算时,必须从内存中读取数据和指令,而CPU的计算速度远高于内存的I/O速度,导致了性能瓶颈。
根据木桶理论,程序的整体性能受最慢操作的限制。大多数程序都频繁访问内存或进行I/O操作,因此单纯提升CPU性能并不足以突破瓶颈。
为了缓解这一问题,计算机架构、操作系统和编译器都作出优化:
- 缓存机制:CPU通过引入L1、L2、L3缓存来弥补与内存的速度差异。
- 进程与线程管理:操作系统采用进程和线程分时复用机制,平衡CPU与I/O速度差异。
- 编译器优化:优化指令执行顺序,提高缓存利用率,减少CPU等待内存的时间。
然而,这些优化在提升系统性能的同时,也为并发编程带来了新的挑战,尤其是线程安全问题。
2. 什么是线程安全问题?
线程安全问题指的是当多个线程同时访问共享资源时,程序无法按预期正常执行,导致数据不一致。
导致线程不安全的原因主要有三个:
- 原子性问题:多个线程同时访问共享变量时,可能会中断或交叉执行,导致不正确的结果。
- 可见性问题:一个线程对共享变量的修改,其他线程可能看不到,造成数据不一致。
- 重排序问题:由于编译器、JVM或处理器优化,导致代码执行顺序不一致,产生错误的结果。
下面我们具体分析下。
3. 源头之一:原子性问题
什么是原子性呢?
- 在数据库事务的ACID特性中就有原子性,它意味着一个操作要么完全成功,要么完全失败,不允许部分成功或部分失败。
- 在多线程编程中,原子性指的是一个操作(或一组操作)在执行过程中不能被中断,保证要么全部完成,要么全部不做。
下面我们来演示一个多线程中出现原子性问题的例子。
3.1. 原子性问题示例
public class MultiThreadsError implements Runnable {
static MultiThreadsError instance = new MultiThreadsError();
int index = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(instance);
Thread thread2 = new Thread(instance);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("结果是" + instance.index); //小于20000,每次结果不一样
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
index++;
}
}
}
打印结果:
结果是11028
原因分析:
index++
不是原子操作:
- 读取
index
的值。 - 将值加 1。
- 将新的值写回
index
。
- 多线程相互干扰:线程1和线程2可能同时读取相同的
index
值,然后各自加 1 并写回,导致加 1 操作互相覆盖,最终结果不正确。
3.2. 原子性问题分析
从本质上说,原子性问题产生的原因有两个:
- CPU时间片切换;
- 非原子性指令:例如,
index++
不是一个原子操作,它包含多个步骤(读取、加1、写回)。
CPU时间片切换
现代操作系统通过时间片轮转调度线程,每个线程在CPU上运行一段时间后会被切换。
这种切换可能导致线程在操作共享数据时产生原子性问题。例如,线程A读取某个变量的值,然后准备修改它,但在修改之前,线程A被切换出去,线程B也修改了这个值。结果,线程A的操作就丢失了,导致数据不一致。
除此之外,在多核CPU中,线程的并行执行也会导致原子性问题。
如图所示,两个线程并行执行,同时从内存中将 index
加载到寄存器中并进行计算,最终导致 index
的结果小于我们的预期值。
通过上述分析,我们发现多线程环境下的并行执行或线程切换,可能导致结果不符合预期。为了解决这一问题,主要有两个方法:
- 避免指令中断:确保
index++
等非原子操作在执行过程中不被中断,避免上下文切换。 - 使用互斥机制:通过同步控制,确保多线程执行时,只有一个线程能操作共享资源,从而避免并发冲突。
在Java中,可以使用 synchronized
关键字来实现这一功能,确保同一时刻只有一个线程可以访问共享资源。
@Override
public synchronized void run() {
for (int i = 0; i < 10000; i++) {
index++;
}
}
4. 源头之二:可见性问题
可见性问题是指一个线程对共享变量的修改,另一个线程可能看不到,从而导致数据不一致。
4.1. 为什么会有可见性问题?
现代CPU设计中有三级缓存(L1、L2、L3缓存),这些缓存的目的是加速CPU与内存之间的数据传输,减少CPU访问内存的延迟。
如下图所示,L1和L2缓存是每个CPU核心的私有缓存,L3缓存是多个核心共享的缓存。
不同线程可能运行在不同的CPU核心上,每个核心有自己的缓存(L1、L2)。当一个线程修改数据时,工作内存中的数据不会立即同步到主内存,这可能导致缓存不一致。
比如下图中:
- 两个CPU的缓存中都缓存了x=20这个值;
- 其中CPU2将x=20修改成了x=40,这个修改只对本地缓存可见,
- 而当CPU1后续对x再进行运算时,它获取的值仍然是20,这就是缓存不一致的问题。
这种缓存不一致是可见性问题的主要原因。
4.2. 可见性问题示例
接下来以一个例子来证实内存的不可见。
public class Visibility {
boolean flag = true;
public void changeFalse() {
this.flag = false;
}
public static void main(String[] args) throws InterruptedException {
Visibility visibility = new Visibility();
new Thread(() -> {
System.out.println("5s后flag修改为flase");
try {
Thread.sleep(5);
} catch (Exception e) {
e.printStackTrace();
}
visibility.changeFalse();
System.out.println("ChangeFalse线程修改flag的值为: " + visibility.flag);
}, "ChangeFalse").start();
while (visibility.flag == true) {
}
System.out.println("主线程得到flag的值为false");
}
}
代码分析:
- flag:一个普通的实例变量,初始化为 true。
- 子线程(ChangeFalse)在 5 秒后将 flag 修改为 false,然后打印修改后的值。
- 主线程通过
while (visibility.flag == true)
不断检查 flag,等待它变为 false,并打印 “主线程得到flag的值为false”。
点击执行:
5s后flag修改为flase
ChangeFalse线程修改flag的值为: false
程序进入无限循环。
原因分析:
- 主线程读取自己的工作内存,发现
flag == true
,于是进入死循环。 - 子线程在 5 毫秒后修改了 flag 的值,但这个修改只反映在子线程的工作内存 中,主线程的工作内存并没有及时获取到该修改。
- 因此,主线程一直在死循环中,等待 flag 变为 false。
要解决上述的可见性问题,可以使用 volatile
关键字。将 flag 修改为 volatile boolean flag = true;
即可。
5. 源头之三:重排序问题
什么是重排序呢,我们先来看一段代码。
5.1. 重排序问题示例
public class OutOfOrderExecution {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
}
}
}
}
运行结果:
第111263次(0,0)
代码分析:
- 定义四个int类型的变量,初始化都为0;
- 定义两个线程t1、t2,t1线程修改a和x的值,t2线程修改b和y的值;
- 正常情况下会有三种结果:
- t1线程先执行,得到结果x=0、y=1;
- t2线程先执行,得到结果x=1、y=0;
- t1和t2线程同时执行,得到结果x=1、y=1。
而上面的打印出来的结果【0,0】。这就是的指令重排序问题。
重排序问题是指编译器、JVM或CPU为了优化性能,可能会改变程序中指令的执行顺序,导致程序的执行顺序与代码书写的顺序不一致。
假设上面的代码通过指令重排序之后,变成下面这种结构:
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
x = b; //指令重排序
a = 1;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
y = a; //指令重排序
b = 1;
}
});
经过重排序之后,如果t1和t2线程同时运行,就会得到x=0、y=0的结果。
解决方法:使用 volatile
或 synchronized
来禁止重排序,确保指令执行顺序。
6. 总结
线程不安全的根本原因主要有三个:
- 原子性问题:操作被拆分成多个步骤,多个线程同时执行时,操作可能会中断或交叉执行,导致数据错误。
- 可见性问题:源于缓存导致的不同线程工作内存与主内存不一致;
- 重排序问题:编译器、JVM或CPU出于性能优化目的可能改变指令执行顺序,导致程序执行结果与预期不一致。
这些技术优化的本意是提高程序性能,但在实现时会引入新的问题。因此,在编写并发程序时,我们必须清楚技术带来的潜在问题,并采取适当措施进行规避,才能确保程序稳定高效。