网上已有很多文章讲过Java线程创建方式,本文尝试用不一样的视角去谈。
这篇万字长文将由浅入深,从一个略有争议的话题开始,它将是串联起其他各类问题的线索,对于面试、工作、筑基,或有些许助力。
一、线程创建方式
1.1、创建方式是多种,还是1种?
听说Java创建线程有4种常见方式;也有些辟谣说其实只有1种,情况究竟如何?
常见4种包括:
① 继承Thread类
② 实现Runnable接口
③ 实现Callable接口 + FutureTask包装
④ 使用线程池
这些可以大致归纳为:new Thread(xx),区别在于xx有丰富的方式(如图),这就是常说的创建线程方式有多种所对应的含义了。
但若细究,会发现“创建线程”的表述并不准确:
- new Thread()是创建了线程对象,但此时也仅仅是JVM堆中的一个普通的Java对象;
- Thread.start()内部才真正触发了操作系统层面的线程的创建,进而触发执行具体线程任务。
因此说Java创建线程的方式只有1种:Thread.start()。
而常说的4种或更多方式,严格来说是定义线程任务、构造线程对象的方式。
1.2、关联的各种问题
那么有必要改口吗?咬文嚼字没意义,关键看是否会造成误解。
如果将语境限定在Java层面,似乎不会误解;另外一般new Thread()和start()紧邻使用,即使真的误以为前者创建线程,也没啥实质影响。
但是且慢,start()的一个重要影响是,它会占用一定的本地内存(Java线程栈内存的典型默认值1MB)。
假如存在这种情况:大量地new Thread()却尚未start(),那么误以为new Thread()已创建线程,则可能影响对内存占用情况的判断。
首先想到的是线程池OOM场景,它的阻塞队列里的任务是否属于已new Thread()但未start()的情况?毕竟线程池对使用者是黑盒,需“开盒”分析才能确认。
除了线程池,还有几个问题:
- start()创建了OS线程,那么OS线程跟Java线程之间是什么关系?
- start()底层究竟做了什么?
- 从定义子线程任务,到start()后执行任务,这个“任务”是如何传递、关联的?
PS: 除开特定小节,本文提到的“线程”不包括虚拟线程。
翻译问题
在探讨各问题之前,对于“创建线程”的说法先排除下是否是翻译的锅。
Java经典资料里,确实也直接将new Thread()称为创建线程(create a thread)——
例如《Java核心技术》第10版14章:
Oracle文档:
但这些资料在给thread下定义时,却是按操作系统线程的概念定义的:a thread of execution, thread of control。
总之,thread单词是两种含义混用:既可以指操作系统线程,又可以指Thread实例对象;
那么中文语境里“线程”同样混用含义也不是很意外了。
二、Java线程 vs OS线程
在分析线程池OOM是否大量new Thread()而未start()之前,先理解一下Java线程是怎样的一种存在。在接触Java线程的早期,我模糊地以为Java自己实现了某种线程、并且能某种程度参与线程调度(sleep/park/unpark),后来才发现并非如此。
2.1、Java线程和OS线程,你俩什么关系?
《深入理解Java虚拟机》书中对主流JVM实现的线程的描述:“每一个Java线程都是直接映射到一个操作系统原生线程来实现的”。
但这样表述可能产生一种印象:Java线程跟OS线程,像是并列的两个线程实体。
更直白具体的描述是这样的:每个Java线程(Thread实例)都是一个OS原生线程的封装。它额外维护了一些Java作为上层应用所需的一些管理信息,例如Java层的线程状态、Monitor锁、ThreadLocal存储等等。
光语言描述可能抽象,下图展示了Java的Thread对象是如何层层封装了底层的OS线程,各层对象之间均通过指针或字段进行关联。
不难发现,从Java Thread到OS线程(pthread),Java套了3层壳。为什么套这么多层?通俗地说:
- OSThread层是为了兑现“编译一次、到处运行”的承诺,屏蔽操作系统差异;
- JavaThread层是JVM真正管理、维护线程的对象;
- Thread层是为了让程序员能傻瓜式地操作线程而无需操心底层复杂细节;
| 对象 | 功能层次 | 作用 |
|---|---|---|
| Thread (Java) | API层 | 为程序员提供start/sleep/interrupt等API,屏蔽了线程底层的复杂细节 |
| JavaThread (C++) | JVM逻辑层 | 管理Java的执行上下文(栈帧、安全点、异常)、维护线程状态、协同GC |
| OSThread (C++) | 平台适配层 | 屏蔽操作系统差异,如果没有它,JavaThread里就得对不同系统做if判断了 |
至此,在Java语境中将new Thread()称为创建线程,从以下角度看有合理性:
1、抽象封装:new Thread()和start()是Java提供的线程API,屏蔽了OS层的细节(如pthread_create调用、栈内存分配、内核结构体);
2、生命周期模型差异:Java给Thread定义了6种状态,跟OS通用的5态模型并非一一映射,且自定义了状态流转机制。
总之在Java的语境中,“线程”可理解为Java层封装后的线程:不完全等同于OS线程,而是更抽象、更上层的存在。
那么Java的线程状态相对于OS层的有何区别,只是细化了阻塞态、合并了就绪/运行态吗?
另外JDK19起推出的虚拟线程又是怎么回事?
2.2、虚拟线程
Java传统线程面对IO密集型任务,配置线程数某种程度上存在一根筋两头堵的局面:
- 如果配的少,线程大部分时候在等待IO完成,CPU闲置浪费;
- 如果配的多,线程频繁切换、上下文切换成本较大,对CPU也是无谓浪费。
实践中可根据经验配置合适值以获得最大吞吐量,但CPU切换成本仍不容忽视。
总之传统模式下的痛点是,Java传统线程(平台线程)本质上作为OS线程的封装,同时还承载了线程任务,想启动/切换任务就得创建/切换OS线程。
而JDK19起推出的虚拟线程的精髓,是“解耦”、“复用”:用虚拟线程承接任务,实现平台线程与任务解耦,一个平台线程可同时承接大量任务。
执行任务时才将虚拟线程“挂载”到平台线程去执行,这样切换任务只需低成本地切换虚拟线程即可,免去了切换OS线程的成本。
IO密集型场景,可以创建大量虚拟线程以提高CPU利用率,但同时平台线程只需维持少量数目即可。
线程的成本
先简单回顾下,常说线程创建/切换的成本高,成本大概在哪——
| CPU成本 | 内存成本 | |
|---|---|---|
| 创建 | 切换内核态等系统调用占CPU时间 | 1~几MB的内存 |
| 切换 | CPU时间占用(切换内核态、保存/恢复上下文)未能利用局部性原理引起的cache miss(相关数据需消耗CPU周期重新读取) | \ |
我们最关心的线程切换成本,主要是直接和间接对CPU时间的占用。
一个仅关于效率层面的类比:高铁350km/h如果一站不停直达,那得有多快;因停靠多站点而间歇性刹车、上下客、启动,又会慢多少?
虚拟线程的成本
- 创建:虚拟线程只是Java堆中的对象,创建成本极低:占用内存远小于OS线程,不涉及系统调用相关的CPU成本;
- 切换:虚拟线程的上下文的保存/恢复仅发生在JVM内;平台线程(载体线程)并未切换,因此免去了昂贵的 OS线程上下文切换、对CPU缓存的影响更小。
那么虚拟线程的上下文切换是如何实现的?
虚拟线程的上下文(栈帧、局部变量、执行位置)被封装为Continuation对象,位于JVM堆中。
- 当虚拟线程因执行阻塞操作而主动让出(yield)载体线程,上下文数据被保存到堆内存的Continuation对象中,并释放载体线程;
- 当虚拟线程重新被调度到载体线程上执行,堆内存Continuation里的上下文数据将拷贝回载体线程的栈中,从断点处继续执行。 当然,虚拟线程也有局限性,后面再谈。
2.3、线程的调度
书上说Java线程(平台线程)的调度完全由OS负责。那这些阻塞/唤醒方法(sleep/park/unpark/notify)算什么?虚拟线程的调度跟OS调度有关系吗?
调度含义
首先,OS里调度的含义是,在多个就绪的线程中选一个分配CPU使用权。
再看线程的阻塞/唤醒方法,从功能含义上看并非调度。例如unpark(),它只是唤醒线程、让其进到OS的调度队列;至于何时能使用CPU,仍是由OS调度器决定。
或从主体、客体的角度理解:调度行为的主体是调度器,客体是线程;调度器根据一定算法去调度线程。而线程的那些阻塞/唤醒方法,实施的主体是线程,属于被调度对象。
虚拟线程的调度,含义是让哪个虚拟线程挂载到平台线程上,其调度器默认是基于ForkJoinPool实现的,调度算法大致是LIFO(载体线程自己队列的任务)+FIFO(窃取其他载体线程任务队列的任务)。
但跳出JVM看,即使挂载到平台线程也未必立刻使用CPU,因为平台线程自身可能在调度队列中,等待被调度上CPU。因此完整链路视角是两层调度结构:虚拟线程-->平台线程-->CPU。
调度方式
调度方式分为:抢占式、协作式。二者区别是,当前线程是被动/主动放弃CPU执行权。
虚拟线程的调度是协作式的:虚拟线程执行到阻塞操作时主动让出平台线程,由JVM调度器决定后继者;
OS线程的调度是抢占式的:线程占用CPU的时间由OS调度器分配,时间到后强制切换线程。
理解OS调度也是后续理解容器CPU时间分配的基础,这里稍微提下。
以现代 Linux 为例,它对用户线程采用的调度器是CFS (Completely Fair Scheduler,完全公平调度器)。“公平”是指各线程能获得相对均匀的CPU使用时间,避免单一任务长时间占用CPU。这样的设计对IO密集型线程是友好的,因为线程占用CPU时间相对少,一旦醒来进入调度队列,往往能被优先调度。
CFS也支持给线程设置“优先级”,但一般不建议在Java中给线程设置优先级。
以Linux为例,非Root权限启动的JVM,即使设置了线程优先级也会被忽略或重置;即使以Root启动,由于CFS的设计,设置优先级只能修改分配CPU时间片比例的“权重”,不能保证执行顺序 ,因此难以达到预期的调节效果。
三、线程状态
前面说Java线程是封装后的OS线程,并改动了线程状态映射关系。但线程状态的差异并不仅仅是我们通常以为的细化或合并,下面看看其中到底藏了什么玄机。
3.1、3种层次的线程状态:Java / OS / Linux
下表是Java和OS的线程状态的常见划分方式:
| Java线程状态 | OS线程状态 |
|---|---|
| NEW | \ |
| RUNNABLE | 就绪 (READY) |
| 运行 (RUNNING) | |
| BLOCKED | 阻塞 (BLOCKED 或 WAITING) |
| WAITING | |
| TIMED_WAITING | |
| TERMINATED | 终止 (TERMINATED) |
但如果你以为上面就是操作系统的线程状态,那么当你上Linux服务器看线程状态(一堆S/R)就懵逼了。
因为刚才列出的OS线程状态只是理论模型,像Linux定义的线程状态就不太一样,下表节选了Linux状态,其中括号里单个字母是对应状态的简写。
| OS线程状态理论模型 | Linux 线程状态(节选) |
|---|---|
| 就绪 (READY) | (R) TASK_RUNNING |
| 运行 (RUNNING) | |
| 阻塞 (BLOCKED) | (S) TASK_INTERRUPTIBLE |
| (D) TASK_UNINTERRUPTIBLE | |
| 终止 (TERMINATED) | (Z) EXIT_ZOMBIE |
| (X) EXIT_DEAD |
下面俯瞰3个层面的线程状态图。
Java线程状态:
其中Object.notify是间接(并非直接)使线程从WAITING-->RUNNABLE,原因后面小节会解释。
OS理论模型:
OS vs Java:
- OS理论模型的READY和RUNNING,在Java里被归为RUNNABLE;
- OS的阻塞态,在Java里被细分为BLOCKED, WAITING, TIMED_WAITING。
Linux线程状态(节选):
OS vs Linux:
- Linux的RUNNING,同样不区分就绪、运行。
为什么作为“实战派”的Linux/Java都不区分这两种状态,而OS理论模型却要区分?
“就绪”对应调度队列中的线程,“运行”对应正占用CPU的线程,而且Linux调度器内部能通过指针区分二者,所以区分是有实际意义的。只是没有通过状态字段暴露给外部,因为CPU切换线程极为频繁,外部观测到“运行”态有滞后性,所以暴露的意义不大、成本反增。 - OS的阻塞状态在Linux中分为:可中断睡眠(S)、不可中断睡眠(D)。“不可中断”指的是线程/进程的睡眠状态无法被任何信号(如kill)所中断,直到操作完成才会被唤醒。
Linux vs Java
特殊情况是,当线程从运行态切到D状态或S状态(网络IO),Java层仍将停留在RUNNABLE状态。
原因是,由OS层线程调度触发的线程状态切换,并未通知JVM,JVM是无感知的。
即,线程状态的同步机制是单向的:JVM-->OS(√),而 OS-->JVM(×)
举个例子,Java可以通过 park/unpark 触发 OS 线程的阻塞/运行;但反过来,例如Java网络IO场景下,OS线程等数据而挂起进入S状态,这个变化对JVM是透明的。
(特殊情况另论,如OS层强行kill Java线程,这个JVM能感知到)
下表是从实际可能发生情形的角度展示对应关系,并不代表Java/Linux线程状态的严格映射关系,仅供参考。
| Java 线程状态 | Linux 线程状态(节选,并省略前缀) |
|---|---|
| NEW | \ |
| RUNNABLE | (R) RUNNING |
| (D) UNINTERRUPTIBLE | |
| (S) INTERRUPTIBLE (网络 IO) | |
| BLOCKED | (S) INTERRUPTIBLE (不含网络 IO) |
| WAITING | |
| TIMED_WAITING | |
| TERMINATED | (Z) ZOMBIE |
| (X) DEAD |
3.2、线程IO
线程IO如此特殊,那么用一道面试题开启本小节:线程在等IO期间对CPU占用情况如何?
基础版回答:该线程不占CPU,因为操作系统会将等IO的线程挂起,让其他线程使用CPU。
所以IO密集型任务通常会配更多线程数,因为线程多数时间在等IO,适当配多可充分利用CPU。
面试官狡黠一笑:你的说法是否太绝对了?
- 你刚说的是阻塞IO,那么NIO(非阻塞IO)也是如此吗?
- 网络IO、磁盘IO都是如此吗?
- IO的读、写场景都如此吗?
好家伙,一串夺命连环问,上面排列组合一下得有8种情况,汗流浃背了……
面试官:别跑啊,还有呢!
- 虚拟线程IO是非阻塞的吗?
- IO场景中,CPU使用率只是一方面指标,再考虑上iowait, load指标又如何?
- 使用率、iowait、load,这三者都可高可低,排列组合又是8种情况,这些情况分别有哪些可能原因?
面试者内心OS:怎么又是8种组合?噢,这就是八股吧……
IO是大话题,这些后续有机会其他文章再介绍。
3.3、Java各阻塞状态的理解
3.3.1、概述
前面介绍了3种层次的线程状态模型,其中阻塞状态比较特殊,就着Java的阻塞状态我们挖掘一下。
首先明确下线程“阻塞”的底层含义:线程让出CPU,并且被移出OS的调度队列。
对应此含义,Java的“阻塞”状态分成3种:BLOCKED, WAITING, TIMED_WAITING,这些有何区别?简要来说:
- BLOCKED是synchronized场景等待获取monitor锁的阻塞;
- WAITING是显式调用线程协作api引起的阻塞等待;
- TIMED_WAITING大致是限时WAITING。
有人可能想,线程IO的阻塞状态不是WAITING吗?这个前面小节已介绍过,等待IO在Java里仍为RUNNABLE状态,原因是线程状态同步的单向性。
更具体介绍如下表
| Java线程状态 | 含义 |
|---|---|
| WAITING | 线程阻塞,等待其他线程执行特定动作将自己唤醒。 典型的成对阻塞/唤醒操作:wait/notify,park/unpark,join/目标线程结束 |
| TIMED_WAITING | 线程阻塞,但限期等待,超时后自动唤醒;相对于WAITING的阻塞操作多了个sleep()。 |
| BLOCKED | 线程阻塞,专指synchronized场景等待获取monitor锁的状态 |
3.3.2、细说 BLOCKED
BLOCKED状态的触发条件,有些地方概括为“(synchronized)获取锁失败”,其实不完全严谨,原因下面会解释。先看其官方注释,看起来有点晦涩:
它先概述了BLOCKED是“阻塞等待monitor锁”,然后介绍了2种情况:
- 情况1(蓝+红框):进入synchronized代码块/方法时,等待获取monitor锁(言外之意是获取失败而进入等待);
- 情况2(蓝+橙框):调Object.wait后再次进入synchronized代码块/方法,等待monitor锁。
晦涩的是情况2:Object.wait()之后,线程不是进入WAITING状态吗?其实注释原文的表述省略了一些环节,所以显得跳跃突兀。
(下面的叙述假定读者wait/notify这个线程间协作通信机制有一定了解。)
如果简单理解,流程可补全为——
synchronized里调wait释放锁、进入WAITING-->被其他线程notify-->重新获取monitor锁-->获取失败后状态为BLOCKED。
但这样其实仍不够准确,实际上:
- 被其他线程notify后,当前线程就已从WAITING转为BLOCKED,并非重新获取monitor失败才进入BLOCKED。
- 执行notify后还需等线程退出synchronized,当前线程才重新获取monitor。
更完整流程的表述——
synchronized里调wait进入WAITING-->被其他线程notify进入BLOCKED-->执行notify的线程退出synchronized后,当前线程尝试重新获取monitor锁-->获取失败则仍为BLOCKED。
下图相对更完整地还原情况2:
补充说明:
- notify的作用是“通知”(通知线程A所等待的条件已满足),不是“唤醒”线程。
尽管Object.notify()官方注释的表述是“Wakes up a single thread that is waiting on this object's monitor”,但这里的wake up并非真的唤醒OS线程,应理解为将线程从WAITING状态中“唤醒”、转为BLOCKED状态。这或许又是Java语境中thread的概念不等同于OS thread的一个印证。
实际上notify的JVM源码(objectMonitor.cpp的ObjectMonitor::notify)中,它只是将WaitSet中的头个线程转移到cxq这个栈(后续会转为EntryList),并没有执行唤醒操作。 - 线程A被唤醒后,尝试获取monitor锁,如果:
1° 获取锁失败,线程A仍为BLOCKED状态;
2° 获取锁成功,状态切为RUNNABLE,并且从Object.wait方法返回,继续执行剩余代码(一般是从循环检查条件的while循环中跳出)。
总结
synchronized的BLOCKED状态的触发——
情况1:首次获取锁失败;
情况2:调Object.wait后(进入WAITING),从被notify通知起,直到成功获取锁之前,线程都是BLOCKED状态。
所以情况 1、2 概括为“等待monitor锁”,比“竞争monitor失败”更为准确。从这点看,BLOCKED的官方注释虽然晦涩,但表述其实相当准确。
3.3.3、WAITING & wait/notify
前面反复说Object.wait()会释放锁、进入WAITING,那么其他挂起线程的api会释放锁吗?
其他api都不释放锁,如sleep/park/join。因此synchronized里需慎用,否则其他竞争同个锁的线程可能全部陷入BLOCKED卡住。
那么wait()有何特别?
Object.wait()/notify()是用于线程协作的等待/通知机制。即在条件不满足时释放锁、进入等待,等着条件被其他线程改为满足、并被通知。先列出使用规范:
- 强制要求(否则报错)的约束:只能在synchronized里使用;wait/notify的Object必须是同一个monitor锁对象。
- 若违反虽不报错、但强烈建议的使用方式:在while循环中检查条件、并在循环体里调wait。
synchronized (lock) {
while (!condition) {
lock.wait();
}
// remaining code
}
为什么要求synchronized?
- 首先解释为什么加锁:是为了确保“检查条件-wait”的原子性,防止“丢失唤醒”问题(Lost Wake-Up)。 假如不加锁,可能出现:线程A已判断条件不成立、即将wait但尚未wait;线程B改了条件、notify通知A;此时的通知没意义,因为A尚未wait;等A执行了wait,由于线程B已完成任务,可能再没人通知线程A、A将永远WAITING。
- 那么不用synchronized、用其他锁可以吗?wait/notify底层源码就是基于monitor的,所以没法挪用;其他锁如ReentrantLock也有自己的等待通知机制,用的Condition接口的await/signal,这里就不展开了。
为什么要求在while循环里wait?
- 防止虚假唤醒。WAITING中的线程,可能在没有收到notify通知的情况下被OS层的一些特殊情况唤醒,不一定真的是条件被满足了,所以唤醒后需检查条件是否满足。
- 防止已满足的条件在唤醒后再次变成不满足的情况。正如前面讲BLOCKED小节提到的,被notify的线程只是被“通知”条件已满足、可以准备竞争锁,而非被“唤醒”,因此执行过wait()的线程竞争锁可能失败,而其他获得锁的线程可能将条件改为不满足,所以也需醒后检查条件。
最后再看sleep/park/join:是挂起线程的api,原理上跟monitor锁没有直接关联,可在任何地方使用;如果在synchronized里使用需慎重,因为它们不会释放monitor锁。
3.3.4、虚拟线程 pinning 问题
虚拟线程也有synchronized里不释放锁的危险情况。即所谓的pinning问题:虚拟线程阻塞期间不释放载体线程,使载体线程随之阻塞,不能挂载其他虚拟线程。pinning有两类情况:
① synchronized内执行阻塞操作,或进入synchronized时的锁竞争(JDK24之前);
② native代码(native方法、外部函数)里执行阻塞操作。
情况① synchronized相关
这是因为monitor关联的是载体线程而非虚拟线程。如果阻塞期间允许卸载,则下个挂载的虚拟线程也能调用相同的synchronized代码,使锁失效。
更糟糕的是,其他虚拟线程若竞争同个monitor将竞争失败,也将pin在各自载体线程上,可能导致全部载体线程卡住。
情况①的特例是Object.wait(),尽管它不释放载体线程,但释放了锁,所以调度器会临时增加载体线程数为补偿,因此影响不大;其他阻塞操作不释放锁,即使补偿也会BLOCKED,所以不补偿。
JDK24起已解决synchronized场景的pinning问题,解决方式是将monitor的关联对象从载体线程(的指针)改为虚拟线程(的线程id)。
情况② native代码相关
这是源于native代码在方法栈上产生native栈帧,导致虚拟线程无法卸载。
前面小节提到虚拟线程切换机制,是将封装了上下文(栈帧、局部变量、执行位置)的Continuation对象保存到堆中、后续从堆中恢复。
native栈帧的相关数据受OS管理、不受JVM管理,无法安全地保存恢复,因此JVM选择pin住载体线程,等native代码执行完。
OpenJDK项目文档(JEP 444/JDK 21)称,native代码的pin限制是有必要的,是为了Java与native代码之间正确交互。因此估计官方短期内不会解决native代码的pin问题。
小结
JDK24前的虚拟线程synchronized场景:
- wait():释放锁,不卸载,补偿;
- 其他阻塞操作:不释放锁,不卸载,不补偿。
JDK24起,synchronized的pinning问题已解决;但native代码场景仍会pin。
3.3.5、方法栈的理解
上面提到native代码,那么补充解释下方法栈。
在JVM规范里,本地方法栈、虚拟机栈(JVM栈)概念上是两个区域;《深入理解Java虚拟机》说JVM的HotSpot实现,是“本地方法栈和虚拟机栈合二为一”,这是什么意思?
简单来说:二者共用一个栈,即一条栈里两种类型的栈帧可交替存在。
举例说,通过Thread.start()创建OS线程,其内部是调C/C++实现的native方法start0。
如下简化示意图,左侧大致是我们在IDEA里看到的,只会包含JVM栈的方法栈帧,到start()为止;右侧则更完整地展示了所涉及的native方法栈帧。
但一个略显意外但又合理的事实是:右侧的示意图也并非全貌。
事实上,Java里所有方法栈的底部(即第一个方法),都是native方法。因为所有Java方法都依托于线程执行,而Java线程的真正入口都是JVM的C/C++代码。完整的简化示意图如下:
当执行到native栈帧,则脱离了JVM的管理,完全由操作系统负责。比如线程的PC(程序计数器),对于Java方法记录的是正执行的字节码指令地址,而到native方法则为空。
至于方法的第一个native栈帧到底是谁,将在文末介绍。
四、线程池的两种OOM、源码流程
终于要回收文章开头的问题了:线程池是否存在大量new Thread()却尚未start()的情况?如果误以为new Thread()已创建线程,则可能影响对内存占用情况的判断。
众所周知,JDK线程池newFixedThreadPool和newCachedThreadPool可能OOM,情况如下:
| 线程池 | 特点 | 典型OOM报错 | 原因 |
|---|---|---|---|
| newFixedThreadPool | 阻塞队列无上限 | GC overhead limit exceeded | 任务持有的大对象占堆内存 |
| newCachedThreadPool | 工作线程数无上限 | unable to create new native thread | 线程的栈空间占本地内存 |
其中newCachedThreadPool不一定只是本地内存OOM,如果任务持有大对象则也可能堆内存OOM;它的另一个问题是高峰期可能频繁CPU上下文切换。
回到主线问题:阻塞队列里的任务是否完成了new Thread()? 先上结论,队列里的任务未进行new Thread()。不过还是得靠源码说话。
上述两种线程池的创建都是调ThreadPoolExecutor构造器,传参不同而已。
通常调ThreadPoolExecutor的execute()或submit()来提交线程任务,其中submit()内部也是调execute()。
4.1、execute逻辑
先复习下execute()的关键逻辑,图中①②③④顺序上对应了线程池从饥饿到逐渐饱和的情况。
需注意2点逻辑:
- 核心线程数满了,是先入队到阻塞队列(②),等队列满了才创建临时的非核心线程(③)。
- 临时线程并非从阻塞队列里取任务执行,而是直接执行当前任务,相当于插队了(这个逻辑的源码后面会介绍)
总结JDK线程池逻辑:核心线程满-->排队-->创建临时线程;
Tomcat则将其修改为:核心线程满-->创建临时线程-->排队。
二者设计差异源于任务类型:CPU密集型 vs IO密集型——
- CPU密集型:瓶颈在CPU,线程超过CPU核心数后不能提高效率,反而增加上下文切换的开销;所以优先放入队列作为缓冲、而非优先增加线程数。
- IO密集型:瓶颈在等待IO而非CPU,优先扩充线程数可提高响应速度、提升吞吐量。像Tomcat线程典型场景就是执行各种远程调用,等IO时间较多。
回到正题,加到阻塞队列(②)对应源码是execute方法里的workQueue.offer(command)。
workQueue这个阻塞队列有多种实现,以常见的LinkedBlockingQueue为例,其offer()方法功能就是将任务包装成节点加到队列里,其中并不涉及new Thread()(其他阻塞队列同样不涉及),源码如下:
4.2、addWorker逻辑
那么再看负责创建工作线程的addWorker(),源码较长,其逻辑精简概括如下图:
上面两个绿框正是我们探查的目标:
- “启动线程”即调start();
- “创建工作线程对象”,即new Worker(firstTask),其中firstTask即当前提交的任务。
Worker对应构造器细节如下,其中newThread(this)内部调了new Thread(),this代表Worker实例。
前面addWorker()流程图中的两个绿框(new Thread()、start())之间是否可能出问题导致大量线程卡在尚未start()的状态?
不会,addWorker()源码后半部分如下,红框只是确保线程池仍在运行;如果new Thread()之后不凑巧线程池SHUTDOWN了,那么其他新提交的任务走不到这里,因此只有少量Thread会卡在尚未start()的特殊状态。
4.3、阻塞队列的理解
注意,尽管workQueue的类型是阻塞队列(BlockingQueue),但execute()调用的offer()操作是非阻塞的——以LinkedBlockingQueue为例,offer()逻辑是如果队列已满则立即返回false,而不是阻塞着等待空位出现。offer()源码前面已出现过,这里截取头几行印证:
那么线程池什么环节用到阻塞队列所支持的“阻塞”特性?
- 当核心线程从队列里取任务执行时,假如队列为空,核心线程会阻塞(park()),直到有新任务加到阻塞队列中,线程会被唤醒(unpark()),这样确保了核心线程常驻不回收。
- 对于临时线程(非核心线程),是限时阻塞,即等待特定时长(线程池的keepAliveTime参数)后如果未取到就销毁线程。
阻塞环节的源码方法调用链如下,感兴趣的朋友可以沿路看看,由于涉及ReentrantLock、AQS,若完整讲述流程会很长,所以这里只是速览。
从线程执行任务入口的run()方法开始——
ThreadPoolExecutor$Worker#run()-->runWorker(this)-->getTask()-->
核心线程:workQueue.take()
临时线程:workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS)
以核心线程、LinkedBlockingQueue为例继续深入take(),其内部的部分环节列举如下——
- 获取可中断锁:takeLock.lockInterruptibly()
- 如果队列空,执行notEmpty.await()-->
阻塞:LockSupport.park(this)
后来被唤醒:acquireQueued-->返回true - 从队头取任务:dequeue()
- 解锁:takeLock.unlock()
五、创建线程并启动的原理
本文的起点话题是Java创建线程,那么new Thread()、start()内部究竟做了什么事呢?下面分Java层、JVM层展开介绍。
5.1、Java层
下图主要从Java层源码视角看:new Thread()和start()执行任务,是如何串联、关联的。 此图是站在复习总结的视角画的,当你已亲自在源码里兜转一圈后,再看图可能别有一番风味:
上图左侧约1/8是备注,右侧其余内容主要2个看点:
- 各种定义线程任务方式,最终“殊途同归”地调了Thread的init方法。
- 线程执行任务时都是执行Thread实例的run(),但有两种情况:
①继承Thread类方式是重写了Thread的run()
②其他方式都是给target字段赋值了传入的Runnable实例,这样到时执行任务就是执行target对应的run()。
Thread的run()源码如下:
@Override
public void run() {
if (target != null) {
target.run();
}
}
5.2、JVM层
上面在Java层概览了线程对象创建、线程启动,其中的 native 方法 start0 并未展开介绍。
网上已有些文章介绍start0源码,源码细节多所以篇幅长,这里整理重点关键介绍(Linux平台)。
从 start0 到创建OS线程为止,方法调用链文字版如下,括号内是方法所在JVM源码文件:
start0 (Thread.c) ➜ JVM_StartThread (jvm.cpp) ➜ new JavaThread (thread.cpp) ➜ os::create_thread (os_linux.cpp) ➜ pthread_create (Linux的glibc库)
那么start0内部具体做了什么呢?如果只想宏观了解下,start0 可以看做三步走:
- 创建阶段:主线程创建JVM层的线程对象、以及对应的OS层的原生线程,随后主线程阻塞;
- 握手一(初始化):子线程进行初始化,完成后唤醒主线程,随后子线程阻塞;
- 握手二(注册):主线程将子线程加入到JVM全局线程链表(GC扫描用),然后唤醒子线程,start0返回。
不难发现,start0里创建了OS线程只不过才第一步而已,后面还上演了两步握手的“你等我、我等你”的戏码。为什么?父线程需要确保子线程已初始化,子线程需要确保自己已被GC管理才能开始运行,期间也完成了各层线程对象之间的链接(JVM-->OS、JVM-->Java)。
下图是对JVM层方法源码(JDK8)流程的图解,篇幅关系暂不展开介绍,简介:
- 上半部分是当前线程(主线程),下半部分是子线程;
- 3个虚线箭头。其中,虚箭头2对应第一次握手已完成,虚箭头3对应第二次握手已完成;
- 文字末尾标有①②③④的是各层线程对象之间的关联环节
观察上图子流程的流程,发现第一个函数是java_start (os_linux.cpp)。至此,我们终于知道子线程的方法栈的“第一个栈帧”到底是什么了。
因此,通过Thread.start()创建的线程,其方法栈前几个栈帧对应的方法是:os::java_start ➜ JavaThread::run() ➜ … ➜ java.lang.Thread.run()
前面小节出现过的层层封装图,在此补充后再次贴出,补充点为:
- 加了两个指向箭头;
- 标注的①②③④的值,均可在上一个大图找到对应的①②③④设置环节
六、尾声
总结
至此,我们主要探讨了:
- Java创建线程说法的含义与由来;
- Java传统线程与OS线程的层层封装关系;
- 虚拟线程相对于传统Java线程的飞跃提升与局限;
- Java/OS/Linux各线程状态模型的微妙联系与差异;
- Java各阻塞状态的差异,BLOCKED晦涩的特殊性;
- 线程池面对是否IO密集型任务的扩容/排队逻辑的差异;
- 创建线程实例的Java层源码的殊途同归,start0()的JVM层源码逻辑的谨小慎微。
“不忘初心”地复盘下开篇问题:将new Thread()说成“创建线程”,一般没什么实质负面影响;不过对于初学者,这种说法颇为“黑盒”,容易让理解停留在表象,不利于生产实践中对相关问题、选择的理解把握。
结语
本文是由一个问题引出各维度的发散,囿于篇幅很多主题只能点到为止;囿于水平和精力,难免有理解偏差的地方,权当抛砖引玉,提供一种新的思考路径。
从Java线程创建方式的争议开始,沿着脉络上的疑问四处追击,仿佛是无尽的多分支旅程;但每走一步,便感觉亮起一盏光,愿这光或能与诸君共享。
参考资料
docs.oracle.com/en/java/jav…
docs.oracle.com/javase/8/do…
docs.oracle.com/javase/spec…
docs.oracle.com/en/java/jav…
openjdk.org/jeps/444
openjdk.org/jeps/491
juejin.cn/post/724139…
juejin.cn/post/730903…
juejin.cn/post/749211…
juejin.cn/post/729665…
juejin.cn/post/684490…
blog.csdn.net/kid_2412/ar…
tech.meituan.com/2020/04/02/…
……
《Java核心技术 第10版》
《深入理解Java虚拟机 第3版》
《剑指JVM:虚拟机实践与性能调优》
《Java并发编程的艺术 第2版》
《深入理解Linux网络》
《深入理解Linux进程与内存》
Gemini/Claude/ChatGPT等