# 特性

读书笔记:黄文海.Java多线程编程实战指南(核心篇)(Java多线程编程实战系列)(Kindle位置1032).电子工业出版社.Kindle版本.

# 串行、并发、并行

# 定义

串行:顾名思义,任务有序执行,完成A才能开始B,完成B才能开始C

并发:并发就是在一段时间内以交替的方式去完成多个任务,

并行:而并行就是以齐头并进的方式去完成多个任务。并发

image-20210917125334629

# 线程安全性

一个类如果不是线程安全的,我们就说它在多线程环境下直接使用存在线程安全问题。线程安全问题概括来说表现为3个方面:原子性、可见性和有序性。

# 原子性

# 定义

原子(Atomic)的字面意思是不可分割的(Indivisible)。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性(Atomicity)。

所谓“不可分割”,其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。访问同一组共享变量的原子操作是不能够被交错的,这就排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。这就是“不可分割”的第二个含义。

Java中有两种方式来实现原子性。一种是使用锁(Lock)。锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。另一种是利用处理器提供的专门CAS(Compare-and-Swap)指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的,它可以被看作“硬件锁”。

在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,即对基础类型(long/double除外,仅包括byte、boolean、short、char、float和int)的变量和引用型变量的写操作都是原子的。这点由Java语言规范(JLS,JavaLanguageSpecification)规定,由Java虚拟机具体实现。对long/double型变量的写操作由于Java语言规范并不保障其具有原子性,因此在多个线程并发访问同一long/double型变量的情况下,一个线程可能会读取到其他线程更新该变量的“中间结果”。例如,设一个long型共享变量value的初始值为0,有两个线程(updateThread1、updateThread2)并发地分别将value更新为-1和0,另外一个线程(main)会读取value的值

尽管如此,Java语言规范特别地规定对于volatile关键字修饰的long/double型变量的写操作具有原子性。因此,我们只需要用volatile关键字修饰清单2-6中的共享变量value,就可以保障对该变量的写操作的原子性。

volatile关键字仅能够保障变量写操作的原子性,它并不能保障其他操作(比如read-modify-write操作和check-then-act操作)的原子性。

原子操作+原子操作=原子操作?

这个问题我们通过一个实例就可以得到答案。例如,对于共享int型变量a和b(初始值皆为0),假设线程A执行如下操作:

a=1;//语句①

b=2;//语句②

显然,语句①和语句②都是原子操作(这点由Java语言规范保证)。但是,在线程A执行完语句①之后和在执行语句②之前的这一刻,另外一个线程B可以读取变量a和变量b的值。那么,此刻线程B读取到变量a和变量b的值分别为1和0,也就是说它读取到了线程A所执行操作的中间结果,这有悖于原子操作不可分割的特性。因此,“原子操作+原子操作”所得到的复合操作并非原子操作。

# 可见性

# 定义

在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性(Visibility)。

可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache)子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器(StoreBuffer)中,还没有到达该处理器的高速缓存中,更不用说到主内存中了。而一个处理器的写缓冲器中的内容无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变量的更新。即便一个处理器上运行的线程对共享变量的更新结果被写入该处理器的高速缓存,由于该处理器将这个变量更新的结果通知给其他处理器的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列(InvalidateQueue)中,而没有直接根据更新通知的内容更新其高速缓存的相应内容,这就导致了其他处理器上运行的其他线程后续再读取相应共享变量时,从相应处理器的高速缓存中读取到的变量值是一个过时的值。

处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(StoreBuffer,也称WriteBuffer)和无效化队列(InvalidateQueue)等部件执行内存的读、写操作的。从这个角度来看,这些部件相当于主内存的副本,因此本书为了叙述方便将这些部件统称为处理器对主内存的缓存,简称处理器缓存

虽然一个处理器的高速缓存中的内容不能被另外一个处理器直接读取,但是一个处理器可以通过缓存一致性协议(CacheCoherenceProtocol)来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其反映(更新)到该处理器的高速缓存的过程,我们称之为缓存同步。相应地,我们称这些存储部件的内容是可同步的,这些存储部件包括处理器的高速缓存、主内存。缓存同步使得一个处理器(上运行的线程)可以读取到另外一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。因此,为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是始终停留在其写缓冲器中),这个过程被称为冲刷处理器缓存。并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为刷新处理器缓存。因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。

