Skip to content

线程与同步基础 ​

S1-028必学进阶约 90 分钟JDK 21编程语言基础 · 并发入门

1. 本节定位 ​

并发让程序"同时做几件事":一边处理请求,一边写日志,一边等数据库返回。

本节只讲入门的核心:怎么创建线程、什么叫线程不安全、怎么让它安全。并发编程的完整知识(线程池调优、锁优化、并发容器、JMM)在 S4 企业开发进阶 展开,本节的目标是建立一个不会写错的底线认知。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出进程与线程的区别,以及线程的六种状态;
  • 用三种方式创建线程,并说清为什么必须调用 start() 而不是 run();
  • 解释竞态条件的成因,并复现"丢更新";
  • 说清 volatile 能做什么、不能做什么(这是本节最重要的一个点);
  • 用 synchronized 与 AtomicInteger 两种方式保证计数正确;
  • 说清死锁的四个必要条件,并用"固定加锁顺序"避免它;
  • 知道线程是昂贵资源、应该复用(线程池的入门认知)。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

三个现实:

第一,CPU 是多核的。 单线程程序只能用上一个核,其余核闲着。

第二,等待是常态。 一次数据库查询可能要 10 ms,这 10 ms 里 CPU 没事干。多线程让"等待"与"干活"重叠。

第三,也是最关键的——并发是 bug 的温床。 同一份代码,单线程跑一万次都对,多线程跑十次可能错一次。这种"偶发错误"是生产事故里最难排查的一类。

看一个真实的错觉:

java
private volatile int count;          // 很多人的第一反应:加 volatile 就线程安全了

void increment() {
    count++;                          // 但这一行不是原子操作!
}

count++ 实际是三步:读 count → 加 1 → 写回。两个线程可能同时读到 100,各自加 1 后都写回 101——一次自增凭空消失。本节会让你亲眼看到这个现象。

5. 核心概念 ​

5.1 进程与线程 ​

进程线程
是什么运行中的程序实例进程内的执行单元
资源独立内存空间共享进程的内存
开销大(创建、切换)小
通信进程间通信(IPC)直接读写共享变量(也是危险来源)

关键点:线程共享内存既带来了高效通信,也带来了数据竞争。

5.2 线程的六种状态 ​

状态含义
NEW已创建,还没 start()
RUNNABLE可运行(可能在等 CPU 时间片)
BLOCKED等待获取 synchronized 锁
WAITING无限期等待(wait()、join())
TIMED_WAITING限时等待(sleep(ms))
TERMINATED已结束

注意:sleep() 只让出 CPU,不释放锁;wait() 会释放锁。这是高频面试点,S4 并发章节会深入。

5.3 三种创建方式 ​

java
// 方式一:继承 Thread(不推荐:占用唯一的继承名额)
class MyThread extends Thread {
    @Override
    public void run() { }
}

// 方式二:实现 Runnable(推荐的"传任务"写法)
Runnable task = () -> System.out.println("任务执行");
new Thread(task).start();

// 方式三:实现 Callable(有返回值、能抛异常)
Callable<Integer> callable = () -> 6 * 7;
FutureTask<Integer> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
futureTask.get();                       // 42

为什么推荐"传任务":任务与线程解耦。同一个 Runnable 可以交给普通线程、线程池、虚拟线程——《Java 9-21 新特性与 Record》 的虚拟线程演示就靠这一点。

必须调用 start() 而不是 run():

调用结果
thread.start()启动新线程,由新线程执行 run()
thread.run()只是普通方法调用,在当前线程执行

写错了不会报错,只是"悄悄变成了单线程"——这是并发 bug 的经典伪装。

5.4 线程安全的三要素 ​

要素含义破坏它的典型场景
原子性操作不可分割count++(读改写三步)
可见性一个线程的修改,其他线程能看见缓存、编译器优化
有序性代码执行顺序符合预期指令重排

volatile 保证可见性与有序性(禁止指令重排),但不保证原子性:

java
private volatile int count;

void increment() {
    count++;                   // 可见:能读到最新值;不原子:读改写仍可能交错
}

