c# 多线程 执行事件 并发_Java 多线程编程(1)-入门筑基

目前,多线程编程可以说是在大部分平台和应用上都需要重视和实现的一个基本需求,是一个普遍性的现状。而多线程编程相关的概念以及在实现多线程编程的过程中会遇到的一些问题和难题,也是独立于开发者所面向的操作系统和开发语言的。所以虽然本系列关于多线程编程知识的文章面向的是 Java 平台,使用的例子大部分是用 Kotlin 语言实现的,但在概念上对于大部分开发者来说都是通用且可以理解的。希望本系列文章对你有所帮助

❝ 本系列文章参考的书籍有:
《Java 多线程编程实战指南(核心篇)》
《深入理解 Java 虚拟机》
《Java 并发编程的艺术》

目录:

  1. Java 多线程编程(入门筑基)
  2. Java 多线程编程(异步中包含同步)
  3. Java 多线程编程(线程活性故障有哪些)
  4. Java 多线程编程(“锁”事碎碎念)
  5. Java 多线程编程(聊聊线程池)

一、多线程编程

本系列文章要介绍的是 Java 平台下关于多线程编程的知识点,那么我们首先要明白以下几点:

  1. 什么是多线程编程?
  2. 实现多线程编程的意义是什么?或者说,使用多线程能给我们带来什么益处?
  3. 采用多线程编程就一定是有益的吗?

1.1、什么是多线程编程

假设存在三个事件(事件A、事件B、事件C)需要我们完成,每个事件均包含一定的前置处理时间和等待完成时间,即每个事件均需要先处理一定时间,处理完成后再等待一段时间,等待过后该事件就算作已完成了。那么,我们就可以采用三种不同的方式来完成这三个事件:

  • 串行。按照顺序依次来处理三个事件,待某个事件处理且等待结束后再处理下一个事件。这种方式需要消耗一个人力
  • 并发。先处理事件 A,当事件 A 的前置处理完成后,转而来处理事件 B,当事件 B 的前置处理完成后,转而来处理事件 C,最后就只要等待三个事件结束即可。这种方式需要消耗一个人力
  • 并行。三个事件分别转交由三个人进行同时处理。这种方式需要消耗三个人力

从直观上看,串行的处理效率最低,耗时最长,在每次等待事件完成的时间段内人力都被白白消耗了。并行的处理效率最高,耗时最短,理论上总的所需耗时取决于用时最长的那个事件,但其需要的人力成本也最高。并发的处理效率和耗时长短均介于串行和并行之间,需要的人力成本和串行持平(均低于并行)

从以上假设的情景映射到我们现实世界,我们也可以明白并发在人力有限的情况下是最为经济高效的一种工作方式。并发往往可以提高我们处理事情的效率,即在一段时间内可以处理或者完成几件事情。而并行可以看做是并发的特例,并行一样可以在一段时间内处理或者完成几件事情,工作效率也可能会大幅提升(但也有可能反而下降,例如像我们常说的一个和尚挑水喝,两个和尚担水喝,三个和尚没水喝),人力成本相对并发也会明显地增加

从以上假设的情景映射到软件世界。并发就是在一段时间内以交替的方式来完成多个任务,使用多个线程来分别处理不同的任务,即使在单个处理器的情况下也可以通过「时间片切换」的技术来实现在一个时间段内运行多个线程,因此即使只有一个处理器也可以实现并发。而并行就是以同时处理的方式来完成多个任务,使用多个线程来分别处理不同的任务,然后将多个线程分别转交给不同的处理器进行运行,因此并行需要有多个处理器才可以实现

而在现实情况下,程序需要同时执行的线程数量往往是远多于处理器的数量,并发才是我们的主要实现目标。因此,可以说,「实现多线程编程的过程就是将任务的执行方式由串行改为并发的过程,即实现并发化,以此来尽量提高程序和硬件的运行效率」

1.2、多线程编程的意义

现如今,使用多线程编程是一个普遍存在的现状和需求

对于计算机来说,其处理器的运算速度相比存储和通信子系统要快了几个数量级,如果只采用单线程,那么当线程在处理磁盘 I/O、数据库访问等任务时,处理器就被闲置着没有活干了,这就造成了很大的性能浪费。此时就可以通过采用多线程来使处理器尽量处于运转状态,尽量应用其运算能力

此处,对于一个服务端,衡量其性能高低好坏的一个重要标准之一是「每秒事务处理数(TPS)的大小」,它代表着一秒内服务端平均能响应的请求总数。服务端可能会在极小的时间段内收到多个请求,服务端的 TPS 就和程序的并发能力(即同时处理多项任务的能力)有着密切关联

再比如,在 Android 应用开发中,系统规定了只有 main 线程才可以进行 UI 绘制和刷新,如果不将耗时操作(IO读写、网络请求等)放到子线程进行处理,那么用户对应用的 UI 操作行为(点击屏幕、滑动列表等)很大可能就会由于无法及时被 main 线程处理,导致应用似乎被卡住了,最终用户可能就会放弃使用该应用了

所以,使用多线程编程可以最大限度地利用系统提供的处理能力,提高程序的吞吐率和响应性,避免性能浪费

1.3、多线程就一定能提高效率吗

在某些场景下采用多线程有可能反而会使得整个系统的运行效率降低。采用多线程后,各个线程间可能会相互竞争系统资源,例如处理器时间片、排他锁、带宽、硬盘读写等,而资源往往是有限且每次只能由一个线程使用的,并发编程的最终效益就往往受限于资源的有限分配,多个线程争用同一个排他性资源就会带来线程上下文切换甚至死锁等问题

例如,当采用多个线程来分段下载某个网络文件以此来希望减少下载耗时。由于带宽大小是固定的,使用多个线程同时进行下载首先就会拉低每个线程的平均可用带宽大小,每个线程下载到的单份资源也需要通过硬盘读写合并成一个完整的文件,每段资源的合并需要通过调度程度来按顺序写入,维护调度顺序的过程也是有着性能的消耗,多个线程进行 IO 读写也会加大发生线程上下文切换的次数。因此,某些情况下采用多线程可能会显得“并不那么值得”,需要我们根据实际情况来衡量使用

二、进程、线程、任务、多线程编程

