• 从0开始深入理解并发、线程与等待通知机制(中)


    一,深入学习 Java 的线程

            线程的状态/生命周期

            Java 中线程的状态分为 6 种:

            1. 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。

            2. 运行(RUNNABLE)Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。

            线程对象创建后,其他线程( 比如 main 线程)调用了该对象的 start() 方法。 该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready )。就绪状态的线程在获得 CPU 时间片后变为运行中状态(running )。
            3. 阻塞(BLOCKED):表示线程阻塞于锁。
            4. 等待 (WAITING) :进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
            5. 超时等待 (TIMED_WAITING) :该状态不同于 WAITING ,它可以在指定的时间后自行返回。
            6. 终止 (TERMINATED) :表示该线程已经执行完毕。
            状态之间的变迁如下图所示
             掌握这些状态可以让我们在进行 Java 程序调优时可以提供很大的帮助。

             其他的线程相关方法

            yield()方法:使当前线程让出 CPU 占有权,但让出的时间是不可设定的。也 不会释放锁资源。同时执行 yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。(运行态--->就绪态)

            比如,ConcurrentHashMap#initTable 方法中就使用了这个方法,

            这是因为 ConcurrentHashMap 中可能被多个线程同时初始化 table,但是其实这个时候只允许一个线程进行初始化操作,其他的线程就需要被阻塞或等待, 但是初始化操作其实很快,这里 Doug Lea 大师为了避免阻塞或者等待这些操作 引发的上下文切换等等开销,就让其他不执行初始化操作的线程干脆执行 yield()方法,以让出 CPU 执行权,让执行初始化操作的线程可以更快的执行完成。

            线程的优先级  

            在 Java 线程中,通过一个整型成员变量 priority 来控制优先级,优先级的范 围从 1~10,在线程构建的时候可以通过 setPriority(int)方法来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。

            设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。

            线程的调度 

            线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种:

            协同式线程调度(Cooperative Threads-Scheduling):

            使用协同式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,则程序就会一直阻塞。

            抢占式线程调度(Preemptive Threads-Scheduling)

            使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致整个进程阻塞」的问题出现。

            Java 线程调度就是抢占式调度,为什么?

            在 Java 中,Thread.yield()可以让出 CPU 执行时间,但是对于获取执行时间, 线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是 设置线程优先级,Java 设置了 10 个级别的程序优先级,当两个线程同时处于Ready 状态时,优先级越高的线程越容易被系统选择执行。

            线程和协程 

            为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。

            我们已经知道线程其实是操作系统层面的实体,Java 中的线程怎么和操作系统层面对应起来呢?

            任何语言实现线程主要有三种方式:

            使用内核线程实现(1:1 实现)

            使用用户线程实现(1:N 实现),

            使用用户线程加轻量级进程混合实现(N:M 实现)。

            内核线程实现 

            使用内核线程实现的方式也被称为 1 1 实现。 内核线程( Kernel-LevelThread, KLT ) 就是直接由操作系统内核( Kernel , 下称内核) 支持的线程, 这种线程由内核来完成线程切换, 内核通过操纵调度器( Scheduler ) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。
            由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一 个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
            局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户态(User Mode)和内核态( Kernel Mode )中来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。

            用户线程实现

            严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。 如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

            用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程。Java 语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如 Golang

            混合实现

            线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将内核线程与用户线程一起使用的实现方式, 被称为 NM 实现。 在这种混合实现下, 既存在用户线程, 也存在内核线程。

            用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。

            同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的数量比是不定的,是 NM 的关系。

            Java 线程的实现 

             Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的,但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 11 的线程模型。

            以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。

            所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以Java 优先级并不是特别靠谱。

            协程 (用户线程的一种实现)

            出现的原因

            随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。比如,互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不 同机器上的大量服务共同协作来实现,,也就是我们常说的微服务,这种服务细 分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服 务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短 的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服 务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服务被阻塞而出现等待。

            Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾,11 的内核线程模型是如今 Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

            另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当可观的。

            这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。

            其次,Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力,也使得 Java 引入用户线程成为了一个绕不开的话题。

            协程简介

            为什么用户线程又被称为协程呢?我们知道,内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。

            由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine) 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。

            协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 Linux上 HotSpot 的线程栈容量默认是 1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。

            协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。

            总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)

            守护线程

            Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。

            Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑。

           二,线程间的通信和协调、协作 

            很多的时候,孤零零的一个线程工作并没有什么太多用处,更多的时候,我们是很多线程一起工作,而且是这些线程间进行通信,或者配合着完成某项工作,这就离不开线程间的通信和协调、协作。

            管道输入输出流

            我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存

            设想这么一个应用场景:通过 Java 应用生成文件,然后需要将文件上传到云端,比如:

            1、页面点击导出后,后台触发导出任务,然后将 mysql 中的数据根据导出条件查询出来,生成 Excel 文件,然后将文件上传到 oss,最后发步一个下载文件的链接。

            2、和银行以及金融机构对接时,从本地某个数据源查询数据后,上报 xml 格式的数据,给到指定的 ftp 、或是 oss 的某个目录下也是类似的。
            
            我们一般的做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传到云盘,但是通过 Java 中的管道输入输出流一步到位,则可以避免写入磁盘这一步。
            
            Java 中的管道输入 / 输出流主要包括了如下 4 种具体实现:PipedOutputStream PipedInputStream PipedReader PipedWriter
    前两种面向字节,而后两种面向字符。

            join 方法  

            面试题

            现在有 T1T2T3 三个线程,你怎样保证 T2 T1 执行完后执行,T3 T2执行完后执行?

            答:用 Thread#join 方法即可,在 T3 中调用 T2.join ,在 T2 中调用 T1.join
            join()

            把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。 比如在线程 B 中调用了线程 A Join()方法,直到线程 A 执行完毕后,才会继续执行线程 B 剩下的代码。

            synchronized 内置锁 

            线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码 一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行, 那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作, 包括数据之间的共享,协同处理事情。这将会带来巨大的价值。

            Java 支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同 时访问同一个变量,会导致不可预料的结果。关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,使多个线程访问同一个变量的结果正确,它又称为内置锁机制
  • 相关阅读:
    MySQL索引优化,设计原则 及 trace 详解(思维导图)
    ElasticSearch中实际操作细节点
    Mockito使用方法(Kotlin)
    【PTHREAD】线程互斥与同步之条件变量
    【数据库】02——关系模型是什么东东
    Semaphroe + CountDown
    【数据结构】树与二叉树(十七):二叉树的基础操作:删除指定结点及其左右子树(算法DST)
    短视频创作有什么建议吗?直接上干货
    深度学习实战:基于TensorFlow与OpenCV的手语识别系统
    【计算机网络】运输层习题(谢希仁)(1)
  • 原文地址:https://blog.csdn.net/qq_67801847/article/details/133575710