synchronized 同时解决三个问题(互斥 + 内存屏障),代价是同一时刻只有一个线程能进入。

AtomicInteger 用 CAS 解决原子性,无锁,并发度高,但只适用于单变量操作。

5.5 synchronized 的用法 ​

java
// 1. 修饰实例方法:锁是 this
synchronized void increment() { count++; }

// 2. 修饰静态方法:锁是 Class 对象
static synchronized void staticIncrement() { }

// 3. 修饰代码块:锁是括号里的对象(推荐,粒度更细)
synchronized (lock) {
    count++;
}

关键认知:synchronized 锁的是对象,不是代码。两个线程用不同的对象锁,起不到互斥作用。

粒度选择:锁的范围越小越好(减少等待),但必须覆盖需要保护的完整操作。锁范围过小会把非原子操作露在外面,等于没锁。

5.6 死锁与它的避免 ​

死锁的四个必要条件(破坏任意一个即可避免):

条件含义破坏方式
互斥资源同时只能被一个线程持有无法破坏(锁的本质)
持有并等待拿着一个锁,再等另一个锁一次性申请全部锁
不可剥夺已持有的锁不能被抢走用 tryLock 超时放弃
循环等待A 等 B,B 等 A固定加锁顺序 ✓ 最实用

实践中最常用的两招:

  1. 固定加锁顺序(本节代码用它避免了转账死锁);
  2. tryLock 带超时(拿不到就放弃,不无限等待)。

5.7 线程是昂贵资源 ​

创建线程有成本(栈内存、系统调用),切换线程也有成本。所以:

java
// 反模式:每个任务创建一个线程
for (int i = 0; i < 10_000; i++) {
    new Thread(task).start();          // 创建 1 万个线程
}

// 正确:交给线程池复用
try (ExecutorService pool = Executors.newFixedThreadPool(200)) {
    for (int i = 0; i < 10_000; i++) {
        pool.submit(task);             // 200 个线程轮流干
    }
}

线程池的完整知识(核心参数、拒绝策略、监控)在 S4 章节;本节只需建立"线程要复用"的直觉。

6. 递进示例 ​

6.1 最小示例:创建与等待 ​

java
Thread worker = new Thread(() -> System.out.println("子线程执行"), "worker-1");
System.out.println(worker.getState());     // NEW
worker.start();
worker.join();                             // 主线程等它结束
System.out.println(worker.getState());     // TERMINATED

6.2 业务示例:共享计数 ​

java
class Counter {
    private int count;
    synchronized void increment() { count++; }   // 去掉 synchronized 就会丢更新
    int value() { return count; }
}

6.3 反例:以为 volatile 能保证线程安全 ​

java
private volatile int count;

void increment() {
    count++;                    // 错!volatile 不保证原子性
}

这会读到"看起来对、偶尔错"的结果——比直接崩溃更难排查。第 7 节的实测会把两种写法放在一起对比。

7. 完整可运行代码 ​

文件:labs/concurrency/src/main/java/cn/kb/learn/concurrent/ConcurrencyLesson.java(节选核心部分)

java
package cn.kb.learn.concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.IntSupplier;

public class ConcurrencyLesson {
    static final int THREADS = 4;
    static final int TIMES = 50_000;
    static final int EXPECTED = THREADS * TIMES;

    /** 普通 int:既没有可见性保证,也没有原子性保证 */
    static class UnsafeCounter {
        private int count;

        void increment() { count++; }
        int value() { return count; }
    }

    /** volatile int:保证可见性与有序性,count++ 仍然不是原子操作 */
    static class VolatileCounter {
        private volatile int count;

        void increment() { count++; }
        int value() { return count; }
    }

    /** synchronized:互斥 + 可见性,结果正确但同一时刻只有一个线程能进入 */
    static class SyncCounter {
        private int count;

        synchronized void increment() { count++; }
        int value() { return count; }
    }

    /** AtomicInteger:CAS 无锁操作,结果正确且并发度更高 */
    static class AtomicCounter {
        private final AtomicInteger count = new AtomicInteger();

