Java 并发编程:从线程到虚拟线程的演进之路
引言
并发编程一直是 Java 领域最具挑战性的话题之一。从早期的 Thread 和 Runnable,到 Java 5 革命性的 JUC(java.util.concurrent)包,再到 Java 8 的 CompletableFuture,以及 Java 21 划时代的虚拟线程(Virtual Threads),Java 的并发模型经历了持续近三十年的演进。本文将以时间线为轴,系统梳理这一演进过程中的关键技术革新。
线程模型基础
在深入 Java 并发 API 之前,有必要理解操作系统的线程模型。现代操作系统普遍采用内核线程(Kernel-Level Thread,KLT):每个线程由内核管理和调度,线程的创建、切换、销毁都需要系统调用(syscall)——开销不小。
HotSpot JVM 采用 1:1 线程模型:Java 线程与 OS 内核线程一一对应。这意味着每个 new Thread().start() 背后都是一个真实的操作系统线程。当线程数达到几千时,上下文切换的开销和内存占用(每个线程约 1MB 的栈空间)就会成为瓶颈。
第一阶段:java.lang.Thread 基础(Java 1.x - 4)
最原始的并发方式:直接操作 Thread 类。
1 | // 方式一:继承 Thread |
Runnable 的最大问题是:run() 方法没有返回值,也不能抛出受检异常。Callable 和 Future 的出现解决了这一痛点:
1 | Callable<String> task = () -> { |
synchronized 的锁升级机制
synchronized 是 Java 最古老的内置锁。在 JDK 1.6 之前,它直接映射为操作系统的互斥量(mutex),每次加锁都是一次系统调用,性能很差。JDK 1.6 引入了锁升级机制:
1 | 无锁 → 偏向锁(Biased Locking)→ 轻量级锁(Lightweight Locking)→ 重量级锁(Heavyweight Locking) |
- 偏向锁:假设锁总是由同一线程获取,第一次获取时在 Mark Word 中记录线程 ID
- 轻量级锁:通过 CAS 自旋尝试获取锁,避免系统调用
- 重量级锁:自旋失败后膨胀为内核互斥量,线程阻塞
这一优化使 synchronized 在大多数竞争不激烈的场景下性能接近 Lock 框架。JDK 15 默认禁用了偏向锁(因为在高并发场景下维护偏向锁的成本超过了收益),JDK 21 彻底移除了偏向锁。
第二阶段:JUC 包的崛起(Java 5)
Java 5 引入了 java.util.concurrent 包,这是 Java 并发编程史上最重要的一次升级。其核心设计师 Doug Lea 将多年实践经验凝结为一套强大、灵活、可扩展的并发工具集。
AQS(AbstractQueuedSynchronizer)
AQS 是整个 JUC 包的基石。它实现了一个FIFO 同步队列,上层同步器只需实现 tryAcquire/tryRelease(共享模式则为 tryAcquireShared/tryReleaseShared),就能复用一个经过充分测试的线程排队与唤醒框架。
1 | // AQS 的状态模型 |
基于 AQS 的典型实现:
| 同步器 | 模式 | 核心逻辑 |
|---|---|---|
| ReentrantLock | 排他 | state=0 释放,state≥1 持有(可重入+state计数) |
| Semaphore | 共享 | state 表示剩余许可数 |
| CountDownLatch | 共享 | state 为倒数计数,归零后释放所有线程 |
| ReentrantReadWriteLock | 共享+排他 | 高16位读锁计数,低16位写锁计数 |
| CyclicBarrier | - | 内部使用 ReentrantLock + Condition |
ReentrantLock 实战
1 | ReentrantLock lock = new ReentrantLock(); |
相比 synchronized,ReentrantLock 提供了公平锁选择、可中断获取、超时获取和多条件变量等额外能力。
线程池:ThreadPoolExecutor
ThreadPoolExecutor 是 Java 线程池的标准实现,其核心参数包括:
- corePoolSize:核心线程数
- maximumPoolSize:最大线程数
- keepAliveTime:非核心线程空闲存活时间
- workQueue:任务队列(
ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue) - RejectedExecutionHandler:拒绝策略
任务提交后的执行流程:
1 | 提交任务 → 核心线程已满? → 任务队列已满? → 最大线程已满? → 拒绝策略 |
常用的四种拒绝策略:
- AbortPolicy:抛出
RejectedExecutionException(默认) - CallerRunsPolicy:由提交任务的线程自行执行
- DiscardPolicy:静默丢弃
- DiscardOldestPolicy:丢弃队列中最旧的任务
实际生产环境推荐使用 CallerRunsPolicy,它提供了一种自然的背压机制——提交线程被占用时,任务提交速率自然降低。
第三阶段:CompletableFuture 异步编排(Java 8)
CompletableFuture 将异步编程从”回调地狱”中解放出来,提供声明式的组合和编排能力:
1 | CompletableFuture<String> future = CompletableFuture |
注意:CompletableFuture 默认使用 ForkJoinPool.commonPool(),其并行度默认为 CPU 核心数 - 1。在生产环境中,建议传入自定义的 Executor:
1 | Executor executor = Executors.newFixedThreadPool(10); |
第四阶段:虚拟线程(Java 21)
Project Loom 的核心创新
虚拟线程(Virtual Threads)是 Project Loom 的旗舰成果,于 Java 21 正式发布。它从根本上改变了 Java 的并发模型:
- 传统线程(平台线程):1:1 映射到 OS 内核线程,创建成本高(约 1MB 栈空间)、数量受限于 OS
- 虚拟线程:由 JVM 内部调度,大量虚拟线程共享少量载体线程(Carrier Thread)
1 | // 创建 100 万个虚拟线程——这在传统线程模型下是不可想象的 |
技术实现
虚拟线程的核心技术是Continuation(续延)和Stack Chunk。当虚拟线程执行阻塞操作(I/O、sleep)时,JVM 将它的栈帧保存(挂起)到堆内存中,释放载体线程去执行其他虚拟线程。阻塞结束后,再恢复栈帧继续执行。
这使得 I/O 密集型应用可以用同步代码(简单、可读、可调试)写出异步程序(高性能、高吞吐)的效果。
虚拟线程的适用场景
| 场景 | 虚拟线程 | 平台线程 |
|---|---|---|
| I/O 密集型(HTTP 请求、数据库查询) | 强烈推荐 | 可用但并发有限 |
| CPU 密集型(计算、加密) | 不推荐 | 推荐 |
| 大量并发连接(10万+) | 强烈推荐 | 不可行 |
| 需要线程局部变量(ThreadLocal) | 谨慎使用 | 正常使用 |
结构化并发(Structured Concurrency)
与虚拟线程配套提出的结构化并发(从 Java 21 开始预览)将并发任务组织为清晰的树状结构:
1 | try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { |
实用对比与选型建议
以一个 Web 服务器的请求处理为例,不同方案的特点对比如下:
| 方案 | 代码风格 | 吞吐量 | 调试难度 |
|---|---|---|---|
| 同步 + 线程池 | 简单同步 | 受限于线程数 | 低 |
| 异步回调(CompletableFuture) | 链式调用 | 高 | 中(堆栈追踪复杂) |
| 反应式(RxJava / WebFlux) | 声明式流 | 极高 | 高(心智负担大) |
| 虚拟线程 | 简单同步 | 极高 | 低(堆栈清晰) |
虚拟线程的出现使大多数 I/O 密集型应用不再需要反应式编程。你可以用直观的同步代码,获得媲美异步框架的吞吐量。
结语
从 Thread 到 AQS,从 CompletableFuture 到 Virtual Threads,Java 并发编程的每一次进化都在靠近同一个目标:让开发者用更简单的代码表达更强大的并发能力。特别是虚拟线程的到来,可能是 Java 并发历史上最有影响力的革新——它没有引入新的编程模型,而是让原有模型变得无限可扩展。对于新项目,如果运行在 Java 21+ 上,虚拟线程应该是你考虑并发时的第一选项。