Java 并发编程一文读懂线程协程守护线程

Posted 没对象的指针

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 并发编程一文读懂线程协程守护线程相关的知识,希望对你有一定的参考价值。

一文读懂线程、协程、守护线程

1. 线程的调度

Java 线程的生命周期一文中提到了就绪状态的线程在获得 CPU 时间片后变为运行中状态,否则就会在可运行状态或者阻塞状态,那么系统是如何分配线程时间片以及实现线程的调度的呢?下面我们就来讲讲线程的调度策略。

线程调度是指系统为线程分配 CPU 执行时间片 的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling)抢占式线程调度(Preemptive Threads-Scheduling)

1.1 协同式线程调度

使用协同式线程调度的多线程系统,线程的执行时间由线程本身来控制,某一线程执行完毕之后,会主动通知系统切换到另外一个线程上执行。

使用协同式线程调度的最大好处就是实现简单,而且由于线程获取执行时间和切换由自己控制,切换操作对线程自己是可知的,所以没有线程同步的问题

当然,缺点也很明显:如果一个线程出了问题,则程序就会一直阻塞!一直不通知系统进行线程切换,进程一直不让出 CPU 执行时间,严重时可能导致整个系统崩溃。

1.2 抢占式线程调度

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

Java 的线程调度就是抢占式线程调度。为什么 Java 线程调度是抢占式调度?这个我们在后面的线程的实现模型分析。

1.3 设置线程的优先级

在 Java 中,Thread.yield() 可以让出 CPU 执行时间,但是对于获取执行时间,线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是设置线程优先级,Java 通过一个整型成员变量 priority 来控制优先级,优先级的范围从 1~10,在线程构建的时候可以通过 setPriority(int) 方法来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。

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

2. 线程的实现模型和协程

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

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

任何语言实现线程主要有三种方式:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实现),使用用户线程加轻量级进程混合实现(N:M 实现)。

2.1 内核线程实现

内核线程模型即完全依赖操作系统内核提供的内核线程(Kernel-Level Thread ,KLT)来实现多线程。这种线程由操作系统内核(Kernel, 下称内核) 来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将多个线程的任务映射到各个 CPU 上去执行。每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口 - 轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型。


优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。

缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。

2.2 用户线程实现

从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如 “阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上” 这类问题解决起来将会异常困难,甚至不可能完成。

因而使用用户线程实现的程序一般都比较复杂,此处所讲的 “复杂” 与 “程序自己完成线程操作”,并不限制程序中必须编写了复杂的实现用户线程的代码,使用用户线程的程序,很多都依赖特定的线程库来完成基本的线程操作,这些复杂性都封装在线程库之中,除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,最终又都放弃使用它。

2.3 混合实现

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

用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。

在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为 N:M 的关系。许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 N:M 的线程模型实现。

2.4 Java 线程的实现

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

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

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

2.5 协程

2.5.1 出现的原因

1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择。内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。系统能容纳的线程数量也很有限。

随着互联网行业的发展,微服务架构的兴起,以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的。 但现在在每个请求本身的执行时间变得很短、服务数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

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

2.5.2 什么是协程

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

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

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

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

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

2.5.3 Java19 虚拟线程 - 协程的复苏

2022-09-20,JDK 19 发布了 GA 版本,备受瞩目的协程功能也算尘埃落地,不过,此次 GA 版本并不没有以协程来命名,而是使用了 VirtualThread(虚拟线程),并且还是 preview 预览版本。

在 JDK 19 源码中,官方直接在 java.lang 包下新增一个 VirtualThread 类来表示虚拟线程,和现有的 Thread类并驾齐驱,为了更好的区分虚拟线程和原有Thread 线程,官方又给 Thread 类赋予了一个高大上的名字:平台线程。

大家有想具体学习的可以参考:Java终于发布了"协程"–虚拟线程,原来上手这么简单!

3. 守护线程(后台线程)

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

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

一文让你读懂高并发编程的意义及其好处和注意事项