        void increment() { count.incrementAndGet(); }
        int value() { return count.get(); }
    }

    /** 启动若干线程各自累加,返回最终读数 */
    static int countWith(Runnable increment, IntSupplier reader) throws InterruptedException {
        Thread[] workers = new Thread[THREADS];
        for (int i = 0; i < THREADS; i++) {
            workers[i] = new Thread(() -> {
                for (int j = 0; j < TIMES; j++) {
                    increment.run();
                }
            }, "worker-" + i);
        }
        for (Thread worker : workers) {
            worker.start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        return reader.getAsInt();
    }

    public static void main(String[] args) throws Exception {
        // 1. 线程的创建与生命周期
        Thread worker = new Thread(() -> {
            System.out.println("  子线程执行: " + Thread.currentThread().getName());
        }, "worker-1");
        System.out.println("start 之前的状态: " + worker.getState());
        worker.start();
        worker.join();
        System.out.println("join 之后的状态: " + worker.getState() + ",还活着吗: " + worker.isAlive());

        // 2. 中断:不是强制停止,而是发出协作信号
        Thread sleeper = new Thread(() -> {
            try {
                Thread.sleep(5_000);
                System.out.println("  正常醒来");
            } catch (InterruptedException exception) {
                System.out.println("  被打断: " + exception.getClass().getSimpleName()
                        + ",中断标记已被清除: " + Thread.currentThread().isInterrupted());
            }
        }, "sleeper");
        sleeper.start();
        Thread.sleep(100);
        sleeper.interrupt();
        sleeper.join();

        // 3. 竞态条件:四种计数器的对比
        System.out.println("四种计数器各自的目标值: " + EXPECTED);

        UnsafeCounter unsafe = new UnsafeCounter();
        System.out.println("普通 int       : " + countWith(unsafe::increment, unsafe::value) + "  ← 丢更新");

        VolatileCounter volatileCounter = new VolatileCounter();
        System.out.println("volatile int   : " + countWith(volatileCounter::increment, volatileCounter::value) + "  ← 仍然丢更新");

        SyncCounter sync = new SyncCounter();
        System.out.println("synchronized   : " + countWith(sync::increment, sync::value) + "  ← 正确");

        AtomicCounter atomic = new AtomicCounter();
        System.out.println("AtomicInteger  : " + countWith(atomic::increment, atomic::value) + "  ← 正确");

        // 4. 死锁与 tryLock(完整实现见仓库代码)
        // 两个线程以相反顺序获取两把锁,用 tryLock 超时放弃而不是无限等待

        // 5. 线程池:线程是昂贵资源,应该复用
        try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
            Future<Integer> future = pool.submit(() -> 6 * 7);
            System.out.println("线程池任务结果: " + future.get());
        }
    }
}

运行命令:

powershell
cd labs
./mvnw -pl concurrency compile
java -cp concurrency/target/classes cn.kb.learn.concurrent.ConcurrencyLesson

预期输出(前两个计数器的数值每次运行都会不同,这正是竞态的特征):

text
start 之前的状态: NEW
  子线程执行: worker-1
join 之后的状态: TERMINATED,还活着吗: false
  被打断: InterruptedException,中断标记已被清除: false
四种计数器各自的目标值: 200000
普通 int       : 55584  ← 丢更新
volatile int   : 94248  ← 仍然丢更新
synchronized   : 200000  ← 正确
AtomicInteger  : 200000  ← 正确
  线程 A(先 A 后 B): 放弃第二把锁(tryLock 避免了真死锁)
  线程 B(先 B 后 A): 放弃第二把锁(tryLock 避免了真死锁)
线程池任务结果: 42

这张对比表是本节的核心证据:

写法实测结果结论
普通 int55584 / 200000丢掉了 72% 的更新
volatile int94248 / 200000仍然丢掉 53%——可见性 ≠ 原子性
synchronized200000正确
AtomicInteger200000正确

请重点看第二行:volatile 确实提升了结果(说明可见性起了作用),但离正确还差很远。"加了 volatile 就安全"是一个代价高昂的误解。

