并发编程(一) 什么是并发编程

286 阅读4分钟

1.并发带来的问题

为什么会有并发的问题,很简单,多个线程,对同一个共享资源进行竞争,就会发生和单线程下预期结果不一致的情况,技术上也喜欢把其称作为 “线程安全问题”

e.x: 举个最简单的例子就是我们的i++拉,这个几乎是所有并发编程入门都会去讨论到的问题。

private static long count = 0;

private static void add10K() {
    int idx = 0;
    while (idx++ < 10000) {
        count += 1;
        
        // 打开注释的这句话,不会发生线程安全的问题,可以用你的ide复制代码运行一下
        // 这个问题挺有趣的,值得思考
        // System.out.println(count);
    }
}

public static void main(String[] args) throws InterruptedException {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
                IDemo.add10K();
        }
    });

    Thread thread2 = new Thread(new Runnable() {
        @Override
        public void run() {
            IDemo.add10K();
            }
    });

    thread.start();
    thread2.start();

    // 睡眠2S后再去查询count的结果
    Thread.sleep(2000);
    System.out.println(count);


}

结论: 你会发现结果不是20000,而是10000-20000的一个随机数。

其实这种就是多线程导致的并发抢夺资源进而导致的线程安全问题。

2.为什么会发生这种线程安全问题?

笔者面试多个公司,经常会被问到这种问题,但是每个阶段,我都会有不同的体验。 其实回答起来也很简单,就是三大特性, 可见性,原子性,有序性

这里也不需要去死记硬背,每个特性的具体定义是什么。

这里面去聊,可以去聊他的底层实现,从计算机底层的角度来讲,讲述一下三大特性,再去推导出,为什么会发生这种 “线程安全” 的问题。

2.1 三大特性
  • 可见性 我们的电脑发展是从单核 -> 多核,核指的就是我们CPU的数量

CPU去做什么,CPU就是来执行我们编写的一个一个的程序指令,所谓多个CPU,简单来讲,通过堆CPU数量,提高电脑的运算能力

咱们再去聊下CPU的结构,发展到现在,CPU为了去提高执行的效率,会有个属于自己的 高速缓存,这样就不用每次都去内存中去获取数据,提高获取数据的效率。

所以cpu里面会有专属自己的cpu高速缓存区域,分为三块,L1,L2,L3(这三块都是属于CPU的缓存,数字越高,存储的空间就越大),那么,我用一张图简单来表示一下CPU的内部结构。

CPU的基础模型.jpg

那么我们有多个CPU呢?其实也很简单,其实无非就是每个cpu从 主内存 中读取内容缓存到自己CPU专属的 高速缓存

CPU的基础模型.png

有了以上的知识储备,再来讲 可见性,其实就是CPU之间的高速缓存之间,是不可见的,这样就会带来问题。

举个例子,变量i在主内存中等于0,然后CPU0 将这个i读到cpu内存中,执行i++的操作,这时候在CPU0的高速缓存中i=1, CPU0还没把i=1 刷新到主内存中,这时候CPU1 也要去做一个i++的操作,这时候从主内存中读到的i=0, 进行i++, 用的是旧的值,用的是脏数据进行i++的操作,这样不就发生问题了么?

  • 原子性
    原子性的概念,就是操作是不可分割的,一定要等这个动作做完,才能做下一个动作。要么一起成功,要么一起失败

在Java中,帮我们定义了以下几种操作是 原子性的(不可分割的), 不建议直接死记硬背,要结合语义去理解。

lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

下面我就用一张图,来对下面的文字进行概括性的总结(建议画一次,加深印象):
原子性的流程.jpg

那么,你一定很好奇,操作系统是如何保证,上面的8个步骤操作是如何保证原子性的。

其实,操作系统是通过:总线加锁或者缓存加锁的方式来保证其原子性。

总线加锁,就是处理器会发出一个 #Lock信号,当某一个处理器发出 #Lock 信号的时候,别的处理器的请求都会被阻塞,该处理器可以独占共享内存的资源,直至处理器发出 #Unlock 信号。

总线加锁固然安全,但是效率极低,因为他是CPU和内存之间的上锁,在上锁期间,甚至别的线程也无法去操作其他的内存,效率极其低下。因此,缓存加锁的方式应然而生。

缓存加锁,其实频繁使用的数据,会加载到L1,L2,L3高速缓存中,如果我们一旦要对这部分的数据进行更改,CPU在 #Lock 的时候,会去直接修改缓存行中数据对应的内存地址,同时会禁止别的CPU去修改这部分数据对应的内存地址,从而来保证原子性。

  • 有序性