程序(Program)是对指令、数据及其组织形式的描述,是一种静态的概念。进程(Process)是程序的运行实例,每个被启动的程序就对应运行于操作系统上的一个进程,是一种动态的概念。进程是程序向操作系统申请资源(内存空间、文件句柄等)的基本单位,也是操作系统进行资源调度和资源分配的基本单位。运行一个 Java 程序实质上就是启动了一个 Java 虚拟机进程

线程(Thread)是进程中可独立执行的最小单位,也是操作系统能够进行运算调度的最小单位,也被称为轻量级进程。每个线程总是包含于特定的进程内,一个进程可以包含多个线程且至少包含一个线程,线程是进程中的实际运行单位。同一个进程中的所有线程共享该进程中的资源(内存空间、文件句柄等)

线程所要完成的逻辑计算称为任务(Task)。线程在创建之初的目的就是为了让其来执行特定的逻辑计算,其所要完成的工作就称为该线程的任务

多线程编程是一种以线程为基本抽象单位的编程范式(Praadigm)。现代计算机操作系统几乎都支持多任务处理,多任务处理有两种不同的类型:「基于进程的多任务处理」「基于线程的多任务处理」

  • 基于进程的多任务处理指操作系统支持同时运行多个程序,进程是调度程序能够调度的最小代码单元。进程是重量级的任务,每个进程需要有自己的地址空间,进程间通信开销很大而且有很多限制,从一个进程上下文切换到另一个进程上下文的开销也很大
  • 基于线程的多任务处理意味着单个进程可以同时执行多个任务,线程是调度程序能够调度的最小代码单元。基于线程的多任务处理需要的开销要比基于进程的多任务处理小得多。线程是轻量级的任务,它们共享同个进程下的资源,线程间通信的开销不大,并且同个进程下的不同线程上下文间的切换所需要的的开销要比不同进程上下文间的切换小得多

基于进程的多任务处理是由操作系统来实现并管理的,一般的程序开发接触不到这个层面。而基于线程的多任务处理则可以由程序开发者自己来实现并进行管理。可以说,多线程编程的一个目的就是为了实现「基于线程的多任务处理」

Java 对多线程提供了内置支持。Java 标准类库中的 java.lang.Thread 类就是对线程这个概念的抽象实现,提供了在不同的硬件和操作系统平台上对线程操作的统一处理,屏蔽了不同的硬件和操作系统的差异性

❝ Java 本身是一个多线程的平台,即使开发者没有主动创建线程,此时进程内还是使用到了多个线程,例如还存在 GC 线程。所谓的单线程编程往往指的是在程序中开发者没有主动创建线程

三、创建线程

Java 标准类库 java.lang.Thread 是 Java 平台对线程这个概念的抽象实现,Thread 类或者其子类的一个实例就是一个线程。每个线程在定义之初就是为了执行特定的任务,任务的处理逻辑可以直接声明在 Thread 类的 run() 方法内部或者间接通过该方法来进行调用

Java 平台提供了两种不同的方式来让开发者声明线程所需要执行的任务:

  1. 实现 Runnable 接口
  2. 继承 Thread 类

3.1、实现 Runnable 接口

Runnable 接口抽象了一个可执行的代码单元,其本身仅包含一个抽象方法

public interface Runnable {
    public abstract void run();
}

可以依托任何实现了 Runnable 接口的对象来创建线程,即将实现了 Runnable 接口的对象作为构造参数来声明 Thread 对象

从以下的输出结果可以看到,Task 的 run() 方法在非主线程的 「Thread-0」 上被执行

/**
 * 作者:leavesC
 * 时间:2020/8/1 16:17
 * 描述:
 * GitHub:https://github.com/leavesC
 */
class Task : Runnable {

    override fun run() {
        for (i in 1..5) {
            println("${Thread.currentThread().name} index: $i")
        }
    }

}

fun main() {
    println("currentThread name: " + Thread.currentThread().name)
    val thread = Thread(Task())
    thread.start()
//    currentThread name: main
//    Thread-0 index: 1
//    Thread-0 index: 2
//    Thread-0 index: 3
//    Thread-0 index: 4
//    Thread-0 index: 5
}

3.2、继承 Thread 类

继承 Thread 类需要重写其 run() 方法,该方法是线程运行的入口点

/**
 * 作者:leavesC
 * 时间:2020/8/1 16:17
 * 描述:
 * GitHub:https://github.com/leavesC
 */
class MyThread : Thread() {

    override fun run() {
        for (i in 1..5) {
            println("$name index: $i")
        }
    }

}

fun main() {
    println("currentThread name: " + Thread.currentThread().name)
    val thread = MyThread()
    thread.start()
//    currentThread name: main
//    Thread-0 index: 1
//    Thread-0 index: 2
//    Thread-0 index: 3
//    Thread-0 index: 4
//    Thread-0 index: 5
}

3.3、运行流程

以上两种不同创建线程的方式,其运行流程本质上都是一样的

Thread 类本身也实现了 Runnable 接口,当以自定义的 Runnable 对象作为 Thread 类的构造参数来构造 Thread 对象时,Thread 的 run() 方法会通过调用 Runnable 对象的 run() 方法来执行目标任务

public class Thread implements Runnable {
    
    private Runnable target;
    
 @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    
}

所以,我们可以简单地理解为运行一个线程就是要求 Java 虚拟机来调用 Runnable 对象的 run() 方法,从而使得线程的任务处理逻辑得以被执行。但我们通过 Thread.start() 启动一个线程并不意味着线程就能够马上被执行,线程的具体执行时机由「线程调度器」来决定,执行时机具有不确定性,甚至可能会由于线程活性故障而永远无法运行。此外,由于 run() 方法是 public 的,所以它也可以由外部主动来调用执行,但此时其任务就是由当前的运行线程来执行,这在大多数时候都是没有实际意义的

而不管是通过什么方式来创建线程,当线程的 run() 方法执行结束时(不管是正常结束还是由于异常而中断运行),线程的生命周期也就走到末尾了,其占用的资源会在后续被 Java 虚拟机垃圾回收。而且,线程是一次性资源,我们无法通过再次调用 start() 方法来重新启动线程,当多次调用该方法时会抛出 IllegalThreadStateException

四、线程的特性

4.1、属性与方法