Java中可以通过在变量的声明时添加一个volatile关键字即可,volatile关键字所起到的一个作用就是,提示JIT编译器被修饰的变量可能被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。另外一个作用就是读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,写一个volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作,从而保障了可见性。

约定

对于同一个共享变量而言,一个线程更新了该变量的值之后,其他线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值。如果读取这个共享变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。

# 有序性

# 定义

有序性(Ordering)指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(Outoforder)。所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。在进一步介绍有序性这个概念之前,我们需要先介绍重排序的概念。

# 重排序的概念

顺序结构是结构化编程中的一种基本结构,它表示我们希望某个操作必须先于另外一个操作得以执行。另外,两个操作即便是可以用任意一种顺序执行,但是反映在代码上这两个操作也总是有先后关系。但是在多核处理器的环境下,这种操作执行顺序可能是没有保障的:编译器可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫作重排序(Reordering)。

重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题。与可见性问题类似,重排序也不是必然出现的。

重排序的潜在来源有许多,包括编译器(在Java平台中这基本上指JIT编译器)、编译器)、处理器和存储子系统(包括写缓冲器StoreBuffer、高速缓存Cache)。

定义几个概念便于下文理解:

  • 源代码顺序(SourceCode):源代码中所指定的内存访问操作顺序。

  • 程序顺序(ProgramOrder):在给定处理器上运行的目标代码(ObjectCode)所指定的内存访问操作顺序。尽管Java虚拟机执行Java代码有两种方式:解释执行(被执行的是字节码ByteCode)和编译执行(被执行的是机器码)。为便于讨论,这里仅将目标代码定义为字节码。

  • 执行顺序(ExecutionOrder):内存访问操作在给定处理器上的实际执行顺序。

  • 感知顺序(PerceivedOrder):给定处理器所感知到(看到)的该处理器及其他处理器的内存访问操作发生的顺序。

在此基础上,我们将重排序划分为指令重排序(InstructionReorder)和存储子系统重排序两种,

image-20210917163551206

# 指令重排

在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序(InstructionReorder)。指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。


Java平台包含两种编译器:静态编译器(javac)和动态编译器(JIT编译器)。前者的作用是将Java源代码(.java文本文件)编译为字节码(.class二进制文件),它是在代码编译阶段介入的。后者的作用是将字节码动态编译为Java虚拟机宿主机的本地代码(机器码),它是在Java程序运行过程中介入的。


在其他编译型语言(如C++)中,编译器是可能导致指令重排序的:编译器出于性能的考虑,在其认为不影响程序(单线程程序)正确性的情况下可能会对源代码顺序进行调整,从而造成程序顺序与相应的源代码顺序不一致。在Java平台中,静态编译器(javac)基本上不会执行指令重排序,而JIT编译器则可能执行指令重排序。

# 存储子系统重排序

主内存(RAM)相对于处理器是一个慢速设备。为了避免其拖后腿,处理器并不是直接访问主内存,而是通过高速缓存(Cache)访问主内存的。在此基础上,现代处理器还引入了写缓冲器(StoreBuffer,也称WriteBuffer)以提高写高速缓存操作(以实现写主内存)的效率。有的处理器(如Intel的x86处理器)对所有的写主内存的操作都是通过写缓冲器进行的。这里,我们将写缓冲器和高速缓存统称为存储子系统,它其实是处理器的子系统。即使在处理器严格依照程序顺序执行两个内存访问操作的情况下,在存储子系统的作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,即这两个操作的执行顺序看起来像是发生了变化。这种现象就是存储子系统重排序,也被称为内存重排序(MemoryOrdering)。指令重排序的重排序对象是指令,它实实在在地对指令的顺序进行调整,而存储子系统重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果。习惯上为了便于讨论,在论及内存重排序问题的时候我们往往采用指令重排序的方式来表述,即我们也会用“内存操作X被重排序到内存操作Y之后”这样的表述称呼内存重排序。