由于多核多线程的CPU的诞生,多线程、高并发的编程越来越受重视和关注。多线程可以给程序带来如下好处。
(1)充分利用CPU的资源
从上面的CPU的介绍,可以看的出来,现在市面上没有CPU的内核不使用多线程并发制
的,特别是服务器还不止一个CPU,如果还是使用单线程的技术做思路,明显就out了。因为程序的基本调度单元是线程,并且一个线程也只能在一个CPU的一个核的一个线程跑,如果你是一个i3的CPU的话,最差也是双核心4线程的运算能力;如果是一个线程的程序的话,那是要浪费3/4的CPU性能;如果设计一个多线程的程序的话,那它就可以同时在多个CPU的多个核的多个线程上跑,可以充分地利用CPU,减少CPU的空闲时间,发挥它的运算能力,提高并发量。

就像我们平时坐地铁一样,很多人坐长线地铁的时候都在认真看书,而不是为了坐地铁而坐
地铁,到家了再去看书,这样你的时间就相当于有了两倍。这就是为什么有些人时间很充裕,而有些人老是说没时间的一个原因,工作也是这样,有的时候可以并发地去做几件事情,充分利用我们的时间,CPU也是一样,也要充分利用。
(2)加快响应用户的时间
比如我们经常用的迅雷下载,都喜欢多开几个线程去下载,谁都不愿意用一个线程去下载,
为什么呢?答案很简单,就是多个线程下载快啊。
我们在做程序开发的时候更应该如此,特别是我们做互联网项目,网页的响应时间若提升ls,
如果流量大的话,就能增加不少转换量。做过高性能web前端调优的都知道,要将静态资源地址用两三个子域名去加载,为什么?因为每多一个子域名,浏览器在加载你的页面的时候就会多开几个线程去加载你的页面资源,提升网站的响应速度。多线程,高并发真的是无处不在。

(3)可以使你的代码模块化,异步化,简单化
例如我们在做Android程序开发的时候,主线程的UI展示部分是一块主代码程序部分,但是UI上的按钮用相应事件的处理程序就可以做个单独的模块程序拿出来。这样既增加了异步的操作,又使程序模块化,清晰化和简单化。时下最流行的异步程序处理机制,正是多线程、并发程序最好的应用例子。
相信多线程应用开发的好处还有很多,大家在日后的代码编写过程中可以慢慢体会它的魅力
所在。
接下来谈谈高并发、多线程要注意的问题:
(1)线程之间的安全性
从前面的章节中我们都知道,在同一个进程里面的多线程是资源共享的,也就是都可以访问
同一个内存地址当中的一个变量。例如:若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的:若有多个线程同时执行写操作,一般都需要考
虑线程同步,否则就可能影响线程安全。
这个我们会在后面章节中详细的说明。
(2)线程之间的死循环过程
为了解决线程之间的安全性引入了Java的锁机制,而一不小心就会产生Java线程死锁的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。
假如线程A获得了刀,而线程B获得了叉。线程A就会进入阻塞状态来等待获得叉,而线
程B则阻塞来等待线程A所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生。

(3)线程太多了会将服务器资源耗尽形成死机当机
线程数太多有可能造成系统创建大量线程而导致消耗完系统内存以及CPU的“过渡切换”
造成系统的死机,那么我们该如何解决这类问题呢?
某些系统资源是有限的,如文件描述符。多线程程序可能耗尽资源,因为每个线程都可能希
望有一个这样的资源。如果线程数相当大,或者某个资源的侯选线程数远远超过了可用的资源数,则最好使用。一个最好的示例是数据库连接池。只要线程需要使用一个数据库连接,它就从池中取出一个,使用以后再将它返回池中。资源池也称为。这里先有一个概念,后面会逐渐讲到线程池的概念。
多线程应用开发的注意事项很多,希望大家在日后的工作中可以慢慢体会它的危险所在。

 

以上是关于Java 并发编程一文读懂线程协程守护线程的主要内容,如果未能解决你的问题,请参考以下文章

Java 面试宝典!并发编程 71 道题及答案全送上!

Java 并发一文读懂

一文让你读懂高并发编程的意义及其好处和注意事项

Java并发编程:守护线程与线程阻塞的四种情况

Java并发编程:守护线程与线程阻塞的四种情况

Java——并发编程