目录

引子:这几个东西为什么总被拿来一起比较

很多人第一次接触这几个概念时,会感觉它们像是同一层面的东西:

  • JDK 线程
  • JDK 虚拟线程
  • Go 协程
  • Netty Event Loop
  • JDK 阻塞队列

但它们其实分布在不同层次:

  • 线程 / 协程 / 虚拟线程 解决的是执行单元怎么表示
  • 调度器 解决的是谁来跑、什么时候跑
  • Event Loop 解决的是I/O 事件如何复用少量线程
  • 阻塞队列 解决的是任务或数据如何在并发单元之间传递

把这些层次混在一起,就很容易得出错误结论,比如:

  • “虚拟线程就是 Java 版协程”
  • “Netty loop 就是协程调度器”
  • “用了虚拟线程,就不需要线程池和队列了”
  • “所有生产者消费者问题都用 LinkedBlockingQueue 就行”

这篇文章的目标,就是把这些东西放到一张统一地图里。

先统一一个视角:它们本质上都在解决调度问题

我们先不看 API,只看本质。服务端程序总会面对三个问题:

  1. 任务很多,怎么表达“有一段逻辑要执行”?
  2. 线程有限,怎么把这些逻辑调度到 CPU 上?
  3. 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、sleeppark
  • 钉住 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 通常做三件事:

  1. 轮询 I/O 事件,比如 acceptreadwrite
  2. 执行该 loop 绑定的普通任务
  3. 执行定时任务

而且 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
  • 你要“按优先级/时间调度”时,看 PriorityBlockingQueueDelayQueue
  • 你要“语义简单”时,优先普通 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。