Thread 类的常用属性包括:线程编号、线程名称、线程类别、线程优先级等

ble data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">

按照线程是否会阻止 Java 虚拟机正常停止,Java 中的线程分为「用户线程」「守护线程」。用户线程会阻止 Java 虚拟机的正常停止,即一个 Java 虚拟机只有在其所有用户线程都运行结束的情况下才能正常停止。而守护线程不会影响 Java 虚拟机的正常停止,即使应用程序中还有守护线程在运行也不影响 Java 虚拟机的正常停止。因此,守护线程适合用于执行一些重要性不是很高的任务。但如果 Java 虚拟机是被强制停止或者由于异常被停止的话,用户线程也无法阻止 Java 虚拟机的停止

Java 线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行,优先级高的线程理论上会获得更多的处理器使用时间,但线程调度器并不保证一定按照线程优先级的高低来调度线程。此外, JVM 所在的操作系统可能会忽略甚至主动来修改我们对线程的优先级配置,且如果线程的优先级设置不当,甚至有可能导致线程永远无法得到运行,即产生线程饥饿

如果在线程 A 中创建了线程 B,那么线程 B 就称为线程 A 的子线程,线程 A 就称为线程 B 的父线程。由于 Java 虚拟机创建的 main 线程(主线程)负责执行 Java 程序的入口方法 main() 方法,因此 main 方法中创建的线程都是 main 线程的子线程或间接子线程。此外,一个线程是否是守护线程默认与其父线程相同,线程优先级的默认值也与其父线程的优先级相同。需要注意的是,虽然线程间具有这类父子关系,但是它们并不会相互影响对方的生命周期,一方线程生命周期的结束(不管是正常结束还是异常停止)并不影响另一个线程继续运行

Thread 类的常用方法包括以下几个

ble data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">

Thread.suspend()Thread.resume() 是 Thread 类提供的用于暂停和唤醒线程的方法,用于在某些运行条件不满足的时候暂停执行任务,后续在运行条件满足的时候唤醒线程继续执行任务,但现在均已废弃。由于这两个方法已经不建议使用了,所以我们可以通过其它方式来实现相同的功能:先通过设置一个标志位来标记任务是否需要暂停执行,线程在每次执行比较耗时的操作前都先检查下标记位,如果需要暂停,则让执行线程调用 Obejct.await() 等类似方法来暂停执行,直到其它线程更新了标记位并通过 Object.notify() 等类似方法来唤醒线程

action 对象用于表示需要循环执行的任务,其每次在开始新一次的耗时任务之前(线程休眠三百毫秒),都会检查下标记位判断当前是否需要执行任务,如果不需要的话线程就会暂停,即任务只有在 suspended 为 false 的时候才会被执行

/**
 * 作者:leavesC
 * 时间:2020/8/9 22:22
 * 描述:
 * GitHub:https://github.com/leavesC
 */
private val pauseControl = PauseControl()

fun main() {
    val action = Runnable {
        println("working.....")
        Thread.sleep(300)
    }
    val thread = object : Thread() {
        override fun run() {
            while (true) {
                pauseControl.pauseIfNecessary(action)
            }
        }
    }
    thread.start()
    requestPause()
}

private fun requestPause() {
    while (true) {
        Thread.sleep(1000)
        pauseControl.requestPause()
        println("主动暂停,询问是否需要暂停执行...")
        if (Random.nextInt(1, 20) > 14) {
            pauseControl.requestPause()
            println("暂停执行!!!")

        } else {
            pauseControl.resume()
            println("继续执行!!!")
        }
    }
}

class PauseControl {

    private val lock = ReentrantLock()

    private val condition = lock.newCondition()

    @Volatile
    private var suspended = false

    //请求暂停
    fun requestPause() {
        suspended = true
    }

    //恢复执行
    fun resume() {
        lock.lock()
        try {
            suspended = false
            condition.signalAll()
        } finally {
            lock.unlock()
        }
    }

    //runnable 仅在 suspended 不为 true 的时候才执行,否则将一直等待
    fun pauseIfNecessary(runnable: Runnable) {
        lock.lock()
        try {
            while (suspended) {
                condition.await()
            }
            runnable.run()
        } finally {
            lock.unlock()
        }
    }·

}

运行结果可能是这样的:

working.....
working.....
working.....
working.....
主动暂停,询问是否需要暂停执行...
暂停执行!!!
主动暂停,询问是否需要暂停执行...
继续执行!!!
working.....
working.....
working.....
working.....
主动暂停,询问是否需要暂停执行...
继续执行!!!
working.....
working.....
working.....
...

4.2、线程的状态

线程在它的整个生命周期内会先后处于不同状态,也可能会在多个状态间来回切换。对于给定的线程实例,可以使用 Thread.getState() 方法获取线程的状态,该方法返回 Thread.State 枚举类型值,用于标明在调用该方法时线程所处的当前状态,返回值包含以下几种可能:

ble data-draft-node="block" data-draft-type="table" data-size="normal" data-row-style="normal">

可以用更加通俗的语言来描述线程的生命周期:

  1. 新建状态-NEW
    使用 new 关键字建立一个线程对象后,线程就处于新建状态,一个已创建但还未启动的线程就处于此状态。线程会保持这个状态直到被调用 Thread.start() 方法
  2. 就绪状态-RUNNABLE
    当线程对象调用了 start() 方法之后,线程就会进入就绪状态。该状态可以看成一个复合状态,它包含两个子状态:「READY」「RUNNING」。前者表示线程处于就绪队列中,当被线程调度器选中调度后就可以正式运行,变为 RUNNING 状态。后者表示线程正处于运行中,其 run() 方法对应的指令正在由处理器执行。当执行线程的 yiedId() 方法时,其状态可能会由 RUNNING 切换为 READY
  3. 阻塞状态-BLOCKED
    当线程发起一个阻塞式的 IO 操作或者是在申请一个由其它线程持有的独占资源时,该线程就会处于此状态。处理 BLOCKED 状态的线程不会占用 CPU 资源。当其目标行为或者是目标资源被满足后,就可以切换为 RUNNABLE 状态
  4. 无限期等待状态-WAITING
    当线程的运行需要满足某些执行条件而当前并不满足时,通常就会通过让该线程主动调用 Object.wait()Thread.join() 等类似方法将线程切换为 WAITING 状态。当前状态是 WAITING 的线程处于暂停运行的状态,需要外部其它线程通过 Object.notify() 等方法来主动唤醒该线程
  5. 限期等待状态-TIMED_WAITING
    TIMED_WAITING 状态和 WAITING 状态类似。区别在于 TIMED_WAITING 状态并非是线程本身完全无限制地进行等待,其等待行为带有指定时间范围的限制,当在指定时间内没有完成该线程所期望的特定操作时,该线程就会转为 RUNNABLE 状态。可以通过 Object.wait(long) 方法来使线程切换为 TIMED_WAITING 状态
  6. 终止状态-TERMINATED
    当一个线程完成自身任务或者由于其它原因被迫终止时,线程就会切换到终止状态,至此线程的整个生命周期就结束了

