《java多线程编程实战指南》——第二章笔记

串行(Sequential)、并发(Concurrent)、并行(Parallel)

目标:将串行计算改为并发乃至并行计算

竞态(Race Condition)

1、竞态是指计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving)。一个计算结果的正确性与时间有关的现象就被称为竞态。

2、竞态表现为计算的结果事儿正确时而错误。、

3、二维表分析法是分析和解释竞态的有效和常用工具。

4、一个类能够导致竞态,那么它就不是线程安全的。

5、线程安全意味着不存在竞态,但是不存在竞态却未必是线程安全。

6、保障操作的原子性可以消除竞态。

7、竞态模式:

7.1、read-modify-write

7.2、check-then-act

线程安全性

一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么就称其是线程安全(Thread-safe)的,相应地称这个类具有线程安全性(Thread Safety)

1、线程安全问题包括原子性(Atomic)、可见性(Visibility)、有序性(Ordering)

1.1、原子性的保障能够消除竞态。原子性和可见性一同得以保障了一个线程能够共享变量的相对新值。可见性是有序性的基础,而有序性可能影响可见性

2、原子性

对于设计共享变量访问的操作,若该操作从其执行线程以外的任意线程看来是不可分割的,那么该操作就是原子操作,并且该操作具有原子性。

2.1、原子操作具有不可分割性:两层含义,a、一个线程无法看到其他线程的中间结果;b、原子操作无法被交错

2.2、对 long/double型以外的任何变量的写操作都是原子的

2.3、volatile能够保障变量写操作的原子性

3、可见性

在多线程环境下,一个线程多某个共享变量进行更新后,后续访问该变量的线程可能无法立刻读取甚至永远无法读取到这个更新的结果。这就是线程安全问题的可见性

3.1、可见性问题并非必然出现

3.2、软件和硬件的因素都可以导致可见性问题

3.3、线程启动与可见性:父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。

3.4、线程终止与可见性:一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的

4、有序性

指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(out of order)

4.1、重排序

4.1.1、重排序类型:指令重排序和存储子系统重排序(内存重排序(Memory Ordering))

源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致,就会发生指令重排序(Instruction Reorder)。编译器、运行时和处理器导致指令重排序。

源代码顺序、程序顺序和执行顺序三者保持一致,但是感知顺序和执行顺序不一致,就会发生内存重排序

4.1.2、内存重排序类型:LoadLoad重排序,StoreStore重排序、LoadStore重排序、StoreLoad重排序。

4.1.3、貌似串行语义(As-if-serial Semantics)

重排序并非随意地对指令、内存操作的结果进行杂乱无章的排序或者顺序调整,而是遵循一定的规则。

编译器(主要是JIT编译器)、处理器(包括其存储子系统)都会遵守这些规则,

从而给单线程程序创造一种假象──指令是按照源代码顺序执行的。这种假象就被称为貌似串行语义。

貌似串行语义只是从单线程程序的角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。

4.1.4、数据依赖关系(Data Dependency)

为了保证貌似串行语义,存在数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。

如果两个操作(指令)访问同一个变量(地址),且其中一个操作(指令)为写操作,

那么这两个操作之间就存在数据依赖关系,这些操作包括:写后读(WAR)、读后写(RAW)、写后写(WAW)三种操作。

4.1.5、控制依赖关系(Control Dependency)

如果一条语句(指令)的执行结果会决定另外一条语句(指令)能否被执行,

那么这两条语句(指令)之间就存在控制依赖关系。存在控制依赖关系的语句是可以允许被重排序的,

存在控制依赖关系的语句最典型的就是if语句中的条件表达式和相应的语句体。

允许这种重排序意味着处理器可能先执行f语句体所涉及的内存访问操作,然后再执行相应的条件判断。

允许对存在控制依赖关系的语句进行重排序同样也是出于性能考虑,

这是因为存在控制依赖关系的语句(如if语句)会影响处理器对指令序列执行的并行程度。

4.1.6、保障内存访问顺序

貌似串行语义只是保障重排序不影响单线程程序的正确性,从这个角度出发,

多线程程序的有序性的保障可以理解为通过某些措施使得貌似串行语义扩展到多线程程序。即重排序要么不发生,

要么即使发生了也不会影响多线程程序的正确性,这样有序性的保障也可以理解为从逻辑上部分禁止重排序。

从底层的角度来说,禁止重排序是通过调用处理器提供相应的指令(内存屏障)来实现的。

当然,Java作为一个跨平台的语言,它会替我们与这类指令打交道,而我们只需要使用语言本身提供的机制即可。

5.上下文切换

描述

