1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础

文章目录

  • 1. 并发程序的幕后故事
  • 2. 问题1:缓存导致的可见性问题
  • 3. 问题2:线程切换带来的原子性问题
  • 4. 问题3:编译优化带来的有序性问题

1. 并发程序的幕后故事

核心矛盾: CPU,内存,I/O设备三者的速度差异.
形象比喻:

项目 CPU 内存 I/O设备
速度比较 一天 一年 几千年

为了提高CPU利用率,平衡三者的速度差异,计算机体现机构,操作系统,编译程序分别做以下贡献:

  • CPU增加CPU缓存(不同于内存,以下简称缓存),以均衡和内存的速度差异;
  • 操作系统增加进程和线程,分时复用CPU,均衡CPU和I/O设备速度差异;
  • 编译程序优化指令执行次序,更加合理利用缓存。

有利就有弊,由此带来可见性、原子性和有序性问题。

2. 问题1:缓存导致的可见性问题

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

  1. 单核时代,不存在可见性问题
    所有线程都在单个CPU里面执行,一个线程对缓存的改写,另外一个线程是一定可见的。线程A改写了缓存的V,线程B可以获得V的最新值。
    1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础_第1张图片
  2. 多核时代,可见性问题
    多个线程在不同的CPU执行,缓存变量V分散在不同的CPU缓存上,存在可见性问题。
    1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础_第2张图片
  3. 验证可见性问题
    直观感觉count是20000,实际是介于10000到20000. 假设线程A和B同时执行,第一次把count= 0 读取到各自缓存,count+=1之后,同时写到内存,发现count是1,而不是2, 之后两个线程在各自缓存计算count,所以最终结果不是20000.
public class TestVisibility {
	private static long count = 0;

	private void add10k() {
		int idx = 0;
		while (idx++ < 10000) {
			count += 1;
		}
	}

	private static long calc() throws InterruptedException {
		final TestVisibility testVisibility = new TestVisibility();
		// 创建两个线程,执行add10()
		Thread thread1 = new Thread(() -> {
			testVisibility.add10k();
		});
		Thread thread2 = new Thread(() -> {
			testVisibility.add10k();
		});
		// 启动线程
		thread1.start();
		thread2.start();
		// 等待线程结束
		thread1.join();
		thread2.join();
		return count;
	}

	public static void main(String[] args) throws InterruptedException {
		long count = calc();
		System.out.println(count);
	}
}

3. 问题2:线程切换带来的原子性问题

原子性: 一个或多个操作在CPU执行过程中不被中断的特性。

  1. 线程切换
    1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础_第3张图片
  2. 多线程的任务切换带来原子性问题的原因。

首先, 高级语言的一条语句往往需要多条CPU指令完成,例如count += 1,至少需要三条CPU指令:

  • 首先,需要把变量count从内存加载到CPU寄存器上;
  • 其次,在寄存器执行+1操作;
  • 最后,将结果写入内存(缓存机制导致可能写到CPU缓存而不是内存)。

其次, 操作系统做任务切换,可以发现在任务一条CPU指令,注意是CPU指令,不是高级语言的一条语句。
列如下图,我们最后得到的结果是1,而不是2。
1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础_第4张图片

4. 问题3:编译优化带来的有序性问题

有序性: 程序按照代码的先后次序执行。
经典案例:双重检查创建单利对象

public class Singleton {

	private static Singleton instance;

	private Singleton() {
	}

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

想象中的流程是这样的:

  1. 假设线程A、B同时调用getInstance(), 他们同时发现instance==null,他们同时加锁,而JVM只能保证一个加锁成功,假设A成功加锁,B在等待;
  2. 线程A创建一个实例后,释放锁;
  3. 锁释放后,B唤醒,加锁成功,此时B检查instance==null,已经有实例了,B不创建新的实例。

看似完美,问题出在 new Singleton() 身上:
我们认为new操作如下:

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

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

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

我们假设线程A执行到步骤2时,任务切换到线程B,B检查intance!=null,就返回实例,此时调用intance的成员变量可能出发空指针异常。
1. 可见性、原子性和有序性问题:并发编程bug源头 - 理论基础_第5张图片

你可能感兴趣的:(#,java并发编程读书笔记,并发编程)