由于一个线程在其整个生命周期内只能被启动一次,所以线程也只会处于一次 NEW 状态和一次 TERMINATED 状态。对于一个多线程系统来说,最理想的情况就是所有已启动且未结束的线程能一直处于 RUNNING 状态,但这是不可能实现的。在现实场景下线程会在多个状态间来回切换,且线程从 RUNNABLE 状态转换为 BLOCKED、WAITING 和 TIMED_WAITING 这几个状态中的任何一个时都意味着发生了线程上下文切换

60a786c7bb576cb8dfcbb5df24995f49.png

4.3、线程组

线程组(ThreadGroup)用来表示一组相关联的线程,它是 Thread 类包含的一个内部属性,可以通过 Thread.getThreadGroup()来获取该值。线程与线程组之间的关系类似于文件系统中文件与文件夹之间的关系,一个线程组可以包含多个线程以及其它线程组

如果在创建线程时没有显示指定线程所属的线程组的话,在默认情况下线程就被归类于其父线程所属的线程组下。从 Thread 类的以下方法就可以看出来:

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        ····

        Thread parent = currentThread();
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                //默认也是返回 Thread.currentThread().getThreadGroup()
                g = security.getThreadGroup();
            }

            /* If the security doesn't have a strong opinion of the matter
               use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }
        ····
    
    }

Java 虚拟机在创建 main 线程(所有线程的父线程)的时候会为其自动指定一个线程组,因此任何一个线程都有一个线程组与之相关联。且一个线程组的父线程组默认是在声明该线程组时所在线程的线程组,但并非所有线程组均有父线程组,最顶层线程组就不包含父线程组

/**
 * 作者:leavesC
 * 时间:2020/8/12 22:10
 * 描述:
 * GitHub:https://github.com/leavesC
 */
fun main() {
    val mainThreadGroup = Thread.currentThread().threadGroup
    println(mainThreadGroup)
    println("mainThreadGroup.parent: " + mainThreadGroup.parent)
    println("mainThreadGroup.parent.parent: " + mainThreadGroup.parent.parent)
    val thread = Thread()
    println(thread.threadGroup)
    val thread2 = Thread(ThreadGroup("otherThreadGroup"), "thread")
    println(thread2.threadGroup)
//    java.lang.ThreadGroup[name=main,maxpri=10]
//    mainThreadGroup.parent: java.lang.ThreadGroup[name=system,maxpri=10]
//    mainThreadGroup.parent.parent: null
//    java.lang.ThreadGroup[name=main,maxpri=10]
//    java.lang.ThreadGroup[name=otherThreadGroup,maxpri=10]
}

ThreadGroup 本身存在设计缺陷问题,目前的使用场景有限,日常开发中可以无需理会

4.4、线程异常捕获

在很多时候,我们会通过创建一个线程池来执行任务,而当某个任务由于抛出异常导致其执行线程异常终止时,我们就需要对这种异常情况进行上报以便后续分析。要实现这个效果,就需要能够收到线程被异常终止时的事件通知,这就需要用到 Thread.setUncaughtExceptionHandler(UncaughtExceptionHandler) 方法

通过该方法我们可以在异常发生时且线程被停止前获取到相应的 Thread 对象和 Throwable 实例

/**
 * 作者:leavesC
 * 时间:2020/8/12 22:14
 * 描述:
 * GitHub:https://github.com/leavesC
 */
fun main() {
    val runnable = Runnable {
        for (i in 4 downTo 0) {
            println(100 % i)
            Thread.sleep(100)
        }
    }
    val thread = Thread(runnable, "otherName")
    thread.setUncaughtExceptionHandler { t, e ->
        println("threadName: " + t.name)
        println("exc: $e")
    }
    thread.start()
}

0
1
4
2
4
0
0
1
0
0
threadName: otherName
exc: java.lang.ArithmeticException: / by zero

ThreadGroup 本身也实现了 UncaughtExceptionHandler 接口,所以如果 Thread 对象不包含关联的 UncaughtExceptionHandler 实例的话,则会将异常交由 ThreadGroup 来进行处理

从 Thread 类的以下逻辑就可以看出来

public class Thread implements Runnable {

    private ThreadGroup group;

    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

    private void dispatchUncaughtException(Throwable e) {
        getUncaughtExceptionHandler().uncaughtException(this, e);
    }

    public UncaughtExceptionHandler getUncaughtExceptionHandler() {
        return uncaughtExceptionHandler != null ?
            uncaughtExceptionHandler : group;
    }
    
}

ThreadGroup 默认情况下会将异常交由其父线程组进行处理,而对于不包含父线程组的线程组对象(顶层线程组),则会将异常交由 Thread 类的 defaultUncaughtExceptionHandler 进行处理。所以,我们可以通过 Thread 的静态方法 setDefaultUncaughtExceptionHandler 方法来为程序设置一个全局的默认异常处理器

public class ThreadGroup implements Thread.UncaughtExceptionHandler {

    public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            //当有父线程组时,则将异常交由父线程组来处理
            parent.uncaughtException(t, e);
        } else {
            //当父线程组不存在时,则尝试将异常交由 DefaultUncaughtExceptionHandler 来处理
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                System.err.print("Exception in thread ""
                                 + t.getName() + "" ");
                e.printStackTrace(System.err);
            }
        }
    }
    
}

public class Thread implements Runnable {

    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;
    
