搜 索

Java并发编程丨基础概念

  • 186阅读
  • 2021年12月09日
  • 0评论
首页 / 默认分类 / 正文

前言

并发编程是 Java 程序员最重要的技能之一。“万丈高楼从地起”,在深入学习之前先要了解基本的概念。

并发的概念

什么是并发(Concurrent)?在操作系统中,它指的是在一个时间段中同时有多个程序在运行,但其实任一时刻,只有一个程序在 CPU 上运行,宏观上的并发是通过不断的切换实现的。
为什么需要并发?它其实是一种解耦合的策略,它帮助我们把做什么(目标)和什么时候做(时机)分开。这样做可以明显改进应用程序的吞吐量(获得更多的 CPU 调度时间)和结构(程序有多个部分在协同工作)。

并发与并行

二者最关键的差异在于:是否是同时发生

  • 并发:是指具备处理多个任务的能力,但不一定要同时。
  • 并行:是指具备同时处理多个任务的能力。

举个例子:

  • 串行:你吃饭吃到一半,电话来了,你一直到吃完了以后才去接(一前一后),这就说明你不支持并发也不支持并行。
  • 并发:你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭(来回切换),这说明你支持并发。
  • 并行:你吃饭吃到一半,电话来了,你一边打电话一边吃饭(同时处理),这说明你支持并行。

三者区别:

  • 串行在时间上不可能发生重叠,前一个任务没完成,下一个任务只能等待;
  • 并发允许两个任务彼此干扰,但是同一时间点,只有一个任务运行,交替执行。
  • 并行在时间上是重叠的,两个任何在同一个时刻互不干扰地同时执行;

同步与异步

  • 同步:是指在发出一个调用时,在明确的获得到调用结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
  • 异步:与同步相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

举个例子:

  • 你点外卖买奶茶,在没有得到奶茶之前,外卖订单不会显示“已完成,请评价服务”,这是同步;
  • 你点外卖买奶茶,外卖订单显示“已完成,请评价服务”了,但是你还没有得到奶茶,正迷惑,后来外卖小哥通过打电话 / 发短信告诉你:到了目的地,被保安拦了,你下来取。这是异步。

    阻塞与非阻塞

    阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态

  • 阻塞:是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞:是指在不能立刻得到结果之前,该调用不会阻塞当前线程。

举个例子:

  • 你点外卖买奶茶,在没有喝上奶茶之前,你就不工作,这是阻塞;
  • 你点外卖买奶茶,在没有得到奶茶之前,你继续工作,得到奶茶后,再喝奶茶,这是非阻塞。

进程与线程

进程(Process),是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即为一个进程的创建、运行以及消亡的过程。
线程(Thread),是比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程,多个线程共享进程的堆和方法区内存资源,每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。由于线程共享进程的内存,因此系统产生一个线程或者在多个线程之间切换工作时的负担比进程小得多,线程也称为轻量级进程。
进程和线程最大的区别是,各进程是独立的,而各线程则不一定独立,因为同一进程中的多个线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护,进程则相反。

并发的矛盾

并发编程有一个核心矛盾一直存在,就是 CPU、内存、I/O 设备这三者的速度差异:CPU 是天上一天,内存是地上一年。由于 CPU 执行速度非常快,比计算机主存的读取和写入的速度快了很多,这样就会导致 CPU 的执行速度大大下降。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。
程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶短板理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是解决不了问题的。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异。每个 CPU 都会自带一个高速缓冲区,在运行的时候,会将需要运行的数据从计算机主存先复制到 CPU 的高速缓冲区中,然后 CPU 再基于高速缓冲区的数据进行运算,运算结束之后,再将高速缓冲区的数据刷新到主存中。这样 CPU 的执行指令的速度就可以大大提升。


CPU太快了
CPU增加缓存

  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。

CPU 缓存导致的可见性问题

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
测试例子:

public class ThreadVisible {

    private long aacessCount = 0;

    private void add10K() {
        int idx = 0;
        // 累加 1W 次
        while(idx++ < 10000) {
            aacessCount += 1;
        }
    }

    public static long calc() throws InterruptedException {
        final ThreadVisible test = new ThreadVisible();
        // 创建两个线程,执行add()操作
        Thread th1 = new Thread(()->{
            test.add10K();
        });
        Thread th2 = new Thread(()->{
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        // 静态方法内部不能调用非静态变量
        return test.aacessCount;
    }

    public static void main(String[] args) throws InterruptedException {
        long result = ThreadVisible.calc();
        // 最终结果更接近 1 W,而不是 2 W
        System.out.println(result);
    }
}

直觉告诉我们应该是 20000,因为在单线程里调用两次 add10K() 方法,aacessCount 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?
举个简单的例子说明:
复制到缓存
我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 aacessCount=0 读到各自的 CPU 缓存里,执行完 aacessCount+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。


各自执行+1操作
将结果写回主内存

这是因为各自的 CPU 缓存里都有了 aacessCount 的值,两个线程都是基于 CPU 缓存里的 aacessCount 值来计算,线程 1 和线程 2 都计算完之后就会将计算结果刷新回主存,特别注意一下图中红框的内容,这是两个线程把计算结果刷新回主存的步骤,两个红框中操作的执行顺序不分先后(在实际运行情况,两个操作的顺序是随机的,可能是线程 1 先刷新,也可能是线程 2 先刷新),但是这不影响结果,因为无论是线程 1 还是线程 2,写回主存的结果都是 accessCount=1。
所以导致最终 aacessCount 的值都是小于 20000 的。这就是缓存的可见性问题。
如果将循环 1 万次的 aacessCount+=1 操作改为循环 1 亿次,你会发现效果更明显,最终 aacessCount 的值接近 1 亿,而不是 2 亿。如果循环 1 万次,aacessCount 的值接近 20000,原因是两个线程不是同时启动的,有一个时差

线程切换导致的原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写打游戏,这个就是多进程的功劳。
操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。
在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的 aacessCount += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 aacessCount 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
我们潜意识里面觉得 aacessCount+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 aacessCount+=1 之前,也可以发生在 aacessCount+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

编译优化导致的有序性问题

顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

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

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

参考

♥Java并发知识体系详解♥
极客时间 - Java 并发编程实战
打 赏
  • 支付宝
  • 微信
Alipay
WeChatPay
Java基础丨反射
« 上一篇
评论区
暂无评论
avatar