Java并发编程-理论基础

78 阅读6分钟

1、为什么需要多线程

众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

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

2、线程不安全示例

线程不安全通常发生在多线程环境下,当多个线程共享数据并且没有采取适当的同步措施时,就可能出现数据竞争和不一致性问题。下面通过一个典型的计数器示例来展示线程不安全的情况。

示例:线程不安全的计数器

假设我们有一个简单的计数器类,用于统计某个事件发生的次数。在单线程环境下,下面的代码可以正常工作:

Java
深色版本
1public class UnsafeCounter {
2    private int count = 0;
3
4    public void increment() {
5        count++;
6    }
7
8    public int getCount() {
9        return count;
10    }
11}

然而,在多线程环境下,这个计数器就变得不安全了。当多个线程同时调用increment()方法时,由于count++操作不是原子的,可能导致计数器的值比预期的要小。

演示线程不安全性

为了演示这个问题,我们可以创建一个测试类,其中多个线程将同时对计数器进行递增操作:

1public class CounterTest {
2    public static void main(String[] args) throws InterruptedException {
3        UnsafeCounter counter = new UnsafeCounter();
4
5        Thread[] threads = new Thread[10];
6        for (int i = 0; i < 10; i++) {
7            threads[i] = new Thread(() -> {
8                for (int j = 0; j < 1000; j++) {
9                    counter.increment();
10                }
11            });
12        }
13
14        for (Thread thread : threads) {
15            thread.start();
16        }
17
18        for (Thread thread : threads) {
19            thread.join();
20        }
21
22        System.out.println("Final count: " + counter.getCount());
23    }
24}

理论上,如果有10个线程,每个线程执行1000次递增操作,那么最终的计数应该是10,000。但是,由于increment()方法中的count++不是原子操作,实际运行结果可能小于10,000。

3、并发出现问题的根源: 并发三要素

3.1可见性: CPU缓存引起

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

举个简单的例子,看下面这段代码:

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

假若执行线程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修改的值。

3.2原子性: 分时复用引起

原子性:它指的是一个操作要么完全执行,要么完全不执行,而不会被其他操作中断。当涉及到多线程环境时,由于线程的分时复用(即线程上下文切换),一些看似简单的操作可能不再是原子的。

例如,考虑一个非常常见的非原子操作:i++。这个操作实际上由三个步骤组成:

  1. 读取变量i的当前值。
  2. 将读取的值加1。
  3. 将新值写回i

在单线程环境中,这看起来是一个单一的操作。但在多线程环境中,如果两个线程几乎同时尝试执行i++,那么可能会发生以下情况:

假设初始时i的值为1,线程A和B几乎同时开始执行i++操作。

  1. 线程A读取i的值为1。
  2. 在线程A将i的值加1之前,发生了线程切换,线程B开始执行。
  3. 线程B也读取i的值为1(因为它还没有被线程A更新)。
  4. 线程B将读取的值加1,变为2,并写回i
  5. 再次发生线程切换,线程A继续执行。
  6. 线程A将它的临时值(也就是1)加1,变为2,并写回i

最终,i的值会变成2,尽管有两个线程都试图将其增加1。这是因为每个线程都在旧值的基础上进行计算,没有考虑到另一个线程可能已经改变了值。这就是原子性问题的一个典型例子。

# 有序性: 重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

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

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。