头图

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

TroyLiu

概述

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

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 垃圾回收负担而已。这也是多线程问题隐蔽性强且偶发的原因之一。

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

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

阅读 101

码流
码流 -- 关于Java、Golang、Perl、计算机等等等等。关于编程就对了。

Java 开发工程师

111 声望
2 粉丝
0 条评论
你知道吗?

Java 开发工程师

111 声望
2 粉丝
宣传栏