    public static UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(){
        return defaultUncaughtExceptionHandler;
    }
    
    //设置全局默认的异常处理器
    public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(
                new RuntimePermission("setDefaultUncaughtExceptionHandler")
                    );
        }

         defaultUncaughtExceptionHandler = eh;
     }
    
}

所以,当线程由于异常而终止时,UncaughtExceptionHandler 实例的选择优先级从高到低分别是:

  1. Thread.uncaughtExceptionHandler
  2. ThreadGroup.uncaughtExceptionHandler
  3. Thread.defaultUncaughtExceptionHandler

4.5、线程工厂

在项目中先后需要使用到多个线程可以说是一个普遍的需求,而如果每次均简单的通过 new Thread() 来创建线程的话,在出现问题时就很难定位问题所在。所以 Java 标准库也提供了创建线程的工厂方法,即 ThreadFactory 接口

public interface ThreadFactory {

    Thread newThread(Runnable r);
    
}

ThreadFactory 提供了将要执行的任务 Runnable 与要创建的 Thread 相关联的方法,即我们可以通过 ThreadFactory 来标明 Thread 要执行的具体任务、为 Thread 设置一个有具体含义的名字、设置 Thread 的运行优先级等

例如,Executors 内部就包含了一个 DefaultThreadFactory,通过 threadNumber 自增的方式为每一个创建的线程设置了特定的线程名、确保线程是用户线程、确保线程的优先级为正常级别

static class DefaultThreadFactory implements ThreadFactory {
        private static final AtomicInteger poolNumber = new AtomicInteger(1);
        private final ThreadGroup group;
        private final AtomicInteger threadNumber = new AtomicInteger(1);
        private final String namePrefix;

        DefaultThreadFactory() {
            SecurityManager s = System.getSecurityManager();
            group = (s != null) ? s.getThreadGroup() :
                                  Thread.currentThread().getThreadGroup();
            namePrefix = "pool-" +
                          poolNumber.getAndIncrement() +
                         "-thread-";
        }

        public Thread newThread(Runnable r) {
            Thread t = new Thread(group, r,
                                  namePrefix + threadNumber.getAndIncrement(),
                                  0);
            if (t.isDaemon())
                t.setDaemon(false);
            if (t.getPriority() != Thread.NORM_PRIORITY)
                t.setPriority(Thread.NORM_PRIORITY);
            return t;
        }
    }

而对于我们项目自己定义的线程池,使用 ThreadFactory 的一个比较有意义的用处是:为线程设置关联的 UncaughtExceptionHandler,这在提高系统的健壮性方面是很有好处的

4.6、主线程

当 Java 虚拟机启动时,会立即启动一个 main 线程负责执行 Java 程序的入口 main 方法,因为它是程序开始时执行的线程,所以这个线程通常称为程序的主线程(main thread), main 方法中直接或间接创建的线程都是 main 线程的子线程或间接子线程

可以在 main 函数内通过调用 Thread.currentThread() 方法获得对主线程的一个引用,该方法是 Thread 类的公有静态方法,返回对调用它的线程的引用,方法原型如下所示:

public static native Thread currentThread();

fun main() {
    val mainThread = Thread.currentThread()
    println("currentThread name: " + mainThread.name)
    mainThread.name = "otherName"
    println("currentThread name: " + mainThread.name)
    println(mainThread)
//    currentThread name: main
//    currentThread name: otherName
//    Thread[otherName,5,main]
}

mainThread 的打印结果将依次显示「线程的名称、优先级以及线程所属线程组的名称」。默认情况下,主线程的名称是 main,优先级是 5,主线程所属线程组的名称也是 main

五、多线程编程的挑战

实现多线程编程不是简单地声明多个 Thread 对象并启动就可以的了,在现实场景中,多个线程间往往是需要完成数据交换和行为交互等各种复杂操作的,而不是简单地“各行其是”。相比单线程,使用多线程会带来许多在单线程下不存在或者根本不用考虑的问题

5.1、竞态

先来看一个简单的例子。假设存在一个商店 Shop,其初始商店数量为零。存在四十个生产者 Producer 为其生产商品,每个 Producer 会各自为商店提供一个商品。那么,理论上当所有 Producer 生产完毕后,Shop 的商品数量 goodsCount 应该是四十

可运行以下代码后你会发现实际数量大概率是会少于四十的

/**
 * 作者:leavesC
 * 时间:2020/8/3 22:18
 * 描述:
 * GitHub:https://github.com/leavesC
 */
class Shop(var goodsCount: Int) {

    fun produce() {
        goodsCount++
    }

}

class Producer(private val shop: Shop) : Thread() {

    override fun run() {
        sleep(100)
        if (shop.goodsCount < 40) {
            shop.produce()
        }
        println("over")
    }

}

fun main() {
    val shop = Shop(0)
    val threads = mutableListOf<Thread>()
    for (i in 1..40) {
        threads.add(Producer(shop))
    }
    threads.forEach {
        it.start()
    }
    //保证所有 Producer Thread 都执行完毕会再执行之后的语句
    threads.forEach {
        it.join()
    }
    println("shop.goodsCount: " + shop.goodsCount)
}

以上代码就使用到了多个线程,单个 Producer 线程的行为逻辑是独立的,而多个 Producer 线程的行为逻辑对于 Shop 来说是互相交错且先后顺序不确定的,这就有可能导致一种情况:两个 Producer 同时判断到当前商品数量是十,然后同时为商店生产第十一件商品(「shop.goodsCount++」),最终就导致某个 Producer 生产的第十一号商品被另一个 Producer 覆盖了,两个 Producer 只生产了一件商品,即数据「更新无效/更新丢失」

shop.goodsCount++ 这条语句虽然看起来像是一个不可分割的操作(原子操作),但它实际上相当于如下伪代码所表示的三个指令的组合:

load(shop.goodsCount , r1) //指令1,将变量 shop.goodsCount 的值从内存读到寄存器 r1
increment(r1) //指令2,将寄存器 r1 的值加1
store(shop.goodsCount , r1) //指令3,将寄存器 r1 的内容写入变量 shop.goodsCount 所对应的内存空间