当一个进程中的一个线程由于其时间片用完,或者因其自身原因(比如稍后再继续运行)被迫或者主动暂停其运行时,另外一个线程(可能是同一个进程或者其他进程中的一个线程)可以被操作系统(线程调度器)选中,占用处理器开始或者继续其运行。这种一个线程被暂停,另外一个线程被选中开始或者继续运行的过程就叫作线程上下文切换。也可简单地称为上下文切换。

5.1、线程的切入(Switch In)与切出(Switch Out)

一个线程被剥夺处理器的使用权而被暂停运行就被称为切出,一个线程被操作系统选中占用处理开始或者继续其运行就被称为切入。

5.2、上下文(Context)

切出和切入的时候,操作系统需要保存和恢复相应线程的进度信息,即切入和切出那一刻相应线程所执行的任务状态信息(如计算的中间结果以及执行到了哪条指令)。这个进度信息就被称为上下文。它一般包括通用寄存器(General Purpose Register)和程序计数器(Program Counter)中的内容。

5.3、Java中线程的暂停与唤醒

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

5.4、上下文切换分类

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

5.4.1、自发性上下文切换(Voluntary Context Switch)

自发性上下文切换指线程由于其自身因素导致的切出。比如当前运行的线程发起了I/O操作(如读取文件)或者等待其他线程持有的锁,或在其运行过程中执行下列任意一个方法。

Thread. sleep(longmillis);

Object.wait();Object.wait(longtimeout);Object.wait(longtimeout,intnanos);

Thread.yield();

Thread.join();Thread.join(longtimeout);

LockSupport.park()

5.4.2、非自发性上下文切换(Involuntary Context Switch)

线程由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括:被切出线程的时间片用完、有一个比被切出线程优先级更高的线程需要被运行。

从Java平台的角度来看,Java虚拟机的垃圾回收(Garbage Collect)动作也可能导致非自发性上下文切换。这是因为垃圾回收器在执行垃圾回收的过程中,可能需要暂停所有应用线程才能完成其工作,比如在主要回收(Major Collection)过程中,垃圾回收器在对Java虚拟机堆内存区域进行整理的

5.5、上下文切换的开销

上下文切换的开销包括直接开销和间接开销。

️直接开销:

a.操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。

️b.线程调度器进行线程调度的开销:比如,按照一定的规则决定哪个线程会占用处理器运行。

间接开销:

️a.处理器高速缓存重新加载的开销:一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量,仍然需要被该处理器重新从主内存或者通过缓存致性协议从其他处理器加载到高速缓存之中,这是有一定时间消耗的。

b.高速缓存内容冲刷(Flush)的开销:️上下文切换也可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存),或者主内存(RAM)中线程的数量越多,可能导致的上下文切换的开销也就可能越大。也就是说,多线程编程中使用的线程数量越多,程序的计算效率可能反而越低。因此,在设计多线程程序的时候,减少上下文切换也是一个重要的考量因素。

5.6、线程的活性故障(Liveness Failure)

描述

事实上,线程并不是一直处于RUNNABLE状态,导致一个线程可能处于非RUNNABLE状态的因素,除了资源(主要是处理器资源有限而导致的上下文切换)限制之外,还有程序自身的错误和缺陷。由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或线程虽然处于RUNNABLE状态,但是其要执行的任务却一直无法进展,这种现象被称为线程活性故障。常见的线程活性故障包括以下几种:

死锁(Deadlock)

死锁只会出现在一组线程集合中,如果集合中的每一个线程都持有其他线程需要的资源,导致所有线程因等待资源而被永暂停,这种现象就称之为死锁。死锁产生的典型场景是线程X持有资源A的时候等待线程Y释放资源B,同时线程Y在持有资源B的时候却等待线程X释放资源A,这就好比鹬蚌相争故事中的情形。

锁死(Lockout)

锁死与死锁类似,锁死是指线程在等待一个永远不会发生的事件;与死锁不同的是,锁死的线程可能不持有任何资源。一个较典型的例子就是信号丢失导致的锁死,比如对CountDownLatch.countDown()方法的调用没有放在finally块中时,可能因为异常抛出导致执行CountDownLatch.await()的线程永远处于等待状态。

活锁(Livelock)

指线程一直处于运行状态,但是其任务却一直无法进展的一种活性故障。活锁的一个重要特征就是线程一直处于运行状态,区别于死锁、锁死的线程处于等待状态。同样以鹬蚌相争故事为例,不同的是两者商量好如果同时咬住对方,则两者都松开口,但松口后两者又同时咬住了对方,于是两者在不停的咬住与松口,直至累死。

饥饿(Starvation)

线程一直无法获得其所需的资源而导致其任务直无法进展的一种活性故障。