关于 中断标记已被清除: false:sleep 抛 InterruptedException 时会清空中断标记,所以 catch 后要按需重新设置(Thread.currentThread().interrupt()),否则上层代码感知不到中断——第 9 节会讲这个坑。

8. Debug 观察点 ​

  1. 在 countWith 的 worker.join() 处断点,查看调试器的线程列表:能看到 worker-0 到 worker-3 四个线程,状态在 RUNNABLE 与 TERMINATED 间变化;
  2. 在 SyncCounter.increment() 的 count++ 处断点,线程切换观察:任一时刻只能有一个线程停在这里——这就是互斥;
  3. 把 synchronized 去掉后重复第 2 步:会看到多个线程同时停在同一行——这就是竞态之所以发生;
  4. 在 IDEA 的 Threads 面板里观察 sleeper 线程,interrupt() 之前它是 TIMED_WAITING(停在 sleep 上),打断后立刻进入 RUNNABLE;
  5. 运行死锁演示时,观察两个线程各持一把锁、各自等待 300 ms 后放弃——如果用 lock() 而不是 tryLock(),这里会永远挂住。

9. 常见错误 ​

错误 1:调用 run() 而不是 start() ​

java
Thread thread = new Thread(task);
thread.run();                 // 在当前线程执行,新线程根本没启动

症状:程序行为完全正确,只是没有并发。压测时才暴露出性能问题。

错误 2:以为 volatile 能保证原子性 ​

见 7.2 节实测。volatile 的适用场景:一个线程写、其他线程读的状态标志(如 volatile boolean running)。不适用:count++、复合判断、多变量一致性。

错误 3:在 synchronized 块里做耗时操作 ​

java
synchronized (lock) {
    Thread.sleep(1000);       // 拿着锁睡觉:所有线程陪你等
    callRemoteService();      // 网络调用更糟
}

修复:锁内只做必要的共享状态操作,把耗时操作移到锁外。

错误 4:锁对象不一致 ​

java
synchronized (new Object()) {     // 每次都是新对象 → 每个线程一把自己的锁
    count++;
}

修复:锁对象必须是共享的同一个实例(常用 private final Object lock = new Object();)。

错误 5:捕获 InterruptedException 后什么都不做 ​

java
try {
    Thread.sleep(1000);
} catch (InterruptedException exception) {
    // 空实现:中断信号被吞掉了
}

修复:要么重新抛出,要么恢复中断标记:

java
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();       // 恢复标记,交给上层处理
}

错误 6:为了"性能"滥用多线程 ​

单线程能跑完的任务,加了多线程只会更难调试。并发的收益来自"等待时间远大于计算时间",或者"确实有多个核可以并行"。

10. 动手实验 ​

目标:亲手复现竞态,再亲手修好它。

步骤:

  1. 运行第 7 节代码,连跑三次,记录普通 int 与 volatile int 的读数——观察每次都不一样;
  2. 把 THREADS 改成 8、TIMES 改成 20_000(总量不变),再跑——观察丢更新的比例变化;
  3. 写一个 transfer(Account from, Account to, long amount):先按参数顺序加锁,用两个线程做 A→B 与 B→A 的循环转账,观察是否卡住(记得用 tryLock,不然程序会挂);
  4. 把它改成"按账户名排序后加锁",再跑——观察不再出现互相等待;
  5. 把第 3、4 步的两个版本各跑 100 次,统计"卡住"的次数。

验收方式:能解释第 5 步中"为什么加锁顺序能打破死锁"(提示:循环等待条件)。

扩展任务:用 AtomicLong 实现一个"请求计数器",要求:increment() 返回自增后的值、get() 无锁读取,并说明它为什么比 synchronized 更适合"高频只增不减"的场景。

11. 练习题 ​

基础题

  1. 判断:volatile 能保证 count++ 的线程安全。(对 / 错)
  2. 填空:线程从 NEW 到 RUNNABLE 需要调用 ______;主线程等待子线程结束用 ______。
  3. 简答:thread.start() 与 thread.run() 的区别是什么?

