引言:一个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 ns1x光速
L1缓存~1 ns1x光速
L2缓存~4 ns4x开车速度
L3缓存~12 ns12x自行车速度
主内存(RAM)~100 ns100x步行速度
SSD硬盘~150 μs150,000x蜗牛速度
机械硬盘~10 ms10,000,000x蚂蚁速度
网络IO~150 ms150,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利用率加速比
单线程800ms12.5%1x
并行流120ms95%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利用率说明
11.00x100.00s100.0%基准
21.82x55.00s90.9%性能提升82%
43.08x32.50s76.9%性能提升3倍
84.71x21.25s58.8%开始受串行部分限制
166.40x15.63s40.0%CPU利用率下降明显
327.80x12.82s24.4%继续增加核心收益递减
648.77x11.40s13.7%接近理论极限
1289.35x10.70s7.3%接近理论极限
10.00x10.00s0%理论极限:10倍

4.3 关键启示

  1. 串行部分是瓶颈

    • 10%的串行代码,最大加速比只有10倍
    • 无论增加多少核心,都无法突破这个极限
  2. 边际收益递减

    • 从1核到2核:性能提升82%
    • 从16核到32核:性能提升22%
    • 从64核到128核:性能提升6%
  3. 优化策略

    • 首先优化串行部分(减少 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个操作:

  1. 读取count的值
  2. 加1
  3. 写回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 核心要点

  1. 并发的本质:充分利用CPU,在等待IO时处理其他任务
  2. 并发的价值
    • 提高CPU利用率
    • 提升响应速度
    • 充分利用多核
  3. 并发≠并行
    • 并发:交替执行(单核或多核)
    • 并行:同时执行(必须多核)
  4. 阿姆达尔定律:串行部分限制了加速比的上限
  5. 并发有代价
    • 线程安全问题
    • 上下文切换开销
    • 复杂性增加

7.2 思考题

  1. 为什么Web服务器必须使用多线程?
  2. 如果一个程序50%的代码可以并行,8核CPU的理论加速比是多少?
  3. 什么情况下单线程比多线程更快?

7.3 下一篇预告

《进程与线程的本质:操作系统视角》

  • 进程和线程到底是什么?
  • 为什么线程比进程轻量?
  • 上下文切换的真实成本?
  • Java线程与操作系统线程的关系?

扩展阅读