1. 引言
在本篇文章中,我们将深入探讨并发编程的基本概念,并对比 Java 和 Kotlin 两种语言在实现轻量级并发上的不同路径。
重点会放在 Kotlin 的协程(coroutines)和 Java 正在推进的 Project Loom 所提出的虚拟线程(virtual threads)之间的比较。这些技术旨在解决传统线程模型在高并发场景下的资源开销问题,是现代高性能应用不可忽视的技术方向。
如果你还在用 new Thread()
或者过度依赖线程池处理大量 I/O 密集型任务,那这篇文章可能会让你重新思考架构设计。
2. 并发基础:从操作系统说起
并发的本质是将程序分解为多个可独立执行、顺序无关或部分有序的组件。目标是让多个任务协同工作而不影响最终结果。
在操作系统层面,一个运行中的程序实例被称为 进程(Process)。内核通过为每个进程分配独立的地址空间来实现隔离,保证安全性和容错性。但由于进程拥有独立的内存、文件句柄等资源,创建代价高昂。
更关键的是,进程间通信(IPC)复杂且低效。
这时候,内核级线程(Kernel-level Threads) 就派上用场了:
线程是进程内的独立执行流。一个进程可以包含多个线程,它们共享地址空间和文件句柄,但各自维护独立的调用栈。这使得线程间通信变得简单高效。
然而,内核直接管理和调度这些线程,包括上下文切换、抢占式调度等,导致线程操作成本高,性能受限。
另一种选择是 用户级线程(User-level Threads),这类线程由运行时环境(如 JVM)在用户空间管理:
用户级线程不被内核感知,因此创建、切换速度快得多。常见的映射模型有一对一、多对一等。其核心优势在于:由运行时系统自主调度,可复用少量内核线程承载大量用户线程。
当然,这也要求用户态调度器与内核调度器之间做好协调。
3. 编程语言中的并发抽象
操作系统提供了底层并发原语,但现代编程语言在此基础上构建了更高层的抽象。
✅ Java 提供了 Thread
类作为一级公民支持并发。但底层仍一对一映射到内核线程,属于“重量级”线程。
❌ 这种模式虽然直观,但在高并发下极易耗尽资源 —— 想象一下每请求启动一个线程的 Tomcat 模型。
相比之下,许多语言原生支持“轻量级线程”:
- Kotlin:协程(coroutines)
- Go:goroutines
- Erlang:processes
- Haskell:lightweight threads
这些机制的核心思想是:在运行时内部完成轻量级线程的调度,采用协作式而非抢占式调度,大幅提升效率。
它们通常以少量内核线程为载体,承载成千上万个轻量级执行单元,显著降低上下文切换开销。
⚠️ 虽然 Java 目前没有原生支持,但这正是 Project Loom 的目标。
4. 其他并发模型:响应式编程
除了基于线程/协程的模型,还有一种完全不同的思路 —— 响应式编程(Reactive Programming)。
它将程序流视为一系列异步事件的流动,代码表现为监听、处理并发布事件的函数链。
典型代表有 RxJava、Project Reactor 等。我们常用“弹珠图”描述其数据流:
响应式模型的关键特点:
- 使用固定数量线程(通常等于 CPU 核心数)
- 非阻塞 I/O,避免线程挂起
- 单线程也能高效处理大量并发
✅ 优势:资源利用率高,适合高吞吐 I/O 场景
❌ 劣势:学习曲线陡峭,调试困难,容易陷入“回调地狱”的变种 —— “操作符地狱”
更重要的是,响应式编程难以实现结构化并发(Structured Concurrency),这也是协程的一大优势。
5. 结构化并发的重要性
传统的并发模型存在一个致命问题:无法清晰追踪执行生命周期。
举个例子:你调用一个方法,该方法内部启动了几个后台任务。当方法返回时,你能确定所有子任务都完成了吗?不能。
这种“失控”的并发很容易导致资源泄漏、竞态条件等问题。
而结构化并发的理念是:
✅ 控制流一旦分叉,就必须重新汇合
这意味着所有并发任务的作用域必须严格嵌套,父作用域结束前必须等待所有子任务完成。
这极大提升了代码的可读性、可维护性和错误处理能力。
⚠️ 响应式编程很难做到这一点,因为它的订阅关系往往是松散的、跨层级的。
而 Kotlin 的协程天生支持结构化并发,这是其一大杀手锏。
6. Kotlin 是如何做到的?
Kotlin 是 JetBrains 推出的 JVM 语言,兼容 Java 字节码,广泛用于 Android 和后端开发。
它通过一个丰富的库 kotlinx.coroutines
实现了协程支持,早在 1.3 版本就正式发布。
尽管 JVM 本身不支持轻量级并发,但 Kotlin 利用编译器魔法实现了高效的协程模型。
6.1. 协程到底是什么?
协程本质上是可暂停和恢复的通用子程序。最早出现在 1950 年代的汇编语言中。
与内核线程的关键区别在于:
对比项 | 内核线程 | 协程 |
---|---|---|
调度方式 | 抢占式(Preemptive) | 协作式(Cooperative) |
控制权交出 | 被动由 OS 决定 | 主动调用 yield |
上下文开销 | 高(完整栈 + 内核态) | 极低 |
示例代码:
coroutine
loop
while some_condition
some_action
yield
协程在循环中主动让出控制权,避免阻塞线程。最关键的是:
✅ 成千上万个协程可以复用同一个内核线程
但这意味着协程不提供真正的并行性(parallelism),仅提供并发性(concurrency)。
6.2. Kotlin 协程实战
Kotlin 提供多种协程构建器:
launch
:启动协程,不返回结果async
:启动协程,返回Deferred<T>
runBlocking
:阻塞当前线程直到协程完成(测试专用)
协程总是绑定在一个 CoroutineScope 中:
suspend fun doSomething() {
// 模拟耗时计算
}
suspend
是 Kotlin 的关键字,标记这是一个可挂起函数。注意:
⚠️ 挂起函数只能从协程或其他挂起函数中调用
启动协程示例:
GlobalScope.launch {
doSomething()
// 继续其他操作
}
但强烈建议不要滥用 GlobalScope
,否则会破坏结构化并发原则。
6.3. 结构化并发实践
正确做法是创建应用级别的 CoroutineScope
:
var job = Job()
val coroutineScope = CoroutineScope(Dispatchers.Main + job)
coroutineScope.launch {
doSomething()
// 其他操作
}
关键组件说明:
Dispatcher
:决定协程运行在哪个线程(如主线程、IO 线程等)Job
:管理协程生命周期、取消、父子关系
✅ 所有通过该 scope 启动的协程,都可以通过 job.cancel()
一键取消,防止泄漏。
这就是结构化并发的体现:父级负责清理所有子级。
6.4. 底层原理揭秘
Kotlin 协程是如何实现的?答案是:编译期转换为状态机 + Continuation Passing Style(CPS)
具体来说:
- 编译器将每个
suspend
函数转换为带Continuation
参数的形式:
// 原始
suspend fun doSomething()
// 编译后等价于
fun doSomething(continuation: Continuation): Any?
- 所有挂起点被识别并打上标签,生成一个巨大的
switch
状态机。 Continuation
封装了函数在挂起点的状态(局部变量、执行位置等)。
这类似于回调机制,但由编译器自动处理,开发者无感。
你可以把协程看作是一个自动拆解和重组的函数状态机。
7. Java 的未来:Project Loom
Java 自诞生起就通过 Thread
类提供并发支持。早期曾尝试过“绿色线程”(Green Threads),即用户态线程,但因无法利用多核而放弃。
此后,JVM 线程一直是一对一映射到内核线程,简单直接但资源消耗大。
随着微服务和高并发需求激增,传统线程模型逐渐力不从心。
7.1. Java 并发简史
- JDK 1.0:引入
Thread
类,为跨平台承诺实现绿色线程 - JDK 1.3:放弃绿色线程,全面转向内核线程模型
- 后续版本:通过
ExecutorService
、CompletableFuture
改进编程模型
现状:每个请求一个线程的模型已无法应对百万级并发
7.2. 当前模型的痛点
- 线程创建成本高(默认栈大小 1MB)
- 上下文切换开销大
- 线程池配置复杂,易出错
- 阻塞 I/O 导致线程闲置
解决方案要么是事件循环(Node.js 风格),要么是响应式编程,但都牺牲了代码的直观性。
7.3. Project Loom 的愿景
Project Loom 的目标是:
✅ 在 JVM 上引入轻量级并发模型,解耦逻辑线程与内核线程
核心提案包括:
- 虚拟线程(Virtual Threads):轻量级用户线程
- 有限延续(Delimited Continuations):底层执行片段抽象
- 尾递归优化
目标是让开发者能像使用普通线程一样使用虚拟线程,但成本极低。
7.4. Continuation 是什么?
Continuation 可理解为“程序执行到某一点的剩余部分”。Loom 计划将其暴露为公共 API:
class _Continuation {
public _Continuation(_Scope scope, Runnable target)
public boolean run()
public static _Continuation suspend(_Scope scope, Consumer<_Continuation> ccc)
public ? getStackTrace()
}
⚠️ 注意:这只是原型 API,实际命名可能变化。
Continuation 不仅用于虚拟线程,还可实现生成器(Generator)、协程等高级控制流。
7.5. 虚拟线程的实现
虚拟线程基于 Continuation 构建,内部持有:
continuation
:执行状态scheduler
:调度器(默认为ForkJoinPool
)task
:待执行任务
简化表示:
class _VirtualThread {
private final _Continuation continuation;
private final Executor scheduler;
private volatile State state;
private final Runnable task;
private enum State { NEW, LEASED, RUNNABLE, PAUSED, DONE; }
public _VirtualThread(Runnable target, Executor scheduler) {
// 初始化
}
public void start() {
// 启动逻辑
}
public static void park() {
_Continuation.suspend(_FIBER_SCOPE, null);
}
public void unpark() {
// 恢复执行
}
}
默认调度器使用 ForkJoinPool
的工作窃取算法,确保负载均衡。
7.6. 当前进展预览
Project Loom 已在 Java 16+ 提供早期试用版。可通过 JDK 下载页 获取实验性构建。
创建传统线程的方式略有变化:
Runnable printThread = () -> System.out.println(Thread.currentThread());
ThreadFactory kernelThreadFactory = Thread.builder().factory();
Thread kernelThread = kernelThreadFactory.newThread(printThread);
kernelThread.start();
创建虚拟线程仅需加 .virtual()
:
Runnable printThread = () -> System.out.println(Thread.currentThread());
ThreadFactory virtualThreadFactory = Thread.builder().virtual().factory();
Thread virtualThread = virtualThreadFactory.newThread(printThread);
virtualThread.start();
✅ 最大的惊喜是:虚拟线程仍是 Thread
的实例!
这意味着现有 API 几乎无需修改即可享受轻量级并发的好处。
⚠️ 但注意:ThreadGroup
、ThreadLocal
等机制对虚拟线程的行为可能不同,需谨慎使用。
8. Java 虚拟线程 vs Kotlin 协程:终极对比
两者都致力于解决高并发下的资源瓶颈,但实现哲学截然不同。
8.1. 栈式 vs 无栈延续(Stackful vs Stackless)
特性 | Kotlin 协程(无栈) | Java 虚拟线程(栈式) |
---|---|---|
是否维护独立调用栈 | ❌ 依赖 JVM 栈 | ✅ 拥有完整调用栈 |
挂起点限制 | 只能在顶层挂起 | 任意深度均可挂起 |
内存开销 | 极低 | 较高(但仍远小于传统线程) |
上下文切换成本 | 极低 | 中等 |
编译器依赖 | 高(需 CPS 转换) | 低(运行时支持) |
关键结论:
- 无栈协程更高效,但限制更多
- 栈式虚拟线程更通用,兼容现有阻塞代码
💡 举例:你在 Kotlin 中不能在普通函数里直接调用
delay()
,必须是suspend
函数;而 Java 虚拟线程中可以直接调用Thread.sleep()
。
8.2. 抢占式 vs 协作式调度
调度方式 | Kotlin 协程 | Java 虚拟线程 |
---|---|---|
类型 | 协作式 | 抢占式(I/O 阻塞时) |
挂起点定义 | 显式 suspend 函数 |
自动检测阻塞调用 |
开发者干预 | 需设计挂起点 | 几乎无需干预 |
Java 虚拟线程的调度策略是:
✅ 当线程阻塞在 I/O 或同步操作时自动挂起,交出 CPU
这既保留了抢占式的简洁性,又避免了时间片轮转的浪费。
底层仍使用少量内核线程作为“载体线程”(Carrier Threads)运行虚拟线程。
9. 总结
维度 | Kotlin 协程 | Java 虚拟线程 |
---|---|---|
实现层级 | 库(Library) | JVM 原生 |
学习成本 | 中等(需理解 suspend) | 极低(几乎透明) |
兼容性 | 需改造代码为 suspend 风格 | 兼容现有阻塞代码 |
性能 | 极高(无栈 + 协作调度) | 高(栈式 + 抢占调度) |
结构化并发 | 天然支持 | 需额外设计 |
适用场景 | 新项目、Android、Kotlin 生态 | 现有 Java 项目迁移、高并发服务 |
📌 核心建议:
- 如果你在使用 Kotlin,优先采用协程 + 结构化并发
- 如果是存量 Java 项目,等待 Java 21+ 稳定版的虚拟线程,收益巨大
- 不要再为每个请求分配一个线程,那是上个时代的做法
未来属于轻量级并发。无论是协程还是虚拟线程,都是通向高吞吐、低延迟系统的必经之路。