代码阅读题

java
class Holder {
    private int value;
    private volatile boolean ready;

    void produce() {
        value = 42;
        ready = true;
    }

    int consume() {
        while (!ready) {
            Thread.onSpinWait();
        }
        return value;
    }
}
  1. 这段代码的 ready 用 volatile 修饰是必须的吗?如果用普通 boolean,可能出现什么现象?
  2. value 需要加 volatile 吗?结合"volatile 写之前的操作对读线程可见"说明理由。

编程题

  1. 用线程池并行计算一个 int 数组的和:
    • 把数组切成 threads 段,每段一个任务;
    • 用 Callable 返回每段的和,主线程汇总;
    • 用 100 万个数字验证:结果应等于 500000500000;
    • 说明:为什么这里的"汇总"不需要加锁?

综合任务

  1. 实现一个线程安全的账户转账系统:
    • Account 有 name 与 balance,balance() 用 synchronized 保证读到一致值;
    • transfer(from, to, amount) 要求:余额不足抛异常、不能死锁;
    • 用三个账户两两互相转账各 5000 次(每次 1 元),最后验证总额守恒;
    • 说明:如果不用"按名称排序加锁",你会怎么保证不死锁?
分级提示

提示 1(方向):第 4 题的关键是"可见性"——读线程可能永远读不到写线程的修改。

提示 2(关键点):第 6 题的每段任务只写自己的局部变量,只有主线程读结果,没有共享可变状态就没有竞态。

提示 3(接近答案):第 7 题的加锁顺序按 name 排序即可——所有线程都以"A → B → C"的顺序加锁,就不可能形成环路。

12. 参考答案 ​

基础题
  1. 错。volatile 只保证可见性与有序性,不保证原子性。count++ 是"读、改、写"三步,即使每个线程都读到最新值,仍可能互相覆盖。实测中它丢掉了约一半的更新。
  2. start();join()。
  3. start() 会启动一个新线程,由新线程执行 run();run() 就是普通方法调用,在调用者所在线程执行。写错不会报错,后果是"程序变回单线程"。
代码阅读题(第 4、5 题)

第 4 题:ready 必须用 volatile。

若用普通 boolean,consume() 中的 while (!ready) 可能永远读不到写线程的修改——读线程可能一直从自己的缓存/寄存器里读旧值,导致死循环。volatile 保证写操作立刻对其他线程可见。

第 5 题:value 不需要 volatile。

因为 volatile 写的语义包含:写 ready 之前的操作,对读到 ready = true 的线程都可见(这是 JMM 的 happens-before 规则)。既然 value = 42 在 ready = true 之前,那么看到 ready 为真就能看到 value 为 42。

但这是精细的推理,容易写错。 工程上更稳的做法:value 也加 volatile,或用 synchronized、AtomicReference 承载这一组状态——用明显正确的写法,而不是依赖对内存模型的精确记忆。

编程题(第 6 题)
java
package cn.kb.learn.concurrent;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ParallelSum {
    static long sumParallel(int[] numbers, int threads) throws Exception {
        int chunkSize = (numbers.length + threads - 1) / threads;
        try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
            List<Future<Long>> futures = new ArrayList<>();
            for (int start = 0; start < numbers.length; start += chunkSize) {
                int from = start;
                int to = Math.min(start + chunkSize, numbers.length);
                futures.add(pool.submit(() -> {
                    long sum = 0;
                    for (int i = from; i < to; i++) {
                        sum += numbers[i];
                    }
                    return sum;
                }));
            }

            long total = 0;
            for (Future<Long> future : futures) {
                total += future.get();
            }
            return total;
        }
    }

    public static void main(String[] args) throws Exception {
        int size = 1_000_000;
        int[] numbers = new int[size];
        for (int i = 0; i < size; i++) {
            numbers[i] = i + 1;
        }
        long expected = (long) size * (size + 1) / 2;

        System.out.println("预期和: " + expected);
        System.out.println("4 线程: " + sumParallel(numbers, 4));
        System.out.println("8 线程: " + sumParallel(numbers, 8));
        System.out.println("结果一致: " + (sumParallel(numbers, 4) == expected && sumParallel(numbers, 8) == expected));
    }
}

