并发基础

12/14/2023 Java多线程与并发

# 1、进程与线程

# 1.1、进程

进程(Process)是计算机中正在运行的程序。 程序是一种静态的概念,而进程是程序在执行过程中创建的动态实体。每个进程都有自己的内存空间、代码、数据和资源,它也是操作系统进行任务调度和资源分配的基本单位。

进程是正在运行一个软件或者脚本。这样理解更为简单一点。

多个进程可以同时运行在计算机上,彼此独立并且互不干扰。操作系统通过进程管理来控制和监视进程的创建、运行、暂停和终止等操作。进程还可以通过进程间通信机制来实现进程之间的数据交换和同步。

打开任务管理器,运行的程序就是一个个进程。

# 1.2、线程

线程(Thread)是程序执行的最小单位,它是进程的一部分。 一个进程可以包含一个或多个线程。同一个进程中的多个线程共享同一块内存空间和其他资源,它们可以同时执行不同的任务。每个线程都有自己的程序计数器、栈和一组寄存器,这使得线程能够独立地执行代码。

线程的特点

  • 轻量级:相对于进程来说,线程的创建和上下文切换开销较小。
  • 共享资源:同一进程中的线程可以共享内存和其他资源,因此可以更方便地进行数据共享和通信。
  • 并发执行:多个线程可以同时执行,提高了程序的并发性和效率。

线程的使用可以提升程序的性能和响应性,特别是在多核处理器上可以实现并行计算。但多线程编程也需要注意线程间的同步和共享数据的安全性问题,以避免出现竞态条件和数据不一致的情况。

线程的时间效率和空间效率都比进程要高。

# 2、并行与并发

  • 并行

并行(Parallel)是指同时进行多个任务,任务之间可以同时执行,彼此独立。例如,在多核处理器上,可以同时执行多个线程或进程,这就是并行。

  • 并发

并发(Concurrent)则是指同时交替进行多个任务,任务之间可能有依赖关系或竞争条件。例如,在单核处理器上,通过时间片轮转的方式,多个线程或进程通过快速切换执行,看起来是同时进行的,但实际上是交替执行的,并发中的任务可能需要依赖共享资源或竞争临界区资源。

并行是多个任务同时进行,而并发是多个任务交替进行,并且可能存在资源竞争和依赖。

并行和并发之间的区别在于任务是否可以同时执行以及是否需要竞争共享资源。并行通常需要硬件支持,如多核处理器,能同时执行多个任务。而并发则可以在单核处理器上通过时间片轮转等技术实现。

在实际应用中,可以用并行提高计算性能和执行速度,可适用于多线程编程或分布式计算;而并发则可以提高资源利用率和系统吞吐量,可适用于任务调度和资源管理。

# 3、多线程的必要性

我们先来看个JMM(Java内存模型)简单模型

Java内存模型

CPU、内存(主存)、I/O,这三者的处理速度有着极大的差异。为了平衡这三者的速度差异,需要在计算机体系结构、操作系统、编译程序上进行优化。

# 3.1、CPU缓存优化

CPU增加缓存,会导致可见性问题。

  • CPU(中央处理器)是计算机的核心部件,负责处理大部分的计算任务。然而,CPU处理数据的速度远高于内存读取或写入数据的速度,这种速度上的差异就会导致CPU在等待数据时可能无事可做,从而浪费其处理能力。
  • 为了解决这个问题,专门在CPU和内存之间增加了缓存(Cache)作为一个数据的临时存储区,用于存放CPU预期会用到的数据。因为缓存是位于CPU和内存之间的临时存储区,它的存取速度比内存要快得多,所以能够有效地解决CPU和内存速度上的差异。
  • 当CPU需要读取或写入数据时,它会首先查找缓存中是否有这些数据。如果有(这称为“缓存命中”),CPU就可以直接从缓存读取或写入数据,从而避免了等待内存的消耗时间。如果没有(这称为“缓存未命中”),CPU就需要从内存中读取数据,并同时将这些数据写入缓存,以供后续使用。
  • 通过这种方式,缓存能够有效地利用CPU的高速处理能力,提高计算机的整体性能。

# 3.2、操作系统优化

操作系统增加了进程、线程,会导致原子性问题。

操作系统增加了进程和线程的概念来实现分时复用 CPU 资源,进而均衡 CPU 和 I/O 设备的速度差异。

  • 进程是指计算机中正在运行的程序的实例。操作系统通过为每个进程分配一段独立的内存空间和一组资源(如 CPU 时间片、文件描述符等)来管理并控制进程的运行。通过轮流分配 CPU 时间片,操作系统可以让多个进程交替运行,从而实现了 CPU 的分时复用。
  • 线程是指进程中的一个独立执行单元。一个进程可以有多个线程,它们共享该进程的资源和状态。每个线程有自己的栈空间和程序计数器,但它们共享同一进程的内存空间、文件描述符等。操作系统可以通过调度算法在不同的线程之间切换,从而实现多个线程在单个进程中的并发执行。
  • 通过引入进程和线程的概念,操作系统可以将 CPU 时间片分配给不同的进程,实现进程之间的轮流执行并提高 CPU 的利用率。同时,操作系统可以通过线程的并发执行来隐藏 I/O 设备操作的等待时间,提高系统的响应速度。
  • 总而言之,操作系统通过增加进程和线程的概念,实现了分时复用 CPU 资源,使得多个进程和线程可以并发执行,从而在 CPU 和 I/O 设备的速度差异中实现均衡。

