引言:一个Web服务器的故事
假设你正在开发一个简单的Web服务器,用户访问网站时,服务器需要处理请求并返回响应。最初,你可能会写出这样的代码:
public class SimpleWebServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动,监听端口 8080...");
while (true) {
// 接受客户端连接
Socket client = serverSocket.accept();
// 处理请求(耗时操作)
handleRequest(client);
// 关闭连接
client.close();
}
}
private static void handleRequest(Socket client) throws IOException {
// 读取请求
BufferedReader in = new BufferedReader(
new InputStreamReader(client.getInputStream()));
String request = in.readLine();
// 模拟业务处理:查询数据库、调用外部API等(耗时500ms)
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 返回响应
PrintWriter out = new PrintWriter(client.getOutputStream(), true);
out.println("HTTP/1.1 200 OK");
out.println("Content-Type: text/plain");
out.println();
out.println("Hello, World!");
}
}
问题来了:
- 第一个用户访问:等待500ms,得到响应 ✅
- 第二个用户访问:需要等待第一个用户处理完成,再等待500ms ❌
- 第三个用户访问:需要等待前两个用户都处理完成,再等待500ms ❌❌
现实场景:
- 如果每秒有1000个并发请求
- 每个请求处理时间500ms
- 单线程服务器每秒只能处理 2 个请求(1000ms / 500ms = 2)
- 第1000个用户需要等待:500 * 1000 / 1000 = 500秒 ≈ 8分钟!
这就是为什么需要并发编程的原因。
一、计算机的性能差距:并发的硬件基础
1.1 CPU、内存、IO的速度差距
现代计算机系统中,各个组件的速度存在巨大差距:
| 组件 | 访问速度 | 与CPU速度比 | 比喻 |
|---|---|---|---|
| CPU | ~1 ns | 1x | 光速 |
| L1缓存 | ~1 ns | 1x | 光速 |
| L2缓存 | ~4 ns | 4x | 开车速度 |
| L3缓存 | ~12 ns | 12x | 自行车速度 |
| 主内存(RAM) | ~100 ns | 100x | 步行速度 |
| SSD硬盘 | ~150 μs | 150,000x | 蜗牛速度 |
| 机械硬盘 | ~10 ms | 10,000,000x | 蚂蚁速度 |
| 网络IO | ~150 ms | 150,000,000x | 树懒速度 |
关键洞察:
如果CPU执行一条指令的时间是1秒,那么:
- 从内存读取数据需要 100秒(1分40秒)
- 从SSD读取数据需要 42小时
- 从机械硬盘读取数据需要 115天
- 从网络读取数据需要 4.7年
这意味着什么?
假设一个Web请求的处理过程:
public Response handleRequest(Request request) {
// 1. CPU计算(1ms)
String userId = parseUserId(request); // CPU: 1ms
// 2. 查询数据库(100ms)
User user = database.query(userId); // IO: 100ms ← CPU等待!
// 3. 调用外部API(200ms)
Order order = api.getOrder(userId); // IO: 200ms ← CPU等待!
// 4. 生成响应(1ms)
return buildResponse(user, order); // CPU: 1ms
// 总耗时:302ms
// CPU实际工作:2ms (0.66%)
// CPU等待IO:300ms (99.34%)
}
CPU 99%的时间都在等待IO!这就像让刘翔去送外卖——大材小用,浪费资源。
1.2 为什么单线程效率低下
场景模拟:餐厅点餐
单线程模式:1个服务员
- 服务员A接待客户1 → 厨房做菜(等待)→ 上菜 → 接待客户2
- 客户2必须等待客户1的菜做完才能点餐
- 效率:1个客户/15分钟
多线程模式:1个服务员 + 多任务
- 服务员A接待客户1 → 让厨房做菜
- 不等待,立即接待客户2 → 让厨房做菜
- 不等待,立即接待客户3 → 让厨房做菜
- 客户1的菜好了 → 上菜
- 效率:3个客户/15分钟(提升3倍)
核心思想:在等待IO期间,让CPU去做其他事情。
二、并发的三大核心价值
2.1 价值一:提高CPU利用率
问题:单线程程序在等待IO时,CPU闲置。
解决:多线程允许CPU在等待线程1的IO时,切换到线程2执行。
// ❌ 单线程:CPU利用率低
public void processOrders() {
for (Order order : orders) {
processOrder(order); // 处理完一个才处理下一个
}
}
// ✅ 多线程:提高CPU利用率
public void processOrdersConcurrently() {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (Order order : orders) {
executor.submit(() -> processOrder(order)); // 同时处理多个
}
executor.shutdown();
}
性能对比(假设每个订单处理时间100ms,其中IO占90ms):
| 模式 | 1000个订单处理时间 | CPU利用率 |
|---|---|---|
| 单线程 | 100秒 | ~10% |
| 10线程 | ~10秒 | ~90% |
提升10倍性能!
2.2 价值二:提升程序响应速度
用户体验的关键:响应时间
// ❌ 同步处理:用户等待3秒
public OrderResult createOrder(Order order) {
// 1. 扣减库存(1秒)
inventoryService.deduct(order.getProductId());
// 2. 生成订单(1秒)
orderService.create(order);
// 3. 发送短信(1秒)
smsService.send(order.getPhone(), "订单创建成功");
return new OrderResult("success");
}
// ✅ 异步处理:用户等待0.1秒
public OrderResult createOrderAsync(Order order) {
// 1. 扣减库存(必须同步,0.1秒)
inventoryService.deduct(order.getProductId());
// 2. 异步生成订单(不阻塞)
CompletableFuture.runAsync(() ->
orderService.create(order));
// 3. 异步发送短信(不阻塞)
CompletableFuture.runAsync(() ->
smsService.send(order.getPhone(), "订单创建成功"));
return new OrderResult("success"); // 立即返回
}
用户体验提升30倍!
2.3 价值三:充分利用多核CPU
现代CPU都是多核:
- 个人电脑:4核、8核
- 服务器:32核、64核、128核
- 云服务器:可弹性扩展
问题:单线程程序只能使用1个CPU核心。
解决:多线程可以同时利用多个CPU核心,实现真正的并行计算。
// ❌ 单线程:只用1个核心,8核CPU利用率12.5%
public long sumArray(int[] array) {
long sum = 0;
for (int num : array) {
sum += num;
}
return sum;
}
// ✅ 并行流:使用多个核心,8核CPU利用率接近100%
public long sumArrayParallel(int[] array) {
return Arrays.stream(array)
.parallel() // 并行处理
.sum();
}
性能测试(1亿个数字求和,8核CPU):
| 方法 | 耗时 | CPU利用率 | 加速比 |
|---|---|---|---|
| 单线程 | 800ms | 12.5% | 1x |
| 并行流 | 120ms | 95% | 6.7x |
三、并发 vs 并行:两个容易混淆的概念
3.1 并发(Concurrency)
定义:多个任务交替执行,看起来像同时进行。
比喻:一个厨师快速切换做多道菜
- 炒菜A炒30秒 → 放下
- 炒菜B炒30秒 → 放下
- 炒菜C炒30秒 → 放下
- 回来继续炒菜A…
本质:时间片轮转,任务快速切换。
单核CPU的典型场景:
时间轴:0ms 10ms 20ms 30ms 40ms
线程A: [执行] [等待] [执行] [等待] [执行]
线程B: [等待] [执行] [等待] [执行] [等待]
3.2 并行(Parallelism)
定义:多个任务真正同时执行。
比喻:多个厨师同时各做各的菜
- 厨师A炒菜A
- 厨师B炒菜B
- 厨师C炒菜C
- 同时进行,互不影响
本质:多核CPU同时工作。
多核CPU的典型场景:
时间轴:0ms 10ms 20ms 30ms 40ms
核心1: [线程A执行] [线程A执行] [线程A执行] ...
核心2: [线程B执行] [线程B执行] [线程B执行] ...
核心3: [线程C执行] [线程C执行] [线程C执行] ...
3.3 区别总结
| 维度 | 并发(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 核心数 | 单核或多核 | 必须多核 |
| 任务执行 | 交替执行(快速切换) | 同时执行 |
| 目的 | 提高系统吞吐量和响应速度 | 提高计算速度 |
| 典型场景 | Web服务器处理多个请求 | 大数据并行计算 |
| 关键技术 | 线程调度、上下文切换 | 多核并行 |
重要:
- 并发强调任务的组织和调度
- 并行强调任务的同时执行
- 并发是并行的前提,但并发不一定并行
四、阿姆达尔定律:并发的理论极限
4.1 定律内容
阿姆达尔定律(Amdahl’s Law)描述了并行化对程序性能提升的理论上限。
公式:
加速比 S = 1 / [(1 - P) + P / N]
其中:
- P:程序中可并行部分的比例
- N:处理器核心数
- (1 - P):程序中必须串行的部分
核心思想:程序的加速比受到串行部分的限制。
4.2 实际案例分析
假设一个程序:
- 总执行时间:100秒
- 可并行部分:90秒(P = 0.9)
- 必须串行部分:10秒(1 - P = 0.1)
不同核心数的加速比:
public class AmdahlLawCalculator {
public static void main(String[] args) {
double P = 0.9; // 90%可并行
System.out.println("核心数 | 加速比 | 实际耗时 | CPU利用率");
System.out.println("------|--------|---------|----------");
for (int N : new int[]{1, 2, 4, 8, 16, 32, 64, 128}) {
double speedup = 1.0 / ((1 - P) + P / N);
double time = 100 / speedup;
double cpuUtil = (speedup / N) * 100;
System.out.printf("%6d | %6.2fx | %6.2fs | %6.1f%%\n",
N, speedup, time, cpuUtil);
}
}
}
输出结果:
| 核心数 | 加速比 | 实际耗时 | CPU利用率 | 说明 |
|---|---|---|---|---|
| 1 | 1.00x | 100.00s | 100.0% | 基准 |
| 2 | 1.82x | 55.00s | 90.9% | 性能提升82% |
| 4 | 3.08x | 32.50s | 76.9% | 性能提升3倍 |
| 8 | 4.71x | 21.25s | 58.8% | 开始受串行部分限制 |
| 16 | 6.40x | 15.63s | 40.0% | CPU利用率下降明显 |
| 32 | 7.80x | 12.82s | 24.4% | 继续增加核心收益递减 |
| 64 | 8.77x | 11.40s | 13.7% | 接近理论极限 |
| 128 | 9.35x | 10.70s | 7.3% | 接近理论极限 |
| ∞ | 10.00x | 10.00s | 0% | 理论极限:10倍 |
4.3 关键启示
串行部分是瓶颈:
- 10%的串行代码,最大加速比只有10倍
- 无论增加多少核心,都无法突破这个极限
边际收益递减:
- 从1核到2核:性能提升82%
- 从16核到32核:性能提升22%
- 从64核到128核:性能提升6%
优化策略:
- 首先优化串行部分(减少 1-P)
- 然后才是并行化(增加 N)
// ❌ 错误:直接增加线程数,但串行部分太多
ExecutorService executor = Executors.newFixedThreadPool(100); // 100线程
for (Order order : orders) {
executor.submit(() -> {
synchronized(this) { // ← 串行瓶颈!
processOrder(order);
}
});
}
// ✅ 正确:先减少串行部分,再并行化
ExecutorService executor = Executors.newFixedThreadPool(10);
for (Order order : orders) {
executor.submit(() -> {
processOrder(order); // 无锁,真正并行
});
}
五、并发带来的挑战
并发不是免费的午餐,它也带来了新的问题:
5.1 线程安全问题
// 问题:多个线程同时修改共享变量
public class Counter {
private int count = 0;
public void increment() {
count++; // ← 不是原子操作!
}
}
// 结果:100个线程各执行1000次,期望100000,实际可能是99573
原因:count++ 实际上是3个操作:
- 读取count的值
- 加1
- 写回count
多线程并发执行时,可能发生数据竞争。
5.2 性能开销
上下文切换:线程切换需要保存和恢复状态,有开销。
// 性能测试:上下文切换的成本
public class ContextSwitchTest {
public static void main(String[] args) throws InterruptedException {
// 场景1:单线程循环
long start1 = System.nanoTime();
int sum1 = 0;
for (int i = 0; i < 1000000; i++) {
sum1 += i;
}
long time1 = System.nanoTime() - start1;
// 场景2:10个线程,每个循环100000次
ExecutorService executor = Executors.newFixedThreadPool(10);
long start2 = System.nanoTime();
CountDownLatch latch = new CountDownLatch(10);
for (int t = 0; t < 10; t++) {
executor.submit(() -> {
int sum = 0;
for (int i = 0; i < 100000; i++) {
sum += i;
}
latch.countDown();
});
}
latch.await();
long time2 = System.nanoTime() - start2;
executor.shutdown();
System.out.println("单线程耗时:" + time1 / 1000000.0 + "ms");
System.out.println("多线程耗时:" + time2 / 1000000.0 + "ms");
System.out.println("多线程 / 单线程:" + (double)time2 / time1);
}
}
可能输出:
单线程耗时:2.5ms
多线程耗时:5.8ms
多线程 / 单线程:2.32
结论:如果任务太小、太快,多线程反而更慢(上下文切换开销大于并行收益)。
5.3 复杂性增加
- 死锁:两个线程互相等待对方释放资源
- 活锁:线程不断重试,但无法前进
- 饥饿:某些线程长期得不到执行
- 调试困难:并发Bug难以复现和调试
六、何时使用并发?
6.1 适合并发的场景
✅ IO密集型任务
- Web服务器(大量等待网络IO)
- 数据库查询(等待磁盘IO)
- 文件处理(等待文件IO)
✅ 计算密集型任务(多核)
- 图像处理
- 视频编码
- 科学计算
- 大数据分析
✅ 提升响应速度
- GUI程序(避免界面卡死)
- 游戏服务器(同时处理多个玩家)
6.2 不适合并发的场景
❌ 任务太小、太快
// 错误示例:为简单任务创建线程,得不偿失
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.parallelStream() // ← 数据太少,并行反而慢
.map(n -> n * 2)
.collect(Collectors.toList());
❌ 必须严格顺序执行
// 错误示例:步骤之间有强依赖
step1(); // 必须先执行
step2(); // 依赖step1的结果
step3(); // 依赖step2的结果
// 无法并行
❌ 共享状态太多,锁竞争激烈
// 错误示例:所有线程竞争同一把锁
synchronized(lock) {
// 临界区
}
// 实际上变成了串行执行
七、总结
7.1 核心要点
- 并发的本质:充分利用CPU,在等待IO时处理其他任务
- 并发的价值:
- 提高CPU利用率
- 提升响应速度
- 充分利用多核
- 并发≠并行:
- 并发:交替执行(单核或多核)
- 并行:同时执行(必须多核)
- 阿姆达尔定律:串行部分限制了加速比的上限
- 并发有代价:
- 线程安全问题
- 上下文切换开销
- 复杂性增加
7.2 思考题
- 为什么Web服务器必须使用多线程?
- 如果一个程序50%的代码可以并行,8核CPU的理论加速比是多少?
- 什么情况下单线程比多线程更快?
7.3 下一篇预告
《进程与线程的本质:操作系统视角》
- 进程和线程到底是什么?
- 为什么线程比进程轻量?
- 上下文切换的真实成本?
- Java线程与操作系统线程的关系?
扩展阅读
- Java Concurrency in Practice - Chapter 1: Introduction
- 阿姆达尔定律 - 维基百科
- The Free Lunch Is Over - Herb Sutter