掘金 后端 ( ) • 2024-04-21 10:53

写在前面

朋友们,我们已经学习了进程和线程的基础知识,也知道了多线程有很多优点,在今后的开发中,大家也可以在项目中去使用多线程,来提升系统的并发性。那么问题来了,多线程会带来一些我们无法预知的问题吗?答案是必然会的,因为在多核CPU下,多线程会产生一些并发问题,可能会让程序的执行结果与我们的预期不相符,也就是无法保证多个线程之间的数据一致性和正确性。多线程带来的并发问题真是让人又爱又恨吖~!言归正传,接下来让我们一起去感受并发的魅力,开始发车🚗


1、原子性

原子性,它指的是一个操作或者一组操作要么全部执行并且执行的过程不会被任何其他操作中断,要么就全部都不执行。这意味着原子操作是不可分割的,它们要么完整发生,要么根本不发生,不存在中间状态。

首先我们先看一段代码

public class ThreadUnSafe {
    private int i = 0;
    private void count(){
        for (int j = 0; j < 5000; j++) {
            i++;
        }
    }
    private int getI(){
        return i;
    }
    public static void main(String[] args) throws InterruptedException {
        ThreadUnSafe threadUnSafe = new ThreadUnSafe();
        Thread t1 = new Thread(() -> {
            threadUnSafe.count();
        }, "t1");
        Thread t2 = new Thread(() -> {
            threadUnSafe.count();
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(threadUnSafe.getI());
    }
}

这段代码看起来是不是很ez,当我们执行完之后,i输出的结果会是10000吗?答案是不一定,大家可以在自己的电脑上运行下这段程序,会发现每次运行的结果可能都不一样,那么问题到底出现在什么地方呢?

我们可以看到有关i的操作只有i++,而且是两个线程都会去做i++的操作。我们可以大胆的猜测问题出在了i++上,也就是说t1线程执行i++的过程中,t2线程也在执行i++,两个线程同时对i变量做了读写操作,互相影响到了对方。t1说t2影响了我,t2说t1影响了我,得,两个线程是公说公有理,婆说婆有理。这时候你可能会想,i++在Java语言中就一行代码啊,怎么会出现这个问题呢?

为了一探究竟,我们可以看下CPU在执行这段代码的过程中,具体的汇编指令是什么样的。

我们着重看下红色框里的几条汇编指令,用大白话解释:

(1)讲变量i从内存中加载到CPU的寄存器中

(2)在CPU的寄存器中执行i++操作

(3)将i++后的结果写回缓存

在多线程中,CPU会进行线程调度,线程会不断切换,而线程切换可能发生在任何一个指令完成之后,而不是Java程序中的某条语句之后。在上面的代码示例中,线程t1和线程t2同时执行getI()方法,在线程t1执行过程中,CPU完成指令码的步骤(1)之后有可能发生了线程切换,此时线程B开始执行指令码。这样的话,会导致两个线程都执行完getI()方法后,i的值不是2,而是1。下面用图来解释下整个过程:

从上图我们可以看出,线程t1将i=0加载到CPU寄存器后,发生了线程切换,线程t2执行一系列指令

将i=1写入到内存,随后又发生了一次线程切换,线程t1将i++,此时是在i=0的基础上执行的,所以i++

之后的结果仍然是1,又将i=1写入到了内存,因此最终得到的结果是i=1

总结:我们不难得出结论,在线程切换的过程中,可能会导致并发编程中的原子性问题,所以,造成原子性的根本原因是多线程执行过程中发生了线程切换。

2、可见性

可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

在Java语言中,多个线程在读写内存中的共享变量时,每个会先把主内存中的共享变量加载到各自的工作内存中,每个线程在对数据读写时,也是直接操作各自的工作内存中的数据,具体如下图:

所以,在线程t1修改了共享变量的值时,线程t2并不一定能立马读取到修改后的值,这样也就会产生线程之间的可见性问题。造成可见性问题的根本原因是CPU的缓存机制。

3、有序性

为了提高程序的执行性能,编译器和处理器常常会对指令做重排序,从Java源代码到最终实际执行的指令序列,会分别经历如下3种重排序,具体如下图:

这里对3种重排序我们不做深入的探讨,我们只需要知道最终执行的指令序列并不一定是按照Java源代码的顺序。

int a = 0;
int b = 3;
-----分割线----
int b = 3;
int a = 0;

比如,上面这两段代码,调换两行的执行顺序对实际执行的结果并没有影响,而编译器有可能会对其做重排序。

下面我们用一段代码案例来演示在Java并发编程中有序下会导致什么问题:

public class SingleInstance {
    
    private static SingleInstance instance;
    
    public static SingleInstance getInstance(){
        if (instance == null){
            synchronized (SingleInstance.class){
                if (instance == null){
                    instance = new SingleInstance();
                }
            }
        }
        return instance
    }
}

上面的代码表面上看上去很完美,但是在高并发,大流量的场景下,获取new SingleInstance()创建的类对象时,会因为编译器或者处理器对程序的重排序从而导致问题。

new一个对象需要几步?

(1)分配内存空间

(2)初始化对象

(3)将instance引用执行内存空间

正常情况下,CPU在执行的指令顺序应该是(1)->(2)->(3),但是经过重排序之后有可能变成

(1)->(3)->(2),这样的话,在并发的时候就可能会产生问题。

下面用一张图来产生问题的原因:

白色部分是不会执行的流程,我们可以看到最终线程B得到的instance对象是未完成初始化的,如果此时访问其内部的成员变量可能会出现空指针的异常。

由此可见,有序性的问题是因为重排序引起的。

现在我们已经对并发编程的三大特性有了一定的了解,但是我们还没有讲解如何去解决这些问题,后续我们会慢慢的探讨如何解决并发编程中的原子性、可见性和有序性问题。