Skip to content

多线程入门:让程序同时做多件事

难度:中级 | 预计时间:50 分钟 | 前置:IO 与文件操作


📖 计数器怎么成了负数?

故事背景

你写了一个网站访问计数器——每次请求来就 count++。单用户测试时好好的,上线后有 100 个并发用户,计数器反而变小了,甚至变成负数。你傻了:不就是加一吗,怎么还能加出负数?这不是 Java 的 bug,而是多线程竞态条件的经典表现。作为前端,你可能用过 Web Workers——那是进程级隔离,各自有独立的内存。而 Java 的线程是共享内存的——效率更高,但你的 count++ 可能被另一个线程打断,读到脏数据。这节课让你亲眼看到并发问题的根源。

💻 代码演示

先看一个不带同步的计数器,跑 1000 次加一操作:

java
public class ThreadDemo {
    static int count = 0;            // 共享变量,没有保护!

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                count++;             // 这不是原子操作!(读-改-写 三步)
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();

        t1.join();  // 等待 t1 完成
        t2.join();  // 等待 t2 完成

        System.out.println("期望: 2000, 实际: " + count);
        // 输出可能是: 1873, 1956, 甚至 2000 (运气好时)
    }
}

// ===== 修复方案:synchronized =====
public class FixedCounter {
    static int count = 0;
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {  // 加锁:同一时刻只有一个线程能进
                    count++;
                }
            }
        };
        // ... 其余相同
        System.out.println("期望: 2000, 实际: " + count);  // 始终 2000
    }
}

运行输出:

期望: 2000, 实际: 1873
---修复后---
期望: 2000, 实际: 2000

🎯 三个核心问题

这是什么?

Java 中创建线程有两种方式:继承 Thread 类实现 Runnable 接口(后者更推荐,因为 Java 单继承)。thread.start() 启动线程(不是 run()!),join() 等待线程结束。

线程安全的核心是竞态条件——多个线程同时读写共享数据,结果取决于执行顺序。synchronized 关键字提供互斥锁(mutex),保证同一时刻只有一个线程进入临界区。

为什么需要它?

为什么需要多线程?①充分利用多核 CPU ②避免 IO 阻塞整个程序(Web 服务器每个请求一个线程)③提升响应速度。但共享内存模型带来并发问题——你的代码可能在任何两行之间被另一个线程打断。synchronized 就是"请勿打扰"的牌子——挂上后别人在外面排队。

如果没有它会怎样?

如果没有多线程,一个 Web 服务器一次只能处理一个请求——其他用户在浏览器前等得怀疑人生。如果不用 synchronized,上面的计数器永远得不到正确结果——这种 bug 很难复现(有时候对有时候错),是开发者最头疼的一类问题。

📝 原理讲解

多线程速查:

  • 创建线程new Thread(runnable).start()ExecutorService(进阶)。注意是 start() 不是 run()——run() 只是普通方法调用,不会启动新线程。

  • 线程生命周期:NEW → RUNNABLE(start后)→ BLOCKED(等锁)/ WAITING(join/sleep)→ TERMINATED。

  • synchronized:可以用在方法上(synchronized void add())或代码块上(synchronized(lock) { ... })。锁定的是对象,不是代码。

  • volatile:保证变量在多线程间的可见性——一个线程改了,其他线程立刻看到。但不保证原子性——count++ 仍然不安全。

  • 前端对比:Java Thread ≈ Web Worker,但 Worker 不能共享内存(靠 postMessage 通信),而 Java 线程共享堆内存。JavaScript 是单线程事件循环模型——天然没有竞态条件,但也用不满多核。

🎨 生活类比

类比理解

单线程像只有一个厨师的小餐馆——客人排长队,厨师一个一个炒菜(同步阻塞)。 多线程像多个厨师共用同一个灶台——效率高了,但如果两个厨师同时伸手拿同一瓶酱油(共享变量),可能撞翻瓶子(数据错乱)。 synchronized 就是灶台使用令牌——谁拿到令牌谁炒菜,其他人旁边等着。但令牌本身有开销——如果每个厨师只炒 1 秒钟却排队等令牌 10 秒钟,那就得不偿失了(锁粒度太粗)。

✏️ 动手练习

练习 1

创建两个线程,一个打印奇数,一个打印偶数,交替输出 1-20。用 synchronized + wait()/notify() 实现。

<details> <summary>💡 查看提示</summary>

用共享变量 currentNumber,奇数线程在 currentNumber 为偶数时 wait(),偶数线程反之。每次打印后 notify() 对方。

</details>

练习 2

ExecutorService 创建固定大小(3个线程)的线程池,提交 10 个任务,每个任务打印自己的名字和执行的线程名。

<details> <summary>💡 查看提示</summary>

Executors.newFixedThreadPool(3),用 submit() 提交任务,观察 3 个线程如何复用。

</details>

练习 3

故意写出一个死锁:两个线程各自持有一个锁,又试图获取对方的锁。让程序卡住。

<details> <summary>💡 查看提示</summary>

线程A:先 synchronized(lock1) 后 synchronized(lock2);线程B:先 synchronized(lock2) 后 synchronized(lock1)。两人同时拿到第一把锁,等对方的锁——死锁。

</details>

✅ 自检站

<details> <summary><strong>thread.start()thread.run() 有什么区别?</strong></summary>

start() 创建新线程并调用 run()——代码在新线程中执行。run() 只是普通方法调用——在当前线程中执行,不会启动新线程。这是新手最容易犯的错误之一——写了 run() 却看不到并发效果。

</details>

<details> <summary><strong>为什么 count++ 不是线程安全的?</strong></summary>

count++ 在 JVM 层面是三步操作:①读取 count 的当前值 ②加 1 ③写回。如果两个线程同时读到 count=5,都加 1 得到 6,写回——最终是 6 而不是 7。这就丢失了一次加操作。要用 synchronized 把读-改-写变成不可分割的原子操作。

</details>

<details> <summary><strong>synchronized 锁的是代码还是对象?</strong></summary>

锁的是对象。每个 Java 对象都有一个内置锁(monitor)。synchronized 方法锁的是 this 对象;synchronized(lock) 锁的是 lock 对象。如果两个线程分别锁不同的对象,它们可以同时进入各自的临界区(互不影响)。

</details>