多个 Producer 线程可能会同时各自执行上述指令。例如,假设当前 goodsCount 是十,Producer1 和 Producer2 同时执行到指令1,两个线程将 goodsCount 读到各自处理器的寄存器上,即每个线程会各自拥有一份副本数据,然后对各自寄存器的值进行自增加一的操作,当执行到指令三时,由于 goodsCount 所在的内存空间是特定的,所以两个 Producer 线程对内存空间上 goodsCount 的值的回传会存在相互覆盖的情况。即原本最终结果应该是「递增加二」的行为最终却只有「递增加一」

以上是多线程编程中经常会遇到的一个现象,即竞态。竞态是指计算的正确性依赖于相对时间顺序或者线程的交错。竞态并不一定就会导致计算结果不正确,它只是不排除计算结果时而正确时而错误的可能


从上述代码中可以总结出竞态的两种模式:「read-modify-write(读-改-写)」「check-then-act(检测后行动)」

read-modify-write 的步骤即:读取一个共享变量的值,然后根据该值进行一些计算、接着根据计算结果更新共享变量的值。例如,上述代码中的 goodsCount++ 就是这种模式,相当于如下伪代码所表示的三个指令的组合

load(shop.goodsCount , r1) //指令1,将变量 shop.goodsCount 的值从内存读到寄存器 r1
increment(r1) //指令2,将寄存器 r1 的值加1
store(shop.goodsCount , r1) //指令3,将寄存器 r1 的内容写入变量 shop.goodsCount 所对应的内存空间

线程 A 在执行完指令1,开始执行或者正在执行指令2时,线程 B 可能已经执行完了指令3,这使得线程 A 当前持有的共享变量 shop.goodsCount 是旧值,当线程 A 执行完指令3时,这就使得线程 B 对共享变量的更新被覆盖了,即造成了更新丢失


check-then-act 的步骤即:读取一个共享变量的值,根据该变量的值决定下一步的动作是什么。例如,以下代码就是这种模式

if (shop.goodsCount < 40) { //操作1
    shop.produce() //操作2
}

线程 A 在执行完操作1,开始执行操作2之前,线程 B 可能已经更新了共享变量 shop.goodsCount 的值导致 if 语句中的条件变为不成立,可此时线程 A 依然会执行操作2,这是因为线程 A 此时并不知道共享变量已经被更新且导致运行条件不成立了

❝ 从上述分析中我们可以总结出竞态产生的一般条件。设 Q1 和 Q2 是并发访问共享变量 V 的两个操作,这两个操作并非都是读操作。如果一个线程在执行 Q1 期间另外一个线程同时执行 Q2,那么无论 Q2 是操作还是写操作都会导致竞态。从这个角度来看,竞态可以看做是由于访问(读取、更新)同一组共享变量的多个线程所执行的操作被相互交错而导致的。而上述代码中遇到的 「更新丢失」「读到脏数据」问题就是由于竞态的存在而导致的
需要注意的是,竞态的产生前提是涉及到了多个线程和共享变量。如果系统仅包含单个线程,或者不涉及共享变量,那么就不会产生竞态。对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程访问的是各自的那一份局部变量,因此局部变量的使用不会导致竞态

5.2、线程安全性

如果一个类在单线程环境下能够正常运行,并且在多线程环境下也不需要考虑运行时环境下的调度和交替执行,使用方也不必为其做多任何操作也能正常运行,那么我们就说该类是线程安全的,即这个类具有线程安全性。反之,如果一个类在单线程环境下正常运行而在多线程环境下无法正常运行,那么这个类就是非线程安全的。所以,只有非线程安全的类才会导致竞态

如果一个类不是线程安全的,我们就说它在多线程环境下存在多线程安全问题。以上定义也适用于多个线程间的共享数据

多线程安全问题概括来说表现为三个方面:原子性、可见性、有序性

1、原子性

原子的字面意思即「不可分割」。对于涉及共享变量访问的操作,若该操作从其执行线程以外的其它任意线程来看是不可分割的,那么该操作就是「原子操作」,相应的就称该操作具有「原子性」。所谓“不可分割”,是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经完成,要么尚未发生,其它线程不会看到该操作执行了一部分的中间效果

例如,假设存在一个共享的全局变量 Shop 对象,其存在一个 update() 方法。当线程 A 执行 update() 方法时,在线程 A 执行完语句1之后而未执行语句2之前,此时线程 B 就会看到 goodsCount 已递增加一而 clerk 还未递增加一的这样一个中间效果。此时,我们就说 update() 方法作为一个整体不具备原子性

class Shop(var goodsCount: Int, var clerk: Int) {

    fun update() {
        goodsCount++ //语句1
        clerk++ //语句2
    }

}

理解原子操作这个概念还需要注意以下两点:

  • 原子操作是针对共享变量的操作而言的,仅涉及局部变量访问的操作无所谓是否是原子性,或者可以直接将其看作成原子操作
  • 原子操作是从该操作的执行线程以外的其它线程的视角来描述的,也就是说它只在多线程环境下才有意义,所以可以将单线程环境下的所有操作均当做原子操作

总的来说,Java 中有两种方式来提供原子性:

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

Java 语言规范规定了:「在 Java 语言中,64 位以外的任何类型的变量的读写操作都是原子操作」。而对于 long 和 double 等 64 位的数据类型的读写操作并不强制规定 Java 虚拟机必须保证其原子性,可以由 Java 虚拟机自己选择是否要实现。因此在多线程并发读写同一 long/double 型共享变量的情况下,一个线程可能会读取到其它线程更新该变量的“中间结果”。而之所以会有中间结果,是因为对于 64 位的存储空间的写操作,虚拟机可能会将其拆解为两个步骤来实现,比如先写低 32 位再写高 32 位,从而导致外部线程读取到一个中间结果值。但这个问题也不需要特意关注,因为目前商用 Java 虚拟机几乎都选择将 64 位数据的读写操作实现为原子操作。此外,Java 语言规范也特别规定了用 volatile 关键字修饰的 long/double 型变量的读写操作具有原子性。

需要注意的是, volatile 关键字仅能保障变量读写操作的原子性,但并不能保障其它操作(例如 read-modify-write 、check-then-act)的原子性

2、可见性

