问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

深入分析线程安全问题的本质

创作时间:
作者:
@小白创作中心

深入分析线程安全问题的本质

引用
CSDN
1.
https://blog.csdn.net/D812359/article/details/144942941

线程安全问题是并发编程中常见的问题,主要表现为原子性问题、可见性问题和重排序问题。本文将深入分析这些问题的本质,并提供相应的解决方案。

1. 并发编程背后的性能博弈

随着科技的进步,CPU、内存和I/O设备的性能不断提升,但它们之间的速度差异仍是计算机设计的核心问题。

简单来说,CPU在运算时,必须从内存中读取数据和指令,而CPU的计算速度远高于内存的I/O速度,导致了性能瓶颈。

根据木桶理论,程序的整体性能受最慢操作的限制。大多数程序都频繁访问内存或进行I/O操作,因此单纯提升CPU性能并不足以突破瓶颈。

为了缓解这一问题,计算机架构、操作系统和编译器都作出优化:

  • 缓存机制:CPU通过引入L1、L2、L3缓存来弥补与内存的速度差异。
  • 进程与线程管理:操作系统采用进程和线程分时复用机制,平衡CPU与I/O速度差异。
  • 编译器优化:优化指令执行顺序,提高缓存利用率,减少CPU等待内存的时间。

然而,这些优化在提升系统性能的同时,也为并发编程带来了新的挑战,尤其是线程安全问题。

2. 什么是线程安全问题?

线程安全问题指的是当多个线程同时访问共享资源时,程序无法按预期正常执行,导致数据不一致。

导致线程不安全的原因主要有三个:

  1. 原子性问题:多个线程同时访问共享变量时,可能会中断或交叉执行,导致不正确的结果。
  2. 可见性问题:一个线程对共享变量的修改,其他线程可能看不到,造成数据不一致。
  3. 重排序问题:由于编译器、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

原因分析:

  1. index++ 不是原子操作:
  • 读取 index 的值。
  • 将值加 1。
  • 将新的值写回 index
  1. 多线程相互干扰:线程1和线程2可能同时读取相同的 index 值,然后各自加 1 并写回,导致加 1 操作互相覆盖,最终结果不正确。

3.2. 原子性问题分析

从本质上说,原子性问题产生的原因有两个:

  • CPU时间片切换
  • 非原子性指令:例如,index++ 不是一个原子操作,它包含多个步骤(读取、加1、写回)。

CPU时间片切换

现代操作系统通过时间片轮转调度线程,每个线程在CPU上运行一段时间后会被切换。

这种切换可能导致线程在操作共享数据时产生原子性问题。例如,线程A读取某个变量的值,然后准备修改它,但在修改之前,线程A被切换出去,线程B也修改了这个值。结果,线程A的操作就丢失了,导致数据不一致。

除此之外,在多核CPU中,线程的并行执行也会导致原子性问题。

如图所示,两个线程并行执行,同时从内存中将 index 加载到寄存器中并进行计算,最终导致 index 的结果小于我们的预期值。

通过上述分析,我们发现多线程环境下的并行执行或线程切换,可能导致结果不符合预期。为了解决这一问题,主要有两个方法:

  1. 避免指令中断:确保 index++ 等非原子操作在执行过程中不被中断,避免上下文切换。
  2. 使用互斥机制:通过同步控制,确保多线程执行时,只有一个线程能操作共享资源,从而避免并发冲突。

在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)

代码分析:

  1. 定义四个int类型的变量,初始化都为0;
  2. 定义两个线程t1、t2,t1线程修改a和x的值,t2线程修改b和y的值;
  3. 正常情况下会有三种结果:
  • 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的结果。

解决方法:使用 volatilesynchronized 来禁止重排序,确保指令执行顺序。

6. 总结

线程不安全的根本原因主要有三个:

  • 原子性问题:操作被拆分成多个步骤,多个线程同时执行时,操作可能会中断或交叉执行,导致数据错误。
  • 可见性问题:源于缓存导致的不同线程工作内存与主内存不一致;
  • 重排序问题:编译器、JVM或CPU出于性能优化目的可能改变指令执行顺序,导致程序执行结果与预期不一致。

这些技术优化的本意是提高程序性能,但在实现时会引入新的问题。因此,在编写并发程序时,我们必须清楚技术带来的潜在问题,并采取适当措施进行规避,才能确保程序稳定高效。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号