比如由于当前线程的优先级极低,导致资源一直被其他线程抢占。

5.7、资源争用与调度

线程间的资源共享

由于资源的稀缺性(例如有限的处理器资源)及资源本身的特性(例如打印机一次 只能打印一个文件),往往需要在多个线程间共享同一个资源。

排他性资源

一次只能够被一个线程占用的资源被称为排他性资源,常见的排他性资源包括处理器、数据库连接、文件等。

资源争用(Resource Contention)

在一个线程占用一个排他性资源进行访问(读、写操作),而未释放其对资源所有权的时候,其他线程试图访问该资源的现象就被称为资源争用,简称争用。显然,争用是在并发环境下产生的一种现象。

争用程度

同时试图访问同个已经被其他线程占用的资源的线程数量越多,争用的程度就越高,反之争用的程度就越低。相应的争用就被分别称为高争用和低争用。

资源调度

在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权,即选择哪个申请者占用该资源的过程就是资源的调度。获得资源的独占权而又未释放其独占权的线程就被称为该资源的持有线程。

       a、资源调度策略

资源调度的一种常见策略就是排队。资源调度器内部维护一个等待队列,在存在资源争用的情况下,申请失败的线程会被存入该队列。通常,被存入等待队列的线程会被暂停。当相应的资源被其持有线程释放时,等待队列中的一个线程会被选中并被唤醒而获得再次申请资源的机会。被唤醒的线程如果申请到资源的独占权,那么该线程会从等待队列中移除;否则,该线程仍然会停留在等待队列中等待再次申请的机会,即该线程会再次被暂停。因此,等待队列中的等待线程可能经历若干次暂停与唤醒才获得相应资源的独占权。可见,资源的调度可能导致上下文切换。

     a.1、资源调度公平性

              资源调度策略的一个常见特性就是它能否保证公平性。

所谓公平性,是指资源的申请者(线程),是否按照其申请(请求)资源的顺序而被授予资源的独占权。如果资源的任何一个先申请者,总是能够比任何一个后申请者先获得该资源的独占权,那么相应的资源调度策略就被称为是公平的;如果资源的后申请者可能比先申请者先获得该资源的独占权,那么相应的资源调度策略就被称为是非公平的。

需要注意的是,非公平的资源调度策略往往只是说明它并不保证资源调度的公平性,即它允许不公平的资源调度的出现,而不是表示它刻意造就不公平的资源调度。

     a.2、公平的调度策略

公平的调度策略不允许插队现象的出现,即只有在资源未被其他任何线程占用,并且没有其他活跃线程申请该资源情况下,队列中的线程才被允许被唤醒,抢占相应资源的独占权。其中,抢占成功的申请者获得相应资源的独占权,而抢占失败的申请者会进入等待队列。因此,公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。

      a.3、非公平的调度策略

而非公平的调度策略则允许插队现象,即一个线程释放其资源独占权的时候,等待队列中的一个线程会被唤醒申请相应的资源。而在这个过程中,可能存在另一个活跃线程与这个被唤醒的线程共同参与相应资源的抢占。因此,非公平调度策略中被唤醒的线程不一定就能够成功申请到资源。因此,在极端的情况下,非公平调度策略可能导致等待队列中的线程永远无法获得其所需的资源,即出现饥饿现象。

      a.4、对比

从申请者个体的角度来看:使用公平调度策略时,申请者获得相应资源的独占权所需时间的偏差可能比较小,即每个申请者成功申请到资源所需的时间基本相同;而使用非公平的调度策略时,申请者获得相应资源的独占权所需时间的偏差可能比较大,有的线程很快就申请到资源,而有的线程则要经历若干次暂停与唤醒才成功申请到资源。

从效率上看:在非公平调度策略中,资源的持有线程释放该资源的时候,等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续运行可能需要一段时间。在该时间内,如果使用非公平的调度策略,新来的线程(活跃线程)可以先被授予该资源的独占权,如果这个新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源。这种情形下,非公平调度策略可以减少上下文切换的次数。但是,如果多数(甚至每个)线程占用资源的时间相当长,那么允许新来的线程抢占资源不会带来任何好处,反而会导致被唤醒的线程需要再次经历暂停和唤醒,从而增加了上下文切換。因此,多数线程占用资源的时间相当长的情况下不适合使用非公平调度策略。

综上,在没有特别需要的情况下,我们默认选择非公平调度策略即可。在资源的持有线程占用资源的时间相对长,或线程申请资源的平均间隔时间相对长,或对资源申请所需的时间偏差有所要求(即时间偏差较小)的情况下可以考虑使用公平调度策略。

你可能感兴趣的:(《java多线程编程实战指南》——第二章笔记)