在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的其它线程可能无法立即读取到这个更新的结果,甚至永远也无法读取到,这体现了多线程安全性问题中的一个:可见性。可见性是指一个线程对共享变量的更新结果对于其它读取相应共享变量的线程而言是否可见的问题。多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据,而这往往会导致我们的程序出现意想不到的问题

会存在可见性问题。一方面是由于 JIT 编译器可能出于提高代码运行效率考虑而自动对代码进行一些“优化”,使得共享变量更新失效。一方面是由于处理器并不是直接对主内存中的共享变量进行访问,而是会各自在自己的高速缓存上保留着对共享变量的一份副本,处理器直接访问的是副本数据,对副本数据的修改需要同步回主内存后才可以对其它处理器可见。所以一个处理器对共享变量的更新结果并不一定能立即同步到其它处理器上,这就导致了可见性问题的出现

对于同一个共享变量而言,一个线程更新了该变量的值之后,如果其它线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值。如果读取这个共享变量的线程在读取并使用该变量的时候其它线程无法更新该变量的值,那么该线程读取到的值就被称为该变量的最新值。可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而并不意味着线程能够读取到相应变量的最新值

可见性问题是由于使用了多线程所导致的,它与当前是单核处理器还是多核处理器无关。在单核处理器下,多线程并发是通过时间片分配技术来实现的,此时虽然多个线程都是运行在同个处理器上,但是由于在上下文切换的时候,一个线程对共享变量的修改会被当做其上下文信息保存起来,这也会导致另外一个线程无法立即读取到该线程对共享变量的修改

3、有序性

在说有序性之前,需要先介绍下「重排序」

顺序结构是结构化编程中的一种基本结构,它表示我们希望某个操作必须先于另外一个操作得以执行,但是在多核处理器环境下,这种操作执行顺序可能是没有保障的。编译器和处理器可以在保证不影响单线程执行结果的前提下,对源代码的指令进行重新排序执行,处理器可能不是完全依照程序的目标代码所指定的顺序来执行指令。另外,一个处理器上执行的多个操作,从其它处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫做重排序。重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题

重排序分为以下几种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

有序性指的就是在什么情况下一个处理器上运行的线程所执行的内存访问顺序在另外一个处理器上运行的其它线程看来是乱序的。所谓乱序,是指内存访问操作的顺序看起来是发生了变化

5.3、不安全的线程安全类

上文有提到如何定义一个类是否是线程安全的,Java 也提供了很多被称为线程安全的类,例如 java.util.Vector。Vector 类内部的 add()removeAt()size() 等很多方法都使用了 synchronize 进行修饰,保证了在多线程环境下的安全性,但这种同步保障也无法阻止开发者在逻辑层面上写出不安全的代码

例如,对于以下代码。即使 add()removeAt() 两个方法由于 Vector 类内部的同步处理,保障了两个方法一定是串行执行的,但由于方法调用端缺少了额外的同步处理,导致调用端可能会读取到一个过时的 vector.size 值,最终导致索引越界抛出 ArrayIndexOutOfBoundsException

所以说,线程安全类可能只是保障了其自身单次操作行为的线程安全性,使得我们在调用的时候不需要进行额外的同步保障。但对于使用方的一些特定顺序的连续调用,就可能还是需要在外部实现额外的同步手段来保证调用的正确性,否则就有可能用线程安全类写出不安全的代码

/**
 * 作者:leavesC
 * 时间:2020/8/26 22:52
 * 描述:
 * GitHub:https://github.com/leavesC
 */
private val vector = Vector<Int>()

private val threadNum = 5

fun main() {
    val addThreadList = mutableListOf<Thread>()
    for (i in 1..threadNum) {
        val thread = object : Thread() {
            override fun run() {
                for (item in 1..10) {
                    vector.add(item)
                }
            }
        }
        addThreadList.add(thread)
    }
    val printThreadList = mutableListOf<Thread>()
    for (i in 1..threadNum) {
        val thread = object : Thread() {
            override fun run() {
                for (index in 1..vector.size) {
                    vector.removeAt(i)
                }
            }
        }
        printThreadList.add(thread)
    }
    addThreadList.forEach {
        it.start()
    }
    printThreadList.forEach {
        it.start()
    }
    addThreadList.forEach {
        it.join()
    }
    printThreadList.forEach {
        it.join()
    }
}

5.4、上下文切换

并发的实现和是否拥有多个处理器无关,即使只有单个处理器也能够通过处理器「时间片分配」技术来实现并发。操作系统通过给每个线程分配一小段占有处理器使用权的时间来供其运行,然后在每个线程的运行时间结束后又快速切换到下一个线程来运行,多个线程以这种断断续续的方式来实现并发并完成各自的任务。一个线程被剥夺处理器的使用权并暂停运行的过程就被称为切出,被线程调度器选中来占用处理器并运行的过程就被称为切入

操作系统会分出一个个时间片,每个线程每次运行会分配到若干个时间片,时间片决定了一个线程可以连续占用处理器运行的时间长度,一般是只有几十毫秒,单处理器上的多线程就是通过这种「时间片分配」的方式来实现并发。当一个进程中的一个线程由于其时间片用完或者由于其自身的原因被迫或者主动暂停其运行时,另外一个线程(当前进程中的线程或者其它进程中的线程)就可以被线程调度器选中来占用处理器并开始运行。这种一个线程被剥夺处理器的使用权并暂停运行,另外一个线程被赋予处理器的使用权并开始运行的过程就称为线程上下文切换

线程上下文切换是「处理器个数远小于系统所需要支持的并发线程数」的现实场景下的必然产物。这也意味着在线程切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,即需要保存切入和切出那一刻相应线程所执行的指令进行到什么哪一步了。这个进度信息就被称为上下文

线程的生命周期状态在 RUNNABLE 状态与非 RUNNABLE 状态之间切换的过程就是上下文切换的过程。当被暂停的线程被操作系统选中获得继续运行的机会时,操作系统会恢复之前为该线程保存的上下文,以便其在此基础上继续完成其任务

