引言
在并发编程的舞台上,如果说锁是编导,那么线程无疑就是真正的主角。线程是并发编程中的基本单位,它代表了程序中的一个独立的执行流程。通过将任务分配给不同的线程,我们能够充分利用多核CPU的性能,提高程序的执行效率。然而,正确地管理和使用线程却是一个富有挑战性的任务。
在这篇博客中,我们将一起探讨线程在并发编程中的角色,理解线程的生命周期,学习如何正确地创建、管理和终止线程。我们也将了解线程与锁等并发工具类的关系,以及如何使用这些工具来解决并发中的各种问题。希望通过这篇博客,我们都能够更好地理解线程,这个并发世界的主角,从而更好地驾驭并发编程。让我们开始这段精彩的学习之旅吧!
假如你是一名初学者, 建议你先阅读刘欣老师微信公众号中的这篇和线程有关的文章:我是一个线程(修订版)
这篇文章以故事的视角描述了线程从执行到关闭所经历的一切, 你很容易就带入这个故事。
理解 | 以技术视角理解线程的行为
我建议您在进行下一步抽取分析之前,先完整阅读上述的故事。接下来,我们将根据这篇故事进行详细的分析和讨论。
在JVM实例启动并初始化线程池时,若未调用 ThreadPoolExecutor
的 prestartAllCoreThreads()
方法,线程池初始状态下并不会立即创建任何线程。只有当任务开始提交给线程池时,线程池才会逐渐创建所需的线程来处理这些任务。也就是说我在某一特定的任务(用户登录任务)提交时被创建。
如果您对线程池相关参数感兴趣,也可以阅读这篇文章:
并发编程 | 线程池-资源管理利器
为什么0x6900说处理完包裹必须马上回来, 否则就永远回不来?
在讨论这个事情,我们先来了解下,一个运行中的线程,在什么时候会让出CPU使用权。
- 时间片用完:如果线程的分配的时间片已经用完,那么操作系统会进行上下文切换,将CPU的控制权交给另一个线程。
- 线程阻塞:如果线程需要等待某个事件(如I/O操作完成,获取锁,等待其他线程的信号等),那么它会进入
阻塞
状态,此时操作系统会进行上下文切换,将CPU的控制权交给另一个线程。 - 线程主动让出CPU:线程可以通过调用某些系统调用(如在Java中的
Thread.yield()
方法)来主动让出CPU。这通常用在协同式多任务处理中,让线程可以在适当的时候主动让出CPU,以允许其他线程运行。 - 线程结束:当线程完成其任务并结束时,操作系统会进行上下文切换,将CPU的控制权交给另一个线程。我所在的线程池并不会让线程结束,而是回到线程池等待接收任务。
- 调度器的策略:在某些情况下,操作系统的调度器可能会因为其他原因(如优先级调整,负载平衡等)决定将CPU的控制权从一个线程转移到另一个线程。
看完上面你是否能理解0x6900说的含义?也就是说,如果你没有立即返回,那么很可能发生了死锁或其他问题
。无论如何,对于线程来说,这都不是一个好的状况。一旦你的异常情况被记录下来,可能会引起程序员(即上帝)的注意,并直接进行干预,甚至选择终止线程。
那么线程在哪些情况下“不回家”呢?
- 阻塞:线程可能在等待某种资源(如I/O操作完成,获取锁等),导致无法返回到线程池。
- 死锁:如果两个或更多的线程互相等待对方释放资源,那么它们可能会陷入死锁,无法继续执行。
- 无限循环:如果线程的任务代码包含无限循环,那么线程可能永远不会结束。
- 异常:如果线程在执行任务时发生未捕获的异常,那么线程可能会异常结束,而不是返回到线程池。(这就是为什么我们强烈建议程序员实现全局异常捕获策略。否则,一旦生产环境出现问题,你可能就需要做好24小时待命的准备,随时处理突发问题了,哈哈。)
这里补充一个知识点,Linux操作系统使用了一个称为完全公平调度器(Completely Fair Scheduler,CFS)的调度算法,这个算法会根据线程的行为动态地调整其时间片。在CFS中,每个线程都有一个虚拟运行时间(Virtual Run Time,VRT),表示这个线程使用CPU的时间。当一个线程运行时,它的VRT会增加;当它等待运行时,它的VRT会保持不变。调度器总是选择VRT最小的线程来运行。在这种模式下,如果一个线程频繁地阻塞或唤醒,那么它的VRT会相对较小,因此它更有可能被调度器选中来运行。这就实现了一种动态的时间片调整机制,可以根据线程的行为自动调整其获得CPU时间的机会。
为什么其它线程运行完就被销毁,而我还活着?
因为我“运气”比较好,并没出现什么突发情况,我可以顺利的返回线程池中。其他线程可能并没有我这么幸运。或者说,有些线程可能只是临时创建出来的,并不受线程池管理,在完成任务之后,它们就会被销毁。
我生活在一个何等独特的世界!在这个世界里,'就绪车间’和’生产车间’是何物呢?
我生活在一个什么样的世界?
图中紫色部分为线程私有区域,换色为线程共享区域。
- 当JVM进程启动并运行你的Java程序时,如果你的程序在某个地方创建了一个
ThreadPoolExecutor
,那么JVM会在堆内存
中为这个ThreadPoolExecutor
对象分配空间。这包括它的内部状态(比如线程池的大小、任务队列) - 当你通过调用
ThreadPoolExecutor
的execute
或submit
方法向线程池提交任务时,ThreadPoolExecutor
会决定如何处理这个任务。如果线程池当前的线程数量少于其核心线程数,则会创建一个新的工作线程来执行这个任务。如果当前的线程数量达到了核心线程数,但任务队列还有空余,则任务会被放入队列等待。如果队列已满但线程数量还未达到最大线程数,则会再创建新的工作线程来处理这个任务。如果线程数已达到最大,任务队列也已满,则根据设置的拒绝策略来处理新来的任务。 - 每个工作线程都会运行一个循环,从任务队列中获取任务并执行它们。当工作线程的运行循环在无事可做(例如,任务队列为空)并且超过了一定的时间(根据线程池的设置)时,这个工作线程可能会被停止并且从线程池中移除。
Thread
对象和操作系统的线程是关联的。当ThreadPoolExecutor
创建一个新的工作线程时,它实际上是在请求操作系统创建一个新的轻量级进程(也就是线程)。这个新的操作系统线程将被用于执行你提交到线程池的任务。当工作线程停止时,对应的操作系统线程也会被终止。ThreadPoolExecutor
在堆内存中,而每个Thread
对象有一个线程栈,这个线程栈是在Java虚拟机栈内存区域中的。每个线程栈保存着这个线程进行方法调用和局部变量处理所需要的信息。
就绪车间, 生产车间到底是什么?
了解任何事物, 都要看全貌。现在我们不妨根据就绪车间,生产车间来补充这个完整的工厂?
在这个充满活力的工厂(操作系统)中,各个工人(线程)有自己的工作岗位和职责。
首先,我们有一个叫做"应聘中心"(新建状态)的地方。这里是所有新工人(线程)开始他们在工厂中的旅程的地方。他们提交自己的申请,等待被录用。
录用后,工人们会被送到"就绪车间"(就绪状态)进行基础培训。在这个车间里,他们被充分准备,随时待命,准备投入到"生产车间"(执行状态)。开始他们的工作。在这里,他们可以利用工厂的资源(CPU)来完成他们的任务。
同时,如果工人(线程)需要等待工厂的某些资源(例如,等待I/O完成),他们会被送到"等待室"(等待状态),在那里他们会暂时停工,直到所需的资源可用。
一旦资源可用,他们会被送回"就绪车间",等待重新被分配到"生产车间"。
当工人完成了他们的任务,他们会进入"退休区"(终止状态)。在这个阶段,他们的任务已经完成,他们的资源会被工厂回收,准备服务于新的工人。
在整个过程中,“调度员”(线程调度器)在各个车间和生产线之间协调,决定哪个工人应该在什么时候,去到什么地方,做什么任务。这个调度过程需要考虑许多因素,包括工人的优先级,工人的公平性,以及工厂的整体效率。
在这多姿多彩的世界,你何尝不是一个其中“工人”的一份子呢?
作为线程的我,遇到死锁怎么办?
凉拌, 投个好程序员胎,下面是给程序员朋友的提醒:
- 避免嵌套锁:死锁通常发生在多个线程在一系列的锁上产生的循环等待。因此,尽量避免在一个线程中同时获取多个锁。
- 按固定顺序获取锁:如果必须获取多个锁,那么确保所有线程都按照相同的顺序获取锁。这样可以避免形成循环等待。
- 使用锁超时:在尝试获取锁时,使用带有超时的方法,例如
tryLock()
,而不是lock()
。这样,如果无法立即获取锁,线程可以选择释放已经获得的锁,从而避免死锁。 - 使用中断:在等待锁的同时,也可以对线程进行中断。如果线程被中断,它可以选择释放已经获取的锁,从而避免死锁。
- 使用高级并发API:Java提供了一些高级的并发API,如
java.util.concurrent
包中的ReentrantLock
,它提供了更细粒度的锁控制和更多的选项,以帮助避免死锁。 - 使用死锁检测工具:有些IDE和分析工具,例如 Eclipse, IntelliJ, JConsole, VisualVM 和 FindBugs,可以帮助开发者检测出潜在的死锁问题。
还记得Coffman在1971年的论文《系统死锁》(System Deadlocks)中提出的四个必要条件吗?
补充一个知识点:多线程发生死锁,CPU会到 100% 吗?不会,假如有两个线程都持有对方需要的共享变量资源, 这个时候这两个线程会出现WAITING的状态, 我们可以通过JConsole等工具检测到
一些简单的面试题
在Java中,线程有几种状态?
线程共有六种状态,状态如下图所示(从网上找来的图,两张图均可理解):
操作系统中的线程有几种状态?
5种,如图所示(从网上找来的图):
在 Java 中,“Runnable” 状态实际上是包含了操作系统线程模型中的 “就绪” 和 “运行” 这两种状态。在操作系统中,“就绪” 状态是指线程已经准备好运行,正在等待操作系统的调度器分配 CPU 时间片给它;“运行” 状态是指线程正在 CPU 上执行。然而,在 Java 中,我们并不能控制或直接了解到操作系统级别的线程调度,Java 线程的 “Runnable” 状态实际上是指线程在 Java 的线程调度模型中已经准备好运行和正在运行。因此,“Runnable” 状态是一个更高级别的抽象,它包含了操作系统线程模型中的 “就绪” 和 “运行” 这两种状态。在任何给定时刻,一个 “Runnable” 的 Java 线程可能正在运行,也可能正在等待操作系统的调度。
为什么Java中的线程比操作系统中的线程定义更多的状态?
假设我们将 阻塞
、等待
与 超时等待
这三种状态统一视为 阻塞
,那么实际上,Java所定义的状态比操作系统要少。而Java之所以要将这些状态进行详细区分,是因为在Java语言的层面上,需要更细致的同步状态来帮助程序员理解和管理复杂的同步问题。
那么,我们又该如何理解 就绪
和 运行
状态被抽象成 可运行
状态呢?在Java这样的高级语言中,就绪
状态本身并无法被直接控制。为了更有效地进行抽象和屏蔽底层的复杂性,Java选择将 就绪
和 运行
这两种状态合并为一种 可运行
状态。
总结
作为线程(主角),我真心希望程序员朋友们能注意防范死锁问题。请务必谨慎处理并发编程中的资源分配和锁管理,避免编写引发死锁的代码。保持清醒的头脑,提升编程技艺,让我们一起在多线程世界里航行更加顺利。
最后:感谢刘欣老师编写的《码农翻身》这本书,希望在今后可以出更多类似的书籍或者博客,造福码农群体。