目录
- 目录
- 引子:这几个东西为什么总被拿来一起比较
- 先统一一个视角:它们本质上都在解决调度问题
- JDK 平台线程:1:1 映射内核线程
- JDK 虚拟线程:M:N 调度,但语义仍然是 Thread
- Go 协程:语言运行时主导的 GMP 调度模型
- Netty Event Loop:少量线程驱动大量连接
- 把四者放在一张图里看
- 它们之间到底是什么关系
- 一个关键问题:阻塞到底意味着什么
- JDK 阻塞队列全景梳理
- 阻塞队列之间的核心区别
- 这些队列和线程模型怎么配合
- 怎么选:按场景给结论
- 最后总结
引子:这几个东西为什么总被拿来一起比较
很多人第一次接触这几个概念时,会感觉它们像是同一层面的东西:
- JDK 线程
- JDK 虚拟线程
- Go 协程
- Netty Event Loop
- JDK 阻塞队列
但它们其实分布在不同层次:
线程 / 协程 / 虚拟线程解决的是执行单元怎么表示调度器解决的是谁来跑、什么时候跑Event Loop解决的是I/O 事件如何复用少量线程阻塞队列解决的是任务或数据如何在并发单元之间传递
把这些层次混在一起,就很容易得出错误结论,比如:
- “虚拟线程就是 Java 版协程”
- “Netty loop 就是协程调度器”
- “用了虚拟线程,就不需要线程池和队列了”
- “所有生产者消费者问题都用 LinkedBlockingQueue 就行”
这篇文章的目标,就是把这些东西放到一张统一地图里。
先统一一个视角:它们本质上都在解决调度问题
我们先不看 API,只看本质。服务端程序总会面对三个问题:
- 任务很多,怎么表达“有一段逻辑要执行”?
- 线程有限,怎么把这些逻辑调度到 CPU 上?
- I/O 很慢,等待网络和磁盘时,怎么别把线程白白耗死?
不同技术的回答不一样:
- JDK 平台线程:一个任务通常绑一个操作系统线程
- JDK 虚拟线程:任务还是写成“像线程一样”的同步代码,但底层换成 JVM 调度
- Go 协程:任务写成 goroutine,由 Go runtime 统一调度
- Netty Event Loop:尽量别让连接各占一条线程,而是少量线程轮询 I/O 事件
- BlockingQueue:在这些执行单元之间传递任务、做背压、做解耦
所以,它们不是完全互斥的关系,而是有的互相替代,有的互相配合。
JDK 平台线程:1:1 映射内核线程
传统 Java 线程,也就是平台线程(platform thread),本质上还是 java.lang.Thread 对应一个操作系统线程。
它的特点很直接:
- Java 代码里的阻塞调用,通常就意味着内核线程也跟着阻塞
- 线程切换依赖操作系统调度
- 栈空间、线程元数据、上下文切换成本都比较高
- 数量一多,系统开销会明显上来
这套模型的优点是简单:
- 思维模型直观
- 调试友好
- 大量老库天然兼容
缺点也明显:
- 线程不是“零成本抽象”
- 高并发 I/O 场景下,容易演变成“线程数膨胀”
- 所以大家才发明了线程池、异步回调、Reactor、事件循环这些东西来节流
这也是为什么在很长一段时间里,Java 服务端都绕着一个核心现实打转:
线程很贵,所以必须复用。
JDK 虚拟线程:M:N 调度,但语义仍然是 Thread
JDK 21 开始,虚拟线程正式可用。它最重要的价值不是“更快”,而是把 Java 并发模型重新拉回了更自然的同步编程方式。
它是什么
虚拟线程仍然暴露为 Thread,你照样可以:
Thread.startVirtualThread(() -> {
// 看起来和普通线程代码几乎一样
});
但底层不再是一条 Java 线程固定绑定一条内核线程,而是:
- 大量虚拟线程
- 映射到少量平台线程(carrier thread)
- 由 JVM 调度运行
这就是典型的 M:N 思路。
它解决了什么
它解决的是:I/O 密集型业务想写成同步代码,但又不想为每个请求养一条昂贵的内核线程。
例如:
- HTTP 请求处理
- RPC 调用链
- 数据库访问
- MQ 消费逻辑
以前你常常要在两种痛苦里二选一:
- 用平台线程,同步代码好写,但线程数顶不住
- 用异步回调/响应式,线程效率高,但业务代码被拆碎
虚拟线程试图把这两件事同时拿到:
- 业务代码写起来仍像阻塞式同步代码
- 阻塞等待时,JVM 能把执行权让出来,让 carrier thread 去跑别的任务
它不是什么
虚拟线程不是“所有阻塞都自动无成本”。
你需要分清两类阻塞:
- 可卸载的阻塞:比如大部分基于 JDK 支持的 socket I/O、
sleep、park - 钉住 carrier 的阻塞:比如某些持有监视器进入阻塞区、某些 native 调用、某些老式库的不可协作阻塞
一旦发生 pinning,虚拟线程的收益就会被打折,因为 carrier thread 没法被及时腾出来。
一个很关键的结论
虚拟线程并没有否定线程模型,而是让“线程”这个编程抽象重新变便宜。
所以它和 Go goroutine 很像,但又不完全一样:
- 相似点:都想让“一个并发任务一个轻量执行体”成为现实
- 不同点:Java 保留了
Thread语义和现有生态兼容性,Go 则把 goroutine 作为语言一级抽象
Go 协程:语言运行时主导的 GMP 调度模型
Go 的 goroutine 从一开始就不是“轻量版系统线程”,而是 Go runtime 的核心执行单元。
先看 GMP
Go 调度常用 GMP 来描述:
G:goroutine,任务本身M:machine,对应底层线程P:processor,调度上下文,持有本地运行队列
可以把它理解为:
- goroutine 很轻
- runtime 会把很多 G 分配到少量 M 上执行
- P 负责让调度尽量局部化,减少全局竞争
Go 为什么能把协程做得这么顺
因为它是一整套语言级设计,不只是一个库:
- 栈可以按需扩缩
- 调度器完全内建
- channel 是一等公民
- 网络 I/O 和 runtime 集成很深
- 抢占式调度能力也由 runtime 控制
这意味着 Go 在设计之初就接受了一个前提:
并发模型由语言运行时统一接管。
而 Java 的历史包袱更大,需要兼容几十年线程语义和类库生态,所以虚拟线程的落点会更保守、更兼容。
Go 协程和虚拟线程的差异
可以这样理解:
- goroutine 更像“语言原生的协程任务”
- virtual thread 更像“被 JVM 重新实现过的便宜线程”
工程上最明显的区别有几个:
- Go 更强调 channel、select、context 这些配套并发原语
- Java 虚拟线程更强调兼容现有同步阻塞风格
- Go runtime 对网络、多路复用、调度的控制更强
- Java 需要在 JVM、JDK、已有库之间维持兼容边界
所以不要简单说“虚拟线程 = Java 协程”。更准确的说法是:
虚拟线程是 Java 对轻量并发执行体的一次系统级重构,但外部编程模型仍然叫 Thread。
Netty Event Loop:少量线程驱动大量连接
Netty 的核心思想不是“把线程变轻”,而是:
既然线程贵,那就别让每个连接独占线程。
它主要依赖的是 Reactor / Event Loop 模型。
Event Loop 在干什么
一个 Netty EventLoop 通常做三件事:
- 轮询 I/O 事件,比如
accept、read、write - 执行该 loop 绑定的普通任务
- 执行定时任务
而且 Netty 有一个非常重要的线程亲和性约束:
- 一个
Channel在生命周期内通常绑定到一个固定的EventLoop - 该
Channel上的大多数 handler 逻辑都在这条线程上跑
这带来两个好处:
- 避免了很多跨线程竞争
- 很多 handler 可以近似按单线程思维写
为什么 Netty 不喜欢阻塞
因为 loop 线程少,而且每条线程要服务很多连接。
如果你在 channelRead 里干了件阻塞 300ms 的事情,比如:
- 同步查数据库
- 调远程 HTTP
Thread.sleep
那这个 loop 在这 300ms 里就处理不了它负责的其他连接。于是问题不是“卡住一个请求”,而是“卡住一串连接”。
所以 Netty 最核心的纪律就是:
I/O loop 线程上不要做长时间阻塞和重计算。
Netty 和虚拟线程是什么关系
它们不是一回事。
- Netty Event Loop:I/O 多路复用模型
- 虚拟线程:轻量线程调度模型
但两者可以配合:
- 前端网络层继续用 Netty 处理高并发连接
- 进入业务逻辑后,把阻塞型代码卸载到别的执行器
- 这个执行器可以是平台线程池,也可以是虚拟线程 executor
所以“虚拟线程会不会取代 Netty”,答案通常是否定的。
更准确地说:
- 虚拟线程能降低很多同步阻塞编程的成本
- 但高性能网络框架里,多路复用和事件循环依然有价值
把四者放在一张图里看
可以把它们抽象成下面这张图:
业务任务
├── 平台线程:任务 -> 线程 -> 内核线程
├── 虚拟线程:任务 -> 虚拟线程 -> carrier 平台线程 -> 内核线程
├── Go 协程:任务 -> goroutine -> Go runtime 调度 -> 内核线程
└── Netty:连接/事件 -> EventLoop -> 少量内核线程
如果再进一步压缩成一句话:
- 平台线程:靠 OS 调度
- 虚拟线程:靠 JVM 调度
- Go 协程:靠 Go runtime 调度
- Netty Event Loop:靠事件驱动减少线程占用
它们之间到底是什么关系
1. 平台线程和虚拟线程,是直接替代关系
如果你的任务原本是“一个请求一条线程”的写法,那么虚拟线程就是平台线程的直接升级路线之一。
2. Go 协程和虚拟线程,是相似目标下的不同实现路线
两者都在追求:
- 更便宜的并发单元
- 更高的并发规模
- 尽量保留直观的顺序代码风格
但 Go 是语言原生设计,Java 是生态兼容式演进。
3. Netty Event Loop 和虚拟线程,不是简单替代关系
Netty 主要解决的是高并发网络 I/O 的事件分发问题,虚拟线程主要解决的是同步阻塞代码的并发承载问题。
两者交集有,但职责不一样。
4. 阻塞队列是配套设施,不是调度模型本身
BlockingQueue 不负责决定“线程怎么调度”,它负责的是:
- 任务缓存
- 线程间交接
- 流量削峰
- 背压
也就是说,队列经常服务于线程池、消费者模型、事件处理链,但它自己不是线程模型。
一个关键问题:阻塞到底意味着什么
“阻塞”这两个字,在不同模型里后果完全不同。
在平台线程里
阻塞通常意味着:
- 当前线程卡住
- 对应的内核线程也卡住
在虚拟线程里
阻塞要分情况:
- 如果 JVM 能把它挂起并释放 carrier,那成本就低很多
- 如果 pin 住 carrier,本质上还是占着底层线程
在 Go 协程里
如果是 runtime 能感知的阻塞,比如网络 I/O,runtime 会尝试让别的 goroutine 顶上去。
在 Netty Event Loop 里
阻塞最危险,因为 loop 线程本来就少。卡一个 loop,等于拖累一组连接。
这也是为什么很多人迁移到虚拟线程后,体感提升明显;但如果你在 Netty loop 上直接写阻塞业务,问题仍然不会自己消失。
JDK 阻塞队列全景梳理
JDK 常用阻塞队列主要在 java.util.concurrent 包下。不要只记名字,关键是理解下面几个维度:
- 底层结构:数组、链表、堆、无缓冲、双端
- 是否有界
- 是否按 FIFO
- 是否允许生产者和消费者直接移交
- 是否适合做线程池工作队列
- 是否适合做定时/延迟任务
下面按工程使用频率来讲。
ArrayBlockingQueue
ArrayBlockingQueue 是基于数组实现的有界 FIFO 队列。
特点:
- 固定容量
- 内存连续,结构紧凑
- 明确的上限,天然有背压
- 一般吞吐和内存可控性都不错
适合场景:
- 明确希望限制堆积量
- 生产速度可能高于消费速度,需要硬性限流
- 线程池或任务处理系统里不希望任务无限堆积
典型问题:
- 容量一旦定小了容易频繁阻塞
- 定太大又可能掩盖系统过载
一句话概括:
想要简单、稳定、可控的有界队列,优先考虑它。
LinkedBlockingQueue
LinkedBlockingQueue 是链表结构的 FIFO 队列,可以有界,也可以近似无界。
特点:
- 默认容量如果不传,接近
Integer.MAX_VALUE - 吞吐通常不错
- 插入删除不需要数组搬移
- 但节点对象多,内存占用通常高于数组队列
适合场景:
- 消费速率大多数时候跟得上,只需要一个通用任务队列
- 确实需要比数组队列更灵活的容量
风险点:
- 默认“超大容量”非常危险
- 在线程池里配无界
LinkedBlockingQueue,很容易把问题从“拒绝任务”变成“堆内存打满”
这个坑非常经典。比如固定线程池默认就常常绑定这种思路,结果:
- 核心线程数不变
- 多余请求持续进队
- 延迟越来越高
- 最后变成内存和响应时间同时恶化
一句话概括:
它不是不能用,问题在于默认无界经常掩盖系统过载。
SynchronousQueue
SynchronousQueue 严格来说不是“存储型队列”,因为它几乎不存元素。
它的语义是:
- 生产者
put一个元素时,必须等消费者来接 - 消费者
take时,必须等生产者来交 - 本质上是一次直接移交(handoff)
特点:
- 零容量
- 不做缓存
- 非常适合“任务来了就立即找线程接手”
典型使用场景:
newCachedThreadPool背后的核心队列就是它- 想要让任务尽量直接交给工作线程,而不是排队堆积
它的系统行为是这样的:
- 有空闲线程,就直接接任务
- 没空闲线程,就倾向于新建线程
- 达到线程上限后,再触发拒绝
一句话概括:
它强调立即交接,不强调排队缓存。
PriorityBlockingQueue
PriorityBlockingQueue 是带优先级的无界阻塞队列,底层是堆。
特点:
- 元素按优先级出队,不保证 FIFO
- 默认无界
- 元素需要可比较,或者传入
Comparator
适合场景:
- 调度系统
- 优先级任务执行
- 不同业务请求需要分等级处理
注意点:
- 它是无界队列,仍然有堆积风险
- 同优先级任务的顺序不能简单按提交顺序理解
一句话概括:
当“先后顺序”取决于优先级而不是提交时间时,用它。
DelayQueue
DelayQueue 可以理解成“只能在到期后取出的优先级队列”。
特点:
- 元素必须实现
Delayed - 没到时间不能出队
- 底层也是按过期时间管理
适合场景:
- 本地延迟任务
- 超时控制
- 缓存过期扫描
- 重试任务调度
注意点:
- 它更像调度容器,不适合普通生产者消费者队列
- 如果任务量很大、时钟精度要求高、集群一致性要求强,往往需要更专业的调度系统
LinkedTransferQueue
LinkedTransferQueue 是一个高吞吐、无界的链表队列,同时支持 transfer 语义。
它比普通 BlockingQueue 多了一类能力:
- 可以普通入队
- 也可以要求“必须被消费者接收后我才返回”
这让它兼具两种模式:
- 缓存型队列
- 直接移交型队列
适合场景:
- 高并发消息传递
- 某些需要生产者感知“消息已被接手”的场景
但它没有那么“默认安全”,因为:
- 无界
- 语义比普通 FIFO 队列复杂
- 团队不熟悉时容易误用
LinkedBlockingDeque
LinkedBlockingDeque 是双端阻塞队列。
特点:
- 可以从头尾两端插入和获取
- 可以实现 FIFO,也可以做近似 LIFO
- 有界或无界都可以
适合场景:
- 工作窃取的某些简化实现
- 既需要普通排队,也需要插队/尾部消费
- 特定调度策略,比如高优先级任务头插
如果你只是普通的生产者消费者,大多数时候它不是第一选择;但一旦你的调度策略需要双端操作,它就有价值。
阻塞队列之间的核心区别
可以先看一张压缩表:
| 队列 | 结构 | 有界性 | 顺序 | 是否缓存元素 | 典型场景 |
|---|---|---|---|---|---|
ArrayBlockingQueue | 数组 | 有界 | FIFO | 是 | 稳定有界任务队列 |
LinkedBlockingQueue | 链表 | 常见为无界/可有界 | FIFO | 是 | 通用生产者消费者 |
SynchronousQueue | 直接移交 | 无容量 | 无普通 FIFO 意义 | 否 | 立即交接、线程池直派 |
PriorityBlockingQueue | 堆 | 无界 | 按优先级 | 是 | 优先级任务调度 |
DelayQueue | 堆 | 无界 | 按到期时间 | 是 | 延迟任务、超时处理 |
LinkedTransferQueue | 链表 | 无界 | 近似 FIFO | 可缓存也可移交 | 高并发消息交接 |
LinkedBlockingDeque | 双端链表 | 可有界 | 双端 | 是 | 双端调度、插队 |
如果再用工程语言翻译一次:
- 你要“限制流量”时,优先看有界队列
- 你要“直接交接不堆积”时,优先看
SynchronousQueue - 你要“按优先级/时间调度”时,看
PriorityBlockingQueue和DelayQueue - 你要“语义简单”时,优先普通 FIFO 队列
这些队列和线程模型怎么配合
1. 平台线程 + BlockingQueue
这是最传统的模式:
- 生产者投递任务到队列
- 固定数量工作线程从队列取任务执行
优点是稳定、成熟、容易控流。
2. 虚拟线程 + BlockingQueue
虚拟线程并不会让 BlockingQueue 失效,但使用方式会变。
典型变化是:
- 如果任务本身就是独立 I/O 请求,很多时候可以直接“一任务一虚拟线程”
- 不一定还要像以前那样用复杂线程池去复用线程
- 但如果你仍然需要背压、削峰、分层隔离,队列依然有价值
也就是说,虚拟线程降低的是“线程复用”的必要性,不是否定:
- 容量控制
- 流量整形
- 异步解耦
3. Netty Event Loop + BlockingQueue
Netty loop 本身内部也有任务队列,但业务上你更常见的模式是:
- loop 线程收到 I/O 事件
- 把耗时任务转交到业务线程池
- 业务线程池背后再用某种 BlockingQueue 承载任务
这时队列的职责是:
- 不要让 loop 线程自己阻塞
- 把重活转移出去
- 对业务并发做上限控制
4. Go 协程 + channel
Go 世界更多是用 channel,而不是 JDK 这类 BlockingQueue。
两者思路接近,但重点不同:
- BlockingQueue 更偏 Java 并发包里的通用容器
- channel 是 Go 并发模型的一等原语,和调度、select、context 配套得更紧
所以不要试图机械地做一一映射。Go 的 channel 不只是“另一个队列”,它还是并发通信语义的一部分。
怎么选:按场景给结论
场景 1:普通 Java 业务系统,请求很多,代码大多是同步阻塞风格
优先考虑虚拟线程。
原因:
- 改造成本通常低于全面响应式
- 业务代码可读性更好
- 对典型 I/O 密集型服务收益明显
前提是要检查:
- JDBC 驱动、客户端库、native 调用是否会严重 pin carrier
- 是否存在大量 synchronized + 长阻塞混用
场景 2:极高连接数、极强网络 I/O 压力、需要精细事件控制
优先考虑 Netty/Reactor 模型。
原因:
- 多路复用和事件循环在这类场景依然很强
- 对连接管理、pipeline、零拷贝、协议编解码更成熟
如果业务层有阻塞逻辑:
- 不要直接压在 loop 线程上
- 可以转到平台线程池或虚拟线程执行器
场景 3:固定工作线程处理任务,希望系统永远有明确上限
优先考虑 ArrayBlockingQueue 或有界 LinkedBlockingQueue。
原因:
- 有界队列才有真实背压
- 系统过载时行为更可预测
场景 4:任务不想排队,希望尽量立刻交给线程执行
优先考虑 SynchronousQueue。
它适合:
- 直接移交
- 短任务突发
- 倾向扩线程而不是堆队列
场景 5:任务有轻重缓急
优先考虑 PriorityBlockingQueue。
但一定补上:
- 总量限制
- 监控
- 降级策略
因为它本身是无界的。
场景 6:本地超时、延迟、重试调度
优先考虑 DelayQueue。
如果任务规模再上去,就要评估是否该上:
- 时间轮
- 专门调度框架
- 分布式延迟队列
最后总结
最后把最容易混淆的几点压缩成几句话:
- 平台线程、虚拟线程、Go 协程,讨论的是执行单元和调度方式
- Netty Event Loop,讨论的是I/O 事件驱动和线程复用
- BlockingQueue,讨论的是任务/数据如何排队、交接、背压
它们的关系不是谁彻底消灭谁,而是:
- 虚拟线程在很多 Java 同步 I/O 场景下,确实能替代过去一部分“线程池 + 异步回调”的复杂写法
- Go 协程和虚拟线程目标相近,但语言/runtime 一体化程度不同
- Netty 不会因为虚拟线程出现就立刻失去意义,因为它解决的是高并发网络 I/O 多路复用问题
- 阻塞队列在虚拟线程时代仍然重要,因为背压、削峰、隔离这些问题并没有消失
如果只记一个判断框架,可以记住这句:
先判断你在解决的是“执行体成本”“I/O 多路复用”还是“任务排队与背压”,再选平台线程、虚拟线程、goroutine、event loop 或 blocking queue。