Java 并发系列(一):多线程三大特性

概述

多线程三大特性:原子性、可见性、有序性。java

1.原子性

原子性是指:多个操做做为一个总体,不能被分割与中断,也不能被其余线程干扰。若是被中断与干扰,则会出现数据异常、逻辑异常。编程

多个操做合并的总体,咱们称之为复合操做。一个复合操做,每每存在先后依赖关系,后一个操做依赖上一个操做的结果。若是上一个操做结果被其余线程干扰,对于当前线程看来整个复合操做的结果便不符合预期。同理线程也不能在复合操做中间被中断,中断必须发生在进入复合操做以前或者等到复合操做结束以后。数组

保证原子性就是在多线程环境下,保证单个线程执行复合操做符合预期逻辑。缓存

典型的复合操做:『先检查后执行』和『读取—修改—写入』多线程

1.1 先检查后执行

@NotThreadSafe
public class LazyInitClass {
    private static LazyInitClass instance ;

    public static LazyInitClass getInstance() {
        if(instance == null)
            instance = new LazyInitClass() ;

        return instance ;
    }
}

LazyInitClassgetInstance 中包含先检查后执行的复合操做,一般咱们也能够称 getInstance 中包含竞态条件。假设线程 A 和线程 B 同时执行 getInstance。A 看到 instance 为空,便执行 new LazyInitClass() 逻辑。A 还未完成初始化并设置 instance,B 检查 instance,此时 instance 为空,B 便也会执行 new LazyInitClass()。那么两次调用 getInstance 时可能会获得不一样的结果。一般 getInstance 的预期结果是屡次调用获得相同的对象实例。性能

LazyInitClassgetInstance 方法虽然存在竞态条件,多数状况下并不会形成业务异常,影响仅仅是增长了 JVM 垃圾回收负担而已。这也是多线程问题隐蔽性强且偶发的缘由之一。编码

但话说回来,编程原则之一就是全部逻辑都必须创建在肯定性之上,任何创建在不肯定性上的逻辑都是隐患。虽然从业务上看多数状况下没问题,但竞态条件的存在,让代码逻辑创建在不肯定性之上。做为编码者应该重视此类问题。spa

1.2 读取—修改—写入

@NotThreadSafe
public class ReadModifyAndWriteClass {
    private int count = 0 ;

    public int increase() {
        return count++ ;
    }
}

因为 i++ 自己不是原子操做,属于复合操做。ReadModifyAndWriteClassincrease 包含了读取—修改—写入。假设线程 A 和线程 B 同时执行 increase。A 看到 count 为 0,执行 ++ 逻辑。当 ++ 操做还未完成,此时 B 读取 count 看到的仍然是 0。A、B 各自完成 ++ 逻辑后,count 的值等于 1。这就形成了虽然调用了两次 increase 方法,但 count 只增长了 1。这也与预期:每调用一次 increase,count 增长 1 的结果不符。线程

2.可见性

可见性问题是指,一个线程修改的共享变量,其余线程是否可以马上看到。对于串行程序而言,并不存在可见性问题,前一个操做修改的变量,后一个操做必定能读取到最新值。但在多线程环境下若是没有正确的同步则不必定。设计

有不少因素会使得线程没法当即看到甚至永远没法看到另外一个线程的操做结果。在编译器中生成的指令顺序,能够与源代码中的顺序不一样,此外编译器还会把变量保存在寄存器而非内存中;处理器能够采用乱序或并行等方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;并且,保存在处理器本地缓存中的值,对于其余处理器是不可见的。这些因素都会使得一个线程没法看到变量的最新值,而且会致使其余线程中的内存操做彷佛在乱序执行。

2.1 缓存引发的可见性

image
上图是多核 CPU 内存图,其中 individual memory 表示核心多级缓存。main memory 表示主内存,即共享内存。共享内存(shared memory)是线程之间共享的内存,也称为堆内存(heap memory)。全部实例域(instance fields)、静态域(static fields)和数组元素(array elements)都保存在堆内存中。

A 线程与 B 线程共同操做共享变量 V(初始值为 0),A、B 线程分别将 V 变量从主内存复制到 CPU 内核的多级缓存中,此时 A 与 B 都读到 V 的值为 0。A 更新本身的 individual memory 中的 V 的值为 1,此时若是没有将 V 值同步至主内存中,B 从本身的 individual memory 中读到 V 的值仍然为 0。当 V 值同步到主内存后,多级缓存失效,此时 B 才可以从主内存中读取到最新的 V 值为 1。因为多线程环境下什么时候将多级缓存同步到主内存时间上不肯定,因此形成了可见性问题,即 A 线程对共享变量 V 的写操做,位于写操做后执行的 B 线程的读操做不能当即感知。

3.有序性

有序性问题是指从观察到的结果推测,代码执行的顺序与代码组织的顺序不一致。

3.1 指令重排序引发的有序性问题

在计算机体系结构中,为了提升执行部件的处理速度,常常在部件中采用流水线技术。所谓流水线技术,是指将一个重复的时序过程,分解成若干个子过程,而每个子过程均可有效地在其专用功能段上与其余子过程同时执行。