# 可见性与有序性的关系

可见性是有序性的基础。可见性描述的是一个线程对共享变量的更新对于另外一个线程是否可见,或者说什么情况下可见的问题。 有序性描述的是,一个处理器上运行的线程对共享变量所做的更新,在其他处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。 因此,可见性是有序性的基础。另一方面,二者又是相互区分的。有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。

# 上下文切换

上下文切换(ContextSwitch)在某种程度上可以被看作多个线程共享同一个处理器的产物[,它是多线程编程中的一个重要概念。

# 上下文切换及其产生原因

单处理器(Uniprocessor)上也能够以多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程。这好比即使是一次只能够被一个儿童操纵的玩具(如遥控四驱车)也能够被多个儿童一起玩的情形:每个儿童玩一定的时间,时间到了,这个玩具必须交给另外一个儿童玩,依此规则各个儿童轮流玩。这里,每个儿童可以占用玩具进行玩耍的时间就被称为时间片(TimeSlice)。单处理器上的多线程其实就是通过这种时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或者其自身的原因(比如,它需要稍后再继续运行)被迫或者主动暂停其运行时,另外一个线程(可能是同一个进程或者其他进程中的一个线程)可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫作线程上下文切换。

从Java应用的角度来看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态(包括BLOCKED、WAITING和TIMED_WAITING中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。当一个线程的生命周期状态由RUNNABLE转换为非RUNNABLE时,我们称这个线程被暂停。线程的暂停就是相应线程被切出的过程,这里操作系统会保存相应线程的上下文,以便该线程稍后再次进入RUNNABLE状态时能够在之前执行进度的基础上进展。而一个线程的生命周期状态由非RUNNABLE状态进入RUNNABLE状态时,我们就称这个线程被唤醒(Wakeup)。一个线程被唤醒仅代表该线程获得了一个继续运行的机会,而并不代表其立刻可以占用处理器运行。因此,当被唤醒的线程被操作系统选中占用处理器继续其运行的时候,操作系统会恢复之前为该线程保存的上下文,以便其在此基础上进展。

# 上下文切换的分类及具体诱因

按照导致上下文切换的因素划分,我们可以将上下文切换分为自发性上下文切换(VoluntaryContextSwitch)和非自发性上下文切换(InvoluntaryContextSwitch)。

自发性上下文切换指线程由于其自身因素导致的切出。从Java平台的角度来看,一个线程在其运行过程中执行下列任意一个方法都会引起自发性上下文切换。

  • Thread.sleep(longmillis)
  • Object.wait()/wait(longtimeout)/wait(longtimeout,intnanos)
  • Thread.yield()[14]
  • Thread.join()/Thread.join(longtimeout)
  • LockSupport.park() 另外,线程发起了I/O操作(如读取文件)或者等待其他线程持有的锁(锁的概念在第3章会介绍)也会导致自发性上下文切换[。

非自发性上下文切换指线程由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括被切出线程的时间片用完或者有一个比被切出线程优先级更高的线程需要被运行。从Java平台的角度来看,Java虚拟机的垃圾回收(GarbageCollect)动作也可能导致非自发性上下文切换。这是因为垃圾回收器在执行垃圾回收的过程中可能需要暂停所有应用线程(Stoptheworld)才能完成其工作,比如在主要回收(MajorCollection)过程中,垃圾回收器在对Java虚拟机堆内存区域进行整理(Compact)的时候需要先停止所有应用线程。

# 上下文切换的开销和测量

一方面,上下文切换是必要的。即使是在多核处理器系统中上下文切换也是必要的,这是因为一个系统上需要运行的线程的数量相对于这个系统所拥有的处理器数量总是要大得多(“僧多粥少”)。另一方面,上下文切换又有其不容小觑的开销。

从定性的角度来说,上下文切换的开销包括直接开销和间接开销。

直接开销包括:

  • 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
  • 线程调度器进行线程调度的开销(比如,按照一定的规则决定哪个线程会占用处理器运行)。

间接开销包括:

  • 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这是有一定时间消耗的。
  • 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存)或者主内存(RAM)中。