# 3.3、编译程序优化

编译程序优化指令执行次序,会导致有序性问题。

编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。 编译程序优化指令执行次序是指在编译过程中,编译器根据各种优化策略和规则,自动调整和重排程序中指令的顺序,以提高程序运行的效率和性能。

编译器进行指令优化的主要目的是为了解决以下问题:

  • 硬件资源利用:通过优化调整指令次序,可以更好地利用CPU的并行性,提高CPU利用率,减少资源浪费。例如,通过指令调度可以让CPU同时执行多条无关的指令,从而提高指令的并行等级。
  • 减少等待时间:某些指令执行需要等待前面指令的结果,这会导致CPU空闲等待。通过优化指令顺序,可以尽可能地避免这种依赖,减少CPU空闲时间,提高运行效率。
  • 克服性能瓶颈:例如,优化内存访问指令的顺序,可以减少缓冲区溢出或者下溢的可能性,避免因为内存访问导致的性能瓶颈。
  • 管线浪费:现代CPU通常将指令执行过程分解成多个阶段,并行执行以提高性能,这就是所谓的管线技术。如果指令的执行顺序不能很好地匹配CPU管线,就会导致管线阶段的闲置,造成性能下降。

通过这些优化,编译器可以帮助程序员在不需要手动干预的情况下自动提高程序的运行效率和性能,使得程序在各种硬件平台上都能获得更好的运行效果。

# 4、线程安全问题

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }
}

public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}

// 输出结果:992
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

我们在之前的Java内存模型中可知,CPU执行完程序之后,需要重新写入内存,而内存的写入操作不是及时性的。

导致线程安全问题的主要原因有三个方面:CPU缓存导致的可见性问题、进程或线程导致的原子性问题、编译器优化导致的有序性问题。

# 5、线程安全导致原因

# 5.1、可见性

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

CPU 缓存会导致可见性规则打破,案例:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;
1
2
3
4
5
6

  • 假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
  • 此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。

线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。所以 CPU 缓存会导致可见性问题

# 5.2、原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

分时复用会引起原子性打破,案例:

int i = 1;

// 线程1执行
i += 1;

// 线程2执行
i += 1;
1
2
3
4
5
6
7

这里需要注意的是:i += 1需要三条 CPU 指令

  • 将变量 i 从内存读取到 CPU寄存器;
  • 在CPU寄存器中执行 i + 1 操作;
  • 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。所以 CPU分时复用会导致原子性问题

# 5.3、有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

重排序优化会打破程序有序性,案例:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2
1
2
3
4

虽然上述代码语句2在语句1之后,但是多线程下执行顺序不一定按语句1、语句2这个顺序执行。重排序是指计算机系统(如处理器、编译器等)在执行程序时,按照某种规则重新调整指令或操作的顺序,以提高程序性能或满足其他需求。

重排序可以分为三种类型:编译器重排序、处理器重排序和内存系统重排序。

  • 编译器重排序:在编译阶段,编译器可能会对源代码中的指令重新排序,以优化代码的执行效率。编译器重排序不会改变程序的语义,即保证最终的执行结果与源代码的顺序一致。编译器重排序可以通过指令级并行、循环展开、常量传播等技术来实现。
  • 处理器重排序:在处理器执行指令时,由于处理器采用了流水线技术,它可以对指令进行重排序,以尽可能地利用处理器资源。处理器重排序可能包括指令级重排序(乱序执行)和内存访问重排序。指令级重排序是指处理器可以改变指令的执行顺序,以提高指令的并行度和执行效率。内存访问重排序是指处理器可以改变对内存的读写操作的顺序,以充分利用内存系统的各级缓存。
  • 存系统重排序:由于现代计算机系统中存在多级缓存、总线和内存等层次结构,因此对于内存的读写操作也可能存在重排序。内存系统重排序可以通过缓存一致性协议和写缓冲区等技术来实现。

重排序在一定程度上可以提高程序的执行速度和效率,但必须在确保程序正确性和语义一致性的前提下进行。在并发编程中,重排序可能会引发数据竞争、原子性问题等多线程并发问题,因此需要采取同步和内存屏障等手段进行控制和保护。

编译程序优化的重排序下,程序的有序性会打破。所以编译器优化和处理器重排序可能会导致有序性问题

# 6、Java解决并发问题

JMM(Java内存模型)规范了 JVM 如何按需禁用缓存和编译优化的方法。

# 6.1、可见性、有序性、原子性的理解

# 6.2、volatile、synchronized 和 final 关键字

# 6.3、Happens-Before 规则

# 7、线程安全程度

# 8、线程安全实现