以 DLX 指令集结构为例,一条指令的执行简单说能够分为如下几个步骤:

  1. 取指令(IF)
  2. 指令译码/读寄存器(ID)
  3. 执行/有效地址计算(EX)
  4. 存储器访问/分支完成(MEM)
  5. 写回(WB)

每个步骤均可能使用不一样的硬件完成。
image
由上图所示,若是没有指令流水线,指令2 须要等待指令1 彻底执行完成后执行。假设每个步骤(子过程)须要花费 1 个 CPU 时钟周期,则指令2 须要等待 5 个时钟周期。而使用指令流水线后,指令2 只需等待 1 个时钟周期就能够开始执行。指令2 开始执行时,指令1 根本还没开始执行,仅仅完成了取指操做而已。这仅仅是 DLX 指令集结构的流水线,实际商用 CPU 的流水线级别甚至能够达到 10 级以上,性能提高可谓是很是明显。

因为流水线技术的引入,不得不面对流水线的三种类型的相关:结构相关、数据相关、控制相关。

  1. 结构相关:当指令在重叠执行过程当中,硬件资源知足不了指令重叠执行的要求,发生资源冲突时将产生“结构相关”。
  2. 数据相关:当一条指令须要用到前面指令的执行结果,而这些指令均在流水线中重叠执行时,就可能引发“数据相关”。
  3. 控制相关:当流水线遇到分支指令和其余会改变 PC 值的指令时就会发生“控制相关”。

一旦流水线中出现相关,指令在流失线中的执行就会出现问题,消除相关的最基本方法是让流水线中的某些指令暂停执行。一旦暂停,全部硬件设备都会进入一个停顿周期,直接影响是性能的降低。

咱们说的指令重排序就是在产生数据相关时替代流水线暂停的重要方法。指令重排序仅仅是减小流水线暂停技术的一种,在 CPU 设计中还有不少其余软硬件技术来防止流水线暂停。

下图展现了 A = B + C 操做的执行过程。LW 表示加载,LW R1, B 表示把 B 的值加载到寄存器 R1 中。ADD 表示加法,ADD R3, R1, R2 表示把寄存器 R1 和 R2 中的值相加保存到寄存器 R3 中。SW 表示存储,SW A, R3 表示将寄存器 R3 中的值保存到变量 A 中。
image
能够看到,ADD 指令的流水线上出现了一个 stall,表示一个暂停。之因此出现暂停,是由于 R2 的数据还没准备好( LW R2, C 的操做还没完成 )。因为 ADD 暂停的出现,后续的操做都暂停了一个周期。

下面是一个更为复杂的例子:
image
能够看到,因为 ADD 和 SUB 指令都须要等待上一条指令的执行结果,因此整个流水线上插入了很多 stall。下图显示了如何消除相似的暂停。
image
因为 LW Re, E; LW Rf, F 通过指令重排序后,并不影响代码执行逻辑。而且当重排序后,全部流水线暂停均可以消除。
image
虽然指令重排序会致使有序性问题,但指令重排序对性能的提升有很是重大的意义。

3.2 CPU 缓存引发的有序性问题

2.1 节已经讨论过 CPU 缓存致使的可见性问题。CPU 缓存也会致使有序性问题。

看以下的例子:
image
假设 b、c 为局部变量,初始值为 1,A、D 为共享变量,初始值为 0 和 false。Thread1 先于 Thread2 运行,运行结果:Thread2 输出 0。

从结果推测 Thread1 中的 D = true 先于 A = b + c 执行了。

当 D = true 执行完成后,A = b + c 还没来得及执行,此时 Thread2 输出 A 的值,才会出现结果为 0 的状况。

分析:Thread1 将 A、D 共享变量从主内存复制到当前 CPU 内核的多级缓存中,按顺序执行完 A = b + c 和 D = true 后,多级缓存中 A = 2, D = true。而后 Thread1 将 D 的值优先同步到主缓存,A 的值没有同步到主缓存。此时 Thread2 执行,能看到 D 的最新值 true,却不能看到 A 的最新值,只能看到主缓存中 A 的初始值 0。

因此从 Thread2 看,Thread1 线程的执行出现了有序性问题,但从 Thread1 看,本身的确是按照代码组织顺序执行的。

4.总结

本章详细讲解了多线程的三大特性:原子性、可见性、有序性。想要正确编写多线程程序,必定要正确理解这三大特性。

5.参考资料

  1. 《The Java® LanguageSpecification Java SE 8 Edition》做者:James Gosling、Bill Joy、Guy Steele、Gilad Bracha、Alex Buckley
  2. 《Java Concurrency in Practice》做者:Brain Goetz、Tim Peierls、Joshua Bloch、Joseph Bowbeer、David Holmes、Doug Lea
  3. 《计算机体系结构》做者:张晨曦、王志英、张春元、戴葵、朱海滨

image

相关文章
相关标签/搜索