1 并发问题出现的根源

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

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

    1.1 可见性:CPU缓存引起

    可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
    两个线程分别由不同的CPU执行,线程1对变量声明初始化之后先写入缓存,再写入内存,线程1修改变量后先写入缓存,此时线程2执行,从内存中读取到的是线程1修改后还未被写入内存的值。
    计算机组成与多线程.svg

    1.2 原子性:分时复用引起

    原子性:CPU分时复用引起,即一个操作或者多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
    原子性.svg

    1.3 有序性:指令重排序引起

    有序性:即程序执行的顺序按照代码的先后顺序执行。
    在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

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

有序性.svg
这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

2 怎么解决并发问题

2.1 理解的第一个维度:核心知识点

JMM(Java内存模型)本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:

  • volatilesynchronizedfinal 三个关键字
  • Happens-Before 规则

    2.2 理解的第二个维度:原子性、可见性、有序性

    原子性

    在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

    可见性

    Java提供了volatile关键字来保证可见性。
    当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
    而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

    另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。