为什么"汇总"不需要加锁:每个任务只写自己的局部变量 sum,然后把结果通过 Future 交给主线程;主线程是唯一的汇总者。没有共享可变状态,就没有竞态。

这就是"能不共享就不共享"——并发编程最有效的策略不是加锁,而是缩小共享范围。

预期输出:

text
预期和: 500000500000
4 线程: 500000500000
8 线程: 500000500000
结果一致: true
综合任务(第 7 题)
java
package cn.kb.learn.concurrent;

public class ThreadSafeAccount {
    static class Account {
        private final String name;
        private long balance;

        Account(String name, long balance) {
            this.name = name;
            this.balance = balance;
        }

        synchronized long balance() {
            return balance;
        }

        /**
         * 按账户名顺序获取锁:所有调用者以相同顺序加锁,从根本上消除"循环等待"条件。
         * 如果按参数顺序加锁,A→B 与 B→A 并发时就会互相等待。
         */
        static void transfer(Account from, Account to, long amount) {
            Account first = from.name.compareTo(to.name) <= 0 ? from : to;
            Account second = first == from ? to : from;
            synchronized (first) {
                synchronized (second) {
                    if (from.balance < amount) {
                        throw new IllegalStateException(from.name + " 余额不足");
                    }
                    from.balance -= amount;
                    to.balance += amount;
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Account a = new Account("A", 100_000);
        Account b = new Account("B", 100_000);
        Account c = new Account("C", 100_000);
        long expected = a.balance() + b.balance() + c.balance();

        Thread ab = new Thread(() -> {
            for (int i = 0; i < 5_000; i++) {
                Account.transfer(a, b, 1);
            }
        }, "A->B");
        Thread bc = new Thread(() -> {
            for (int i = 0; i < 5_000; i++) {
                Account.transfer(b, c, 1);
            }
        }, "B->C");
        Thread ca = new Thread(() -> {
            for (int i = 0; i < 5_000; i++) {
                Account.transfer(c, a, 1);
            }
        }, "C->A");

        for (Thread worker : new Thread[]{ab, bc, ca}) {
            worker.start();
        }
        for (Thread worker : new Thread[]{ab, bc, ca}) {
            worker.join();
        }

        long actual = a.balance() + b.balance() + c.balance();
        System.out.println("A / B / C 余额: " + a.balance() + " / " + b.balance() + " / " + c.balance());
        System.out.println("总额守恒: " + actual + "(预期 " + expected + ")→ " + (actual == expected));
    }
}

不用"按名称排序"时的替代方案:

  1. tryLock 超时放弃:拿不到第二把锁就释放第一把,随机等待后重试(活锁风险低但需重试逻辑);
  2. 一把全局大锁:所有转账共用一把锁——简单、绝对不死锁,但并发度降到最低(转账全部串行);
  3. 单线程化的转账队列:把转账请求放进队列,由一个线程串行处理(把并发问题转成排队问题,工程上很常用)。

共同点:都在破坏"持有并等待"或"循环等待"这两个条件。

预期输出:

text
A / B / C 余额: 100000 / 100000 / 100000
总额守恒: 300000(预期 300000)→ true

13. 掌握标准 ​

  • [ ] 能说出线程的六种状态与 start() / run() 的区别;
  • [ ] 能复现竞态条件,并解释"读改写"为什么不是原子操作;
  • [ ] 能说清 volatile 保证什么、不保证什么;
  • [ ] 会用 synchronized 与 AtomicInteger 修复并发错误;
  • [ ] 能说出死锁的四个必要条件,并用固定加锁顺序避免;
  • [ ] 知道线程应该复用(线程池),并知道并发不是"越多越好"。

14. 扩展资源 ​

15. 下一步学习 ​

《Java 8 核心特性总览》:把 S1 学过的语言特性放到"版本"这条线上重新梳理一遍。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引