1. 引言

在本篇文章中,我们将深入探讨并发编程的基本概念,并对比 Java 和 Kotlin 两种语言在实现轻量级并发上的不同路径。

重点会放在 Kotlin 的协程(coroutines)和 Java 正在推进的 Project Loom 所提出的虚拟线程(virtual threads)之间的比较。这些技术旨在解决传统线程模型在高并发场景下的资源开销问题,是现代高性能应用不可忽视的技术方向。

如果你还在用 new Thread() 或者过度依赖线程池处理大量 I/O 密集型任务,那这篇文章可能会让你重新思考架构设计。


2. 并发基础:从操作系统说起

并发的本质是将程序分解为多个可独立执行、顺序无关或部分有序的组件。目标是让多个任务协同工作而不影响最终结果。

在操作系统层面,一个运行中的程序实例被称为 进程(Process)。内核通过为每个进程分配独立的地址空间来实现隔离,保证安全性和容错性。但由于进程拥有独立的内存、文件句柄等资源,创建代价高昂。

更关键的是,进程间通信(IPC)复杂且低效。

这时候,内核级线程(Kernel-level Threads) 就派上用场了:

OS Thread and Process

线程是进程内的独立执行流。一个进程可以包含多个线程,它们共享地址空间和文件句柄,但各自维护独立的调用栈。这使得线程间通信变得简单高效。

然而,内核直接管理和调度这些线程,包括上下文切换、抢占式调度等,导致线程操作成本高,性能受限。

另一种选择是 用户级线程(User-level Threads),这类线程由运行时环境(如 JVM)在用户空间管理:

OS User and Kernel Threads

用户级线程不被内核感知,因此创建、切换速度快得多。常见的映射模型有一对一、多对一等。其核心优势在于:由运行时系统自主调度,可复用少量内核线程承载大量用户线程

当然,这也要求用户态调度器与内核调度器之间做好协调。


3. 编程语言中的并发抽象

操作系统提供了底层并发原语,但现代编程语言在此基础上构建了更高层的抽象。

✅ Java 提供了 Thread 类作为一级公民支持并发。但底层仍一对一映射到内核线程,属于“重量级”线程。

❌ 这种模式虽然直观,但在高并发下极易耗尽资源 —— 想象一下每请求启动一个线程的 Tomcat 模型。

相比之下,许多语言原生支持“轻量级线程”:

  • Kotlin:协程(coroutines)
  • Go:goroutines
  • Erlang:processes
  • Haskell:lightweight threads

这些机制的核心思想是:在运行时内部完成轻量级线程的调度,采用协作式而非抢占式调度,大幅提升效率

它们通常以少量内核线程为载体,承载成千上万个轻量级执行单元,显著降低上下文切换开销。

⚠️ 虽然 Java 目前没有原生支持,但这正是 Project Loom 的目标。


4. 其他并发模型:响应式编程

除了基于线程/协程的模型,还有一种完全不同的思路 —— 响应式编程(Reactive Programming)。

它将程序流视为一系列异步事件的流动,代码表现为监听、处理并发布事件的函数链。

典型代表有 RxJava、Project Reactor 等。我们常用“弹珠图”描述其数据流:

Reactive Programming Marble Diagram

响应式模型的关键特点:

  • 使用固定数量线程(通常等于 CPU 核心数)
  • 非阻塞 I/O,避免线程挂起
  • 单线程也能高效处理大量并发

✅ 优势:资源利用率高,适合高吞吐 I/O 场景
❌ 劣势:学习曲线陡峭,调试困难,容易陷入“回调地狱”的变种 —— “操作符地狱”

更重要的是,响应式编程难以实现结构化并发(Structured Concurrency),这也是协程的一大优势。


5. 结构化并发的重要性

传统的并发模型存在一个致命问题:无法清晰追踪执行生命周期

举个例子:你调用一个方法,该方法内部启动了几个后台任务。当方法返回时,你能确定所有子任务都完成了吗?不能。

这种“失控”的并发很容易导致资源泄漏、竞态条件等问题。

而结构化并发的理念是:

控制流一旦分叉,就必须重新汇合

Structured Concurrency

这意味着所有并发任务的作用域必须严格嵌套,父作用域结束前必须等待所有子任务完成。

这极大提升了代码的可读性、可维护性和错误处理能力。

⚠️ 响应式编程很难做到这一点,因为它的订阅关系往往是松散的、跨层级的。

而 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)

具体来说:

  1. 编译器将每个 suspend 函数转换为带 Continuation 参数的形式:
// 原始
suspend fun doSomething()

// 编译后等价于
fun doSomething(continuation: Continuation): Any?
  1. 所有挂起点被识别并打上标签,生成一个巨大的 switch 状态机。
  2. Continuation 封装了函数在挂起点的状态(局部变量、执行位置等)。

这类似于回调机制,但由编译器自动处理,开发者无感。

你可以把协程看作是一个自动拆解和重组的函数状态机。


7. Java 的未来:Project Loom

Java 自诞生起就通过 Thread 类提供并发支持。早期曾尝试过“绿色线程”(Green Threads),即用户态线程,但因无法利用多核而放弃。

此后,JVM 线程一直是一对一映射到内核线程,简单直接但资源消耗大。

随着微服务和高并发需求激增,传统线程模型逐渐力不从心。

7.1. Java 并发简史

  • JDK 1.0:引入 Thread 类,为跨平台承诺实现绿色线程
  • JDK 1.3:放弃绿色线程,全面转向内核线程模型
  • 后续版本:通过 ExecutorServiceCompletableFuture 改进编程模型

现状:每个请求一个线程的模型已无法应对百万级并发

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 几乎无需修改即可享受轻量级并发的好处。

⚠️ 但注意:ThreadGroupThreadLocal 等机制对虚拟线程的行为可能不同,需谨慎使用。


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+ 稳定版的虚拟线程,收益巨大
  • 不要再为每个请求分配一个线程,那是上个时代的做法

未来属于轻量级并发。无论是协程还是虚拟线程,都是通向高吞吐、低延迟系统的必经之路。


原始标题:Light-Weight Concurrency in Java and Kotlin