按照导致上下文切换的因素来划分,可以将上下文切换划分为「自发性上下文切换」「非自发性上下文切换」

  • 自发性上下文切换。这种情况是线程由于其自身原因导致的切出。从 Java 平台的角度来看,一个线程在其运行过程中执行了以下任何一个操作都会引起自发性上下文切换
    • Thread.sleep()
    • Object.wait()
    • Thread.yieid()
    • Thread.join()
    • LockSupport.park()
    • I/O 操作
    • 等待被其它线程持有的锁
  • 非自发性上下文切换。指线程由于线程调度器的原因被迫切出。这种情况往往是由于被切出的线程的时间片用完,或者有一个比被切出线程更高优先级的线程需要运行。此外,Java 虚拟机的垃圾回收动作也可能导致非自发性上下文切换,这是因为垃圾回收器在执行 GC 的过程中可能需要暂停所有应用线程才能完成

系统在一段时间内产生的上下文切换次数越多,由此导致的处理器资源消耗也就越多,相应的这段时间内真正能够用于执行目标代码的处理器资源就越少,因此我们也需要考虑尽量减少上下文切换的次数,这在后续文章中会介绍

六、线程调度

线程调度是指操作系统为线程分配处理器使用权的过程。主要的调度方式有两种:

  • 协同式线程调度。在这种策略下,线程的执行时机由线程本身来决定,线程通过主动通知系统切换到另一个线程的方式来让出处理器的使用权。该策略的优点是实现简单,可以通过精准控制线程的执行顺序来避免线程安全性问题。缺点是可能会由于单个线程的代码缺陷问题导致无法切换到下一个线程,最终导致进程被阻塞
  • 抢占式线程调度。这也是 Java 平台使用的线程调度策略。在这种策略下,由操作系统来决定当前处理器时间片交由哪个线程来使用,线程无法决定具体的运行时机和运行顺序。虽然我们可以通过 Thread.yieid() 方法来让出时间片,但是无法主动抢夺时间片,且虽然 Thread 类也提供了设置线程优先级的方法,但线程的具体执行顺序还是取决于其运行系统。该策略的优点是不会由于一个线程的问题导致整个进程被阻塞,且提高了并发性。缺点是实现较为复杂,且会带来多线程安全性问题

七、资源争用和资源调度

7.1、资源争用

一次只能被一个线程占用的资源称为排他性资源。常见的排他性资源包括锁、处理器、文件等。由于资源的稀缺性或者资源本身的特性,我们往往需要在多个线程间共享同一个排他性资源。当一个线程占用一个排他性资源而未释放其对资源的所有权时,存在其它线程同时试图访问该资源的现象就被称为资源争用,简称争用。显然,争用是在并发环境下产生的一种现象,同时试图访问一个已经被其它线程占用的排他性资源的线程数量越多,争用的程度就越高,反之争用的程度就越低。相应的争用就被称为高争用和低争用

同一时间段内,处于运行状态的线程数量越多,我们就称并发的程度越高,简称高并发。虽然高并发加大了争用的可能性,但是高并发未必就意味着高争用,因为线程并非就是一定会在某个时刻来一起申请资源,资源的申请操作对于多个线程来说可能是交错开的,或者每个线程持有排他性资源的时间很短。多线程编程的理想情况就是高并发、低争用

7.2、资源调度

当多个线程同时申请同一个排他性资源,申请资源失败的线程往往是会存入一个等待队列中,当后续资源被其持有线程释放时,如果刚好有一个活跃线程来申请资源,此时选择哪一个线程来获取资源的独占权就是一个资源调度的过程,资源调度策略的一个重要属性就是能否「保证公平性」。所谓公平性,是指资源的申请者是否严格按照申请顺序而被授予资源的独占权。如果资源的任何一个先申请者总是能够被比任何一个后申请者先获得资源的独占权,那么该策略就被称为「公平调度策略」。如果资源的后申请者可能比先申请者先获得资源的独占权,那么该策略就被称为「非公平调度策略」。注意,非公平调度策略往往只是不保证资源调度的公平性,即它只是允许不公平的资源调度现象,而不是表示它刻意造就不公平的资源调度

公平的资源调度策略不允许插队现象的出现,资源申请者总是按照先来后到的顺序获得资源的独占权。如果当前等待队列为空,则来申请资源的线程可以直接获得资源的独占权。如果等待队列不为空,那么每个新到来的线程就被插入等待队列的队尾。公平的资源调度策略的优点是:每个资源申请者从开始申请资源到获得相应资源的独占权所需时间的偏差会比较小,即每个申请者成功申请到资源所需的时间基本相同,且可以避免出现线程饥饿现象。缺点是吞吐率较低,为了保证 FIFO 加大了发生线程上下文切换的可能性

非公平的资源调度策略则允许插队现象。新到来的线程会直接尝试申请资源,只有当申请失败时才会将线程插入等待队列的队尾。假设两种多个线程一起竞争同一个排他性资源的场景:

  1. 当资源被释放时,如果刚好有一个活跃线程来申请资源,该线程就可以直接抢占到资源,而无需去唤醒等待队列中的线程。这种场景相对公平调度策略就少了「将新到来的线程暂停」「将等待队列队头的线程唤醒」的两个操作,而资源也一样有被得到使用
  2. 即使等待队列中的某个线程已经被唤醒来试图抢占资源的独占权,如果新到来的活跃线程占用资源的时间不长的话,那么就有可能在被唤醒的线程开始申请资源之前,新到来的活跃线程已经释放了对资源的独占权,从而不妨碍被唤醒的线程申请资源。这种场景也一样避免了「将新到来的线程暂停」这么一个操作

因此,非公平调度策略的优点主要有两点:

  1. 吞吐率一般来说会比公平调度策略高,即单位时间内它可以为更多的申请者调配资源
  2. 降低了发生上下文切换的概率

非公平调度策略的缺点主要有两点:

  1. 由于允许插队现象,极端情况下可能导致等待队列中的线程永远也无法获得其所需的资源,即出现「线程饥饿」的活性故障现象
  2. 每个资源申请者从开始申请资源到获得相应资源的独占权所需时间的偏差可能较大,即有的线程可能很快就能申请到资源,而有的线程则要经历若干次暂停与唤醒才能成功申请到资源

综上所诉,公平调度策略适用于资源被持有的时间较长或者线程申请资源的平均时间间隔较长的情形,或者要求申请资源所需的时间偏差较小的情况。总的来说使用公平调度策略的开销会比使用非公平调度策略的开销要大,因此在没有特别需求的情况下,应该默认使用非公平调度策略

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值