引言

并发编程一直是 Java 领域最具挑战性的话题之一。从早期的 ThreadRunnable,到 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
2
3
4
5
6
7
8
9
10
11
// 方式一:继承 Thread
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread running");
}
}

// 方式二:实现 Runnable
Thread t = new Thread(() -> System.out.println("Runnable running"));
t.start();

Runnable 的最大问题是:run() 方法没有返回值,也不能抛出受检异常。CallableFuture 的出现解决了这一痛点:

1
2
3
4
5
6
Callable<String> task = () -> {
Thread.sleep(1000);
return "result";
};
Future<String> future = executor.submit(task);
String result = future.get(); // 阻塞获取结果

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
2
3
4
5
6
7
8
9
10
// AQS 的状态模型
private volatile int state; // 同步状态

// 排他模式
protected boolean tryAcquire(int arg) { ... }
protected boolean tryRelease(int arg) { ... }

// 共享模式
protected int tryAcquireShared(int arg) { ... }
protected boolean tryReleaseShared(int arg) { ... }

基于 AQS 的典型实现:

同步器 模式 核心逻辑
ReentrantLock 排他 state=0 释放,state≥1 持有(可重入+state计数)
Semaphore 共享 state 表示剩余许可数
CountDownLatch 共享 state 为倒数计数,归零后释放所有线程
ReentrantReadWriteLock 共享+排他 高16位读锁计数,低16位写锁计数
CyclicBarrier - 内部使用 ReentrantLock + Condition

ReentrantLock 实战

1
2
3
4
5
6
7
8
9
10
11
12
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();

lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 等待,释放锁
}
queue.poll();
} finally {
lock.unlock(); // 必须在 finally 中释放
}

相比 synchronizedReentrantLock 提供了公平锁选择、可中断获取、超时获取和多条件变量等额外能力。

线程池:ThreadPoolExecutor

ThreadPoolExecutor 是 Java 线程池的标准实现,其核心参数包括:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • keepAliveTime:非核心线程空闲存活时间
  • workQueue:任务队列(ArrayBlockingQueueLinkedBlockingQueueSynchronousQueue
  • RejectedExecutionHandler:拒绝策略

任务提交后的执行流程:

1
2
3
提交任务 → 核心线程已满? → 任务队列已满? → 最大线程已满? → 拒绝策略
↓ ↓ ↓ ↓
直接执行 进入队列 创建新线程 CallerRuns/Abort/Discard

常用的四种拒绝策略:

  • AbortPolicy:抛出 RejectedExecutionException(默认)
  • CallerRunsPolicy:由提交任务的线程自行执行
  • DiscardPolicy:静默丢弃
  • DiscardOldestPolicy:丢弃队列中最旧的任务

实际生产环境推荐使用 CallerRunsPolicy,它提供了一种自然的背压机制——提交线程被占用时,任务提交速率自然降低。

第三阶段:CompletableFuture 异步编排(Java 8)

CompletableFuture 将异步编程从”回调地狱”中解放出来,提供声明式的组合和编排能力:

1
2
3
4
5
6
7
8
9
10
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchUser(userId)) // 异步获取用户
.thenApply(user -> enrichUser(user)) // 转换
.thenCompose(user -> fetchOrders(user)) // 组合另一个异步任务
.exceptionally(ex -> "fallback"); // 异常处理

// 多任务组合
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined = f1.thenCombine(f2, (a, b) -> a + " " + b);

注意:CompletableFuture 默认使用 ForkJoinPool.commonPool(),其并行度默认为 CPU 核心数 - 1。在生产环境中,建议传入自定义的 Executor

1
2
Executor executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(task, executor);

第四阶段:虚拟线程(Java 21)

Project Loom 的核心创新

虚拟线程(Virtual Threads)是 Project Loom 的旗舰成果,于 Java 21 正式发布。它从根本上改变了 Java 的并发模型:

  • 传统线程(平台线程):1:1 映射到 OS 内核线程,创建成本高(约 1MB 栈空间)、数量受限于 OS
  • 虚拟线程:由 JVM 内部调度,大量虚拟线程共享少量载体线程(Carrier Thread)
1
2
3
4
5
6
7
8
9
// 创建 100 万个虚拟线程——这在传统线程模型下是不可想象的
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(1000); // 阻塞时释放载体线程
return "done";
});
}
}

技术实现

虚拟线程的核心技术是Continuation(续延)和Stack Chunk。当虚拟线程执行阻塞操作(I/O、sleep)时,JVM 将它的栈帧保存(挂起)到堆内存中,释放载体线程去执行其他虚拟线程。阻塞结束后,再恢复栈帧继续执行。

这使得 I/O 密集型应用可以用同步代码(简单、可读、可调试)写出异步程序(高性能、高吞吐)的效果。

虚拟线程的适用场景

场景 虚拟线程 平台线程
I/O 密集型(HTTP 请求、数据库查询) 强烈推荐 可用但并发有限
CPU 密集型(计算、加密) 不推荐 推荐
大量并发连接(10万+) 强烈推荐 不可行
需要线程局部变量(ThreadLocal) 谨慎使用 正常使用

结构化并发(Structured Concurrency)

与虚拟线程配套提出的结构化并发(从 Java 21 开始预览)将并发任务组织为清晰的树状结构:

1
2
3
4
5
6
7
8
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser());
Future<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // 等待所有子任务
scope.throwIfFailed(); // 任一失败则全部取消
return new Response(user.resultNow(), order.resultNow());
}
// try 块结束时自动等待并清理所有子任务

实用对比与选型建议

以一个 Web 服务器的请求处理为例,不同方案的特点对比如下:

方案 代码风格 吞吐量 调试难度
同步 + 线程池 简单同步 受限于线程数
异步回调(CompletableFuture) 链式调用 中(堆栈追踪复杂)
反应式(RxJava / WebFlux) 声明式流 极高 高(心智负担大)
虚拟线程 简单同步 极高 低(堆栈清晰)

虚拟线程的出现使大多数 I/O 密集型应用不再需要反应式编程。你可以用直观的同步代码,获得媲美异步框架的吞吐量。

结语

ThreadAQS,从 CompletableFuture 到 Virtual Threads,Java 并发编程的每一次进化都在靠近同一个目标:让开发者用更简单的代码表达更强大的并发能力。特别是虚拟线程的到来,可能是 Java 并发历史上最有影响力的革新——它没有引入新的编程模型,而是让原有模型变得无限可扩展。对于新项目,如果运行在 Java 21+ 上,虚拟线程应该是你考虑并发时的第一选项。