ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多线程编程从入门到实战:并发三大问题、锁机制与线程池全解析

多线程编程从入门到实战:并发三大问题、锁机制与线程池全解析 多线程大概是所有编程知识里最割裂的一门课——面试考的和实际用的、学校教的和公司要求的常常对不上号。我在刚工作那会儿写多线程代码本地跑得好好的一上线就出诡异问题数值错乱、程序卡死、CPU飙高排查起来一头雾水。后来花了大把时间看书、翻源码、一遍遍复现问题才把这块的东西串成体系。这篇就是把我这些年对多线程的理解从头到尾整理一遍从线程和进程的关系、并发三大核心问题到各种锁的机制、不同语言的实现差异、线程池的实战配置再到调试方法和面试高频考点。内容信息量不小适合刚入门的人系统地扫一遍也适合工作了两三年但总感觉基础不牢的人回头补课。1. 线程是什么先把这个最基础的概念彻底搞清楚1.1 进程和线程的关系用开公司的思路一下就懂了很多人一上来就背定义进程是资源分配的最小单位线程是CPU调度的最小单位。背是背下来了但根本不知道这句话在说什么。我换个说法。你把一个运行中的程序想象成一家公司。进程就是这家公司本身——它有自己的办公楼独立的内存空间、固定的员工人数上限资源限制、独立的财务资源分配。而线程就是公司里的员工一个进程至少有一个线程也就是一个员工也可以有多个线程就是多个员工在同一栋楼里办公。关键点在于员工之间是共享办公楼的。A员工用的打印机B员工也能用A员工改了墙上的排班表B员工抬头就能看到。这就是线程之间共享进程的内存空间。而两家公司之间A公司的打印机B公司完全用不了A公司改了排班表B公司也看不到这就是进程之间的内存隔离。这一下就能解释很多现象了。为什么多线程比多进程轻量因为创建线程不用重新盖一栋楼分配独立内存只需要在已有的楼里加一张办公桌分配独立的栈空间线程切换的成本也比进程切换低因为大部分资源是共享的CPU只需要切换执行上下文不需要切换整个内存映射。但共享也带来麻烦。两个人同时用一台打印机就会撞车两个人同时改一个文件就会覆盖这就是并发问题的根源。所以学多线程本质上学的不是怎么开线程——那不叫难学的是怎么管好共享的资源。1.2 线程在CPU眼中到底是什么再从操作系统的视角看一层。线程在CPU眼里其实就是一个执行流加一套被执行的状态程序计数器下一条指令在哪、寄存器组当前计算的中间结果、栈局部变量和函数调用链。把这些打包起来就是一个线程的执行上下文。当操作系统要做线程切换时干的事就是把当前线程的上下文保存下来再把下一个线程的上下文恢复上去然后CPU接着往下执行。这个过程叫上下文切换Context Switch。听起来简单但实际上是有成本的——保存恢复寄存器、刷新TLB、调度器的开销一次切换大概要消耗几微秒时间。如果一个线程池里的线程设得过多大量时间都花在切换上反而比不用多线程还慢。这也是很多人第一次写多线程程序觉得怎么比单线程还慢的根本原因。线程还有一个重要特性同一进程内的多个线程共享堆内存和方法区/全局数据区但各自独享栈。这决定了线程之间的通信必然是通过共享变量来完成——你改了我看我看完再改回去。各种并发问题全出在这个看和改不是原子的上面。1.3 从创建到消亡线程的完整生命周期Java中用Thread.State枚举定义了线程的六种状态理解了这六种状态你基本就能看懂线程的一切行为状态含义进入方式如何切走NEW线程已创建但未启动new Thread()调用 start()RUNNABLE可运行/运行中start() 后或被唤醒后由CPU调度决定BLOCKED阻塞在锁上竞争 synchronized 失败获得锁WAITING等待被唤醒wait()、join()、park()notify()/unpark()TIMED_WAITING限时等待sleep()、wait(timeout)时间到或被唤醒TERMINATED已结束run() 执行完毕无法再回退注意区分 BLOCKED 和 WAITING 的区别BLOCKED 是线程想去拿一把锁但没拿到处于停车场门口排队的状态WAITING 是线程主动放弃了CPU在停车场里等待别人来通知。两者是不同维度的阻塞后续看线程Dump分析问题时会用到这个区分。还有一个容易踩的坑start()和run()。很多人刚学时会问为什么不直接调 run()。直接调 run() 不会开新线程它只是普通的方法调用还是在当前线程里执行。只有调 start()JVM 才会真正创建一个新的操作系统线程并调度它执行 run() 方法。2. 多线程必踩的三大坑原子性、可见性、有序性2.1 一个 i 为什么能算错先抛一个经典问题两个线程同时对同一个共享变量 i 执行 i执行 10000 次最后 i 是多少直觉答案是 20000但实际跑出来的结果永远小于等于 20000而且每次还不一样。为什么因为 i 根本不是一条命令它在 JVM 字节码层面至少拆成三条指令从内存把 i 的值读到寄存器/工作内存把值加1把新值写回内存两个线程并发执行时完全可能出现这种调度顺序线程A读到了 i10还没写回去线程B也读到了 i10A加1写回11B也加1写回11。两次自增结果只加了1。这就是经典的丢更新数据竞争导致结果不可预期。我当年第一次跑出这个结果时震惊了很久——语言层面明明是一行代码底层的拆解却完全不是一个原子操作。这也是整个并发编程最核心的分界线什么是原子的什么不是必须深入到硬件和编译器层面去理解而不是看源代码的一行一行的样子。2.2 原子性操作要么全做要么全不做原子性指一个操作是不可中断的要么全部执行成功要么全部不执行不存在中间状态。数据库里的事务也是这个概念。在多线程中如果两个线程同时修改同一个变量就会互相踩踏这就是原子性被破坏。解决办法层面讲就是给操作加锁或者使用原子类Atomic*把读-改-写三步合成一个不可分割的整体。java.util.concurrent.atomic包里的AtomicInteger就是基于 CASCompare-And-Swap实现的它依赖 CPU 提供的原子指令比如 x86 上的cmpxchg让比较并交换这个动作在硬件层面不可被中断。CAS 的机制可以这么理解你想给变量加1但不是直接加——你先看一眼当前值是不是预期值如果是就更新成新值如果不是说明别人抢先改了那你就重新读再试。这个看一眼再更新是CPU保证的原子指令所以不会出现读了旧数据覆盖新数据的情况。2.3 可见性线程A改了线程B为什么看不到原子性问题已经比较反直觉了可见性问题更隐蔽。它说的是线程A修改了共享变量的值但线程B可能长时间看不到这个修改。原因在于现代CPU的多级缓存体系。CPU 运算速度远超内存访问速度所以中间加了 L1、L2、L3 多级缓存。每个CPU核心有自己私有的缓存线程A跑在核心1上修改了变量写的是核心1的缓存线程B跑在核心2上读变量读的是核心2的缓存。如果缓存之间没有同步B读到的自然就是旧值。这就像公司里A员工把会议安排改在了群里但B员工看的是自己手里打印的旧版日程表——消息是更新了但对方看的数据源是旧的。Java内存模型JMM针对这个问题定义了 happens-before 规则。其中最重要的几条程序顺序规则代码书写顺序在后面的一定能看到前面的修改、锁规则解锁操作对后续的加锁操作可见、volatile规则volatile变量的写对后续的读可见。只要满足 happens-before 关系一个线程的修改就能保证对另一个线程可见。2.4 有序性编译器偷偷给你的代码重新排序这一个坑最阴因为它不是你写错而是编译器、JIT、CPU为了优化性能对指令执行的顺序做了重排。在单线程下重排不会影响最终结果因为CPU会保证看起来是按顺序执行的。但多线程环境下另一个线程看不到重排前的中间状态就可能踩进逻辑陷阱。最经典的例子是双重检查锁DCL单例模式public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); } } } return instance; } }instance new Singleton()这行代码在字节码层面是三步1分配内存空间2调用构造函数初始化对象3把引用赋值给变量。如果不加 volatileCPU 可能把 2 和 3 重排变成先赋值引用、再初始化对象。这时候另一个线程进来第一次检查发现 instance 不为 null直接返回——结果拿到的是一个还没初始化完成的对象用它的字段全默认值系统直接出bug。这个问题坑过无数人很多人以为加了synchronized就万事大吉其实 synchronized 只能保证原子性和可见性管不到另一个线程在无锁情况下读到未初始化完成的对象。只有 volatile 能禁止指令重排专门堵这个洞。这也是为什么 DCL 单例必须写private static volatile Singleton instance。3. 锁和同步机制解决并发问题的核心兵器3.1 volatile最轻量的准同步机制volatile 是 Java 中的一个关键字作用有两点保证可见性、禁止指令重排。但它不保证原子性。所以 volatile 适合什么场景一个线程写、其他线程读的状态标记。public class Worker { private volatile boolean running true; public void stop() { this.running false; // 其他线程立刻能看到 } public void run() { while (running) { // 业务逻辑 } } }在这个场景下stop() 被另一个线程调用后run() 里的循环能及时停下来。如果 running 不加 volatile极端情况下可能一直停不下来虽然实际中因为各种原因不一定复现但原理上确实存在这个风险。但如果你写的是volatile int count; count;那该丢更新还是会丢更新。很多初学者把 volatile 当作万能钥匙这是最大的误区。它解决不了复合操作的原子性问题也替代不了锁。3.2 synchronizedJava 最重要的内置锁synchronized 是 Java 从语言层面提供的同步原语用法上可以修饰实例方法、静态方法、代码块。它的核心逻辑是每个 Java 对象都有一个监视器锁Monitor线程进入 synchronized 代码块前必须先拿到这个对象的监视器锁拿不到就阻塞在 BLOCKED 状态等着。synchronized 是可重入的——同一个线程已经持有一把锁后可以再次获取该锁。比如一个 synchronized 方法调用同类里另一个 synchronized 方法不会把自己锁死。这是 JVM 在设计时就考虑好的内部记录锁的持有者和重入次数。如果不可重入递归方法里的同步就会互相死锁。从 JDK 6 开始JVM 对 synchronized 做了大量优化引入了锁升级机制无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁只有一个线程反复获取锁时锁会偏向这个线程省去大量无竞争的同步开销轻量级锁有另一个线程来竞争但不激烈时通过自旋等待而不是直接阻塞重量级锁竞争激烈超过自旋阈值后升级为依赖操作系统的互斥量Mutex线程进入阻塞队列这意味着现在的 synchronized 没有想象中那么重很多场景下性能都不比手动加 Lock 差。新手写并发程序如果不知道用什么锁默认用 synchronized 往往是最稳妥的选择。3.3 Lock 体系灵活但不一定必需java.util.concurrent.locks.Lock提供了比 synchronized 更灵活的锁操作。最常用的实现是 ReentrantLock它在功能上是 synchronized 的超集支持公平锁/非公平锁切换支持 tryLock() 尝试获取锁获取不到就返回 false避免无限等待支持 lockInterruptibly()可响应中断支持多个 Condition 对象更细腻地管理等待/通知两者的对比对比项synchronizedReentrantLock使用方式关键字修饰方法/代码块API调用 lock()/unlock()锁释放自动释放必须手动释放finally里unlock是否可中断不可中断lockInterruptibly() 可中断公平性非公平可选公平/非公平多个等待队列一个隐式Condition多个Condition性能JDK6后优化明显高并发下可控性更强但 ReentrantLock 有个使用陷阱一定要在 finally 块里调用 unlock()。一旦代码在 lock() 和 unlock() 之间抛异常锁没释放其他线程就永远阻塞。这个比 synchronized 危险得多因为 synchronized 是 JVM 自动保证释放的而 Lock 完全靠你人肉保证。实际项目中我用 ReentrantLock 基本都是配合 try/catch/finally 模板来写从不敢省事。3.4 死锁所有并发问题的终结者死锁这个坑几乎每个写过多线程的人都踩过。它的定义是两个或多个线程互相持有对方需要的锁互不相让全部陷入永久阻塞。经典例子// 线程1 synchronized (lockA) { synchronized (lockB) { // 业务 } } // 线程2 synchronized (lockB) { synchronized (lockA) { // 业务 } }线程1拿到 lockA 后想拿 lockB线程2 拿到 lockB 后想拿 lockA两边都等对方释放谁也等不到。死锁产生的必要条件有四个互斥、占有且等待、不可抢占、循环等待。这四条要同时满足才死锁所以打破任意一条就能解掉死锁。工程上最常用的办法是打破循环等待——规定所有线程必须按同一顺序获取锁。比如都先拿 lockA 再拿 lockB就不会出现你拿B我等A的死锁局面。还有一种隐藏很深的情况是动态顺序死锁。表面上看每个锁是按顺序拿的但因为传入参数的不同实际拿锁顺序会反过来。比如转账场景A给B转账和B给A转账如果先锁转出方再锁转入方两个请求互相锁住了对方的账户。解决办法是给账户加一个全局唯一ID按ID排序锁保证所有线程都以相同顺序获取锁。4. 各语言多线程实现差异同样的问题不同的解法4.1 Java多线程的三种创建方式和一种最佳实践Java 创建线程的方式面试官最爱问网上答案五花八门但核心就几种继承 Thread 类class MyThread extends Thread { Override public void run() { System.out.println(线程运行中); } } new MyThread().start();实现 Runnable 接口Thread thread new Thread(() - System.out.println(线程运行中)); thread.start();实现 Callable 接口 FutureTask支持返回值且可以抛异常CallableInteger task () - 42; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get();线程池最佳实践强烈推荐所有生产环境的代码都走线程池而不是裸 new Thread。一定要明确的是继承 Thread 会把任务代码和线程的创建耦合在一起而且 Java 是单继承扩展性差Runnable 没有返回值无法优雅地处理异常Callable 是弥补这些缺点的。但不管哪种直接 new Thread 都有一个大问题——线程用完即销毁频繁创建线程开销极大无法控制并发数量也无法复用。生产环境里永远用 ExecutorService 线程池来管理线程生命周期。4.2 Python 的 GIL 锁是真的假多线程吗Python 有个让无数人纠结的 GIL全局解释器锁。GIL 是个互斥锁它保证同一时间只有一个线程能执行 Python 字节码。这意味着在纯 CPU 密集型任务下Python 多线程根本做不到真正并行——无论开多少个线程一次只有一个在工作。那 Python 多线程是不是就没用了不是。对于 I/O 密集型任务网络请求、文件读写、数据库操作线程在等 I/O 时就会释放 GIL其他线程就能切入执行。concurrent.futures库里的 ThreadPoolExecutor 处理发起多个 HTTP 请求然后等待响应这种场景依然能大幅提升效率。Python 里真要做 CPU 并行计算正确选择是multiprocessing模块——每个进程有独立的解释器和内存空间绕开 GIL实现真正的多核并行。所以 Python 社区的经验是I/O 密集用线程CPU 密集用进程再或者直接用 asyncio 协程来应对高并发 I/O 场景。问题不在工具本身在于你要先判断当前任务的瓶颈在 CPU 还是 I/O。结合标题相关热搜里的多进程和多线程的区别这里补充一句多进程的隔离性和稳定性更强一个进程崩溃不影响其他进程多线程则只有几百微秒级的切换成本通信通过共享变量直接完成效率更高。两者的选择本质是隔离安全和轻量共享的取舍。4.3 C 多线程需要更强的底层控制力C 从 C11 开始引入标准线程库std::thread结束了之前只能用 POSIX threads 或 Windows API 写平台相关代码的混乱局面。创建一个线程很简单#include thread #include iostream void worker() { std::cout child thread std::endl; } int main() { std::thread t(worker); t.join(); // 等待线程结束 return 0; }但 C 的难点在于它没有像 Java 那样的垃圾回收和内存安全保证多线程共享数据时需要程序员手动管理锁和生命周期。C 提供std::mutex、std::lock_guardRAII 风格的锁管理离开作用域自动解锁、std::atomic原子类型来处理同步。一个容易犯的低级错误是C 里如果忘记join()或detach()线程对象析构时程序直接std::terminate()崩溃掉。这个在设计 API 时就得想清楚线程的归属和生命周期。4.4 C# 的 Task 与 async/await从线程驱动到任务驱动C# 给多线程编程带来的最大变化是 Task 编程模型。传统 Thread 直接操作底层的系统线程而 Task 是对异步操作更高层次的抽象配合 async/await 可以写出几乎同步风格的异步代码async Taskint DownloadAsync(string url) { using var client new HttpClient(); string content await client.GetStringAsync(url); return content.Length; }Task 底层由线程池调度它不一定每次都会创建新线程在 I/O 等待期间可以释放线程去做其他事。这对高并发服务器尤其重要——不需要为每个请求分配一个线程线程数量需求大幅下降。理解了这个回来看 Java 后续的虚拟线程Project Loom和 Go 的 goroutine核心思想都是一样的线程太重了我们需要一个更轻量、可大规模创建的并发单元由调度器负责任务到线程的映射。多线程的最终发展方向不是你会用多少锁而是能否跳出操作系统线程的局限在更高的抽象层次管理并发。5. 线程池生产环境真正在用的多线程5.1 为什么我劝你别裸 new Thread很多教学代码喜欢用 new Thread 来演示但生产环境里几乎没有人这么写。裸创建线程的问题非常直接创建和销毁线程本身有性能开销——每次都要申请内存、建立执行上下文线程数量不可控——如果请求大量涌入线程数会爆炸直接把内存耗尽缺乏统一的生命周期管理——线程崩了就崩了很难做监控和兜底可以类比一个餐馆每次来一桌客人就临时招一个厨师客人走了厨师也辞退这个餐馆必乱。线程池的本质是固定雇佣一批厨师来活了安排人做做完继续待命。它控制了系统里的线程总数复用了线程资源还能用队列缓冲任务平滑地应对突发流量。5.2 核心参数与执行流程Java 的ThreadPoolExecutor有七个核心参数参数说明corePoolSize核心线程数即使空闲也保留的线程数maximumPoolSize最大线程数线程不能超过此值keepAliveTime非核心线程空闲存活时间unit存活时间单位workQueue任务队列存放等待执行的任务threadFactory创建线程的工厂可为线程命名handler拒绝策略队列和线程都满了之后怎么办线程池的执行流程是提交任务时如果运行的线程数 corePoolSize池里新开一个核心线程来执行如果核心线程已经满了任务进入 workQueue 排队等待如果队列也满了再创建非核心线程直到 maximumPoolSize直接执行新任务如果线程数已经达到 maximumPoolSize 且队列也满了触发拒绝策略这个流程很多人背不下来我用一句话概括先开核心线程核心线程不够就排队队也排不下就加临时工临时工也请不来就拒单。想清楚这句话面试被问到线程池就不再是死记硬背了。5.3 四种拒绝策略怎么选JDK 内置了四种拒绝策略AbortPolicy直接抛 RejectedExecutionException默认策略CallerRunsPolicy由提交任务的线程自己执行该任务DiscardPolicy直接丢弃不抛异常DiscardOldestPolicy丢弃队列里最老的任务重新尝试提交当前任务我个人的建议是任何策略都不能代替业务层面的兜底。AbortPolicy 虽然最常用但抛异常之后任务还是丢了对用户来说就是一次请求失败。高可用系统一般配合降级方案——捕获拒绝异常后把任务写到本地消息队列或数据库等系统空闲了再补执行。CallerRunsPolicy 则可以起到一个天然限流的作用当线程池满了任务由调用方线程执行调用方被拖慢后天然降低了请求提交速率。5.4 手写一个最简线程池来理解原理理解了参数和流程后其实自己都能写一个迷你线程池public class SimpleThreadPool { private final int coreSize; private final BlockingQueueRunnable taskQueue; private final ListWorker workers new ArrayList(); public SimpleThreadPool(int coreSize, int queueSize) { this.coreSize coreSize; this.taskQueue new ArrayBlockingQueue(queueSize); for (int i 0; i coreSize; i) { Worker worker new Worker(); workers.add(worker); new Thread(worker, pool-worker- i).start(); } } public void execute(Runnable task) throws InterruptedException { taskQueue.put(task); // 队列满时阻塞 } private class Worker implements Runnable { Override public void run() { while (true) { try { Runnable task taskQueue.take(); // 队列空时阻塞 task.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } } }这个代码当然远不如 ThreadPoolExecutor 完善——没有拒绝策略、没有超时回收、没有配置化但核心模型就是一组常驻线程 一个线程安全的任务队列。理解了这句话就算以后去学其他语言、其他框架里的各种池思想都是相通的。6. 多线程调试与线上问题排查6.1 用 gdb 调试多线程程序C/C 多线程调试gdb 是绕不开的工具。平时你用 gdb 调试单线程程序多线程环境下会多出一些专属命令把整个调试思路打开info threads列出当前所有线程能看到每个线程的编号、状态和当前执行位置thread 编号切换到指定线程切过去之后就能像单线程调试一样看栈、设断点break file.c:line thread 3只在某个线程上命中断点set scheduler-locking on让调试器只调度当前选中的线程防止其他线程干扰thread apply all bt打印所有线程的调用栈排查死锁时这招最实用我见过很多同事调试多线程程序时在线程切换的迷宫里转不出来。其实核心思路很简单先用info threads确认当前有几个线程再thread apply all bt看每个线程卡在哪十有八九能直接看出问题。比如某线程卡在 pthread_mutex_lock另一个也卡在 pthread_mutex_lock两个锁的信息一对死锁就锁定了一大半。Java 这边对应的是jstack pid。jstack 输出里能看到每个线程的状态RUNNABLE、BLOCKED、WAITING等如果发现大量线程 BLOCKED再去看它们等的是什么锁锁的拥有者是谁排查链路就清晰了。6.2 复现问题的一般思路并发问题最大的难点是难以稳定复现。它不像普通bug输入相同输出就相同并发bug可能需要几千次运行才偶发一次。我的实践经验是缩小并发面去掉多余的线程只保留能触发问题的最少线程增加复现概率在可疑代码段加循环、加大数据量、设置断点人为干扰线程时序多打印关键日志进入临界区前、后都打日志记录线程名、时间戳、变量值用工具辅助Java 可以开-XX:PrintConcurrentLocksC 可以用 ThreadSanitizer-fsanitizethread自动检测数据竞争ThreadSanitizer 是我特别推荐的它会精确报告哪两个线程访问了同一块内存、是哪一行代码导致的冲突基本上一次运行直接给你定位到具体代码行。排查数据竞争一类的问题这个工具比人肉看代码高效太多。7. 多线程高频面试题与真实应用场景7.1 如何优雅地停止一个线程这个几乎是所有多线程面试里必问的题。直接stop()是危险的——它会强制终止线程把已加锁的临界区直接中断锁来不及释放数据可能处于不一致状态所以早就被标记为废弃方法了。正确姿势是协作式中断。Java 提供了interrupt()机制本质是给目标线程设置一个中断标志位至于线程要不要响应、什么时候响应完全由线程自己决定public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 业务逻辑 Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 break; } } }关键在于 sleep/wait 这些阻塞方法在被 interrupt() 时会抛 InterruptedException并同时清除中断标志位。所以捕获异常后如果不重新设置中断标志下一次循环检查 isInterrupted() 就会拿到 false线程根本停不下来。这个坑非常隐蔽网上代码也经常是错的面试时能准确说出来绝对是加分项。7.2 线程池如何优雅关闭线程池的关闭也有讲究顺序是shutdown()→awaitTermination()→shutdownNow()。shutdown()不允许再提交新任务但已提交的任务继续执行awaitTermination(timeout)等待所有任务执行完返回是否全部终止shutdownNow()尝试中断所有正在执行的任务并返回已提交但未执行的任务列表正常流程是先 shutdown 拒绝新任务然后 awaitTermination 给已有任务一个合理的执行时间窗口如果超时还没结束再 shutdownNow 强制打断。有些任务线程不响应中断最后这个线程可能还是停不下来那就需要额外的手段——比如对线程命名方便在 JVM 退出时定位。7.3 多线程下载与断点续传的实现思路edge 多线程下载插件和http 断点续传和多线程下载这两个热搜词说明下载器是很多人接触多线程的第一个真实场景。其实原理不复杂核心就是 HTTP 协议的 Range 请求头。过程很简单客户端向服务器发一个 HEAD 或带 Range 的 GET 请求拿到文件总大小把文件分成 N 段例如每段 1MB为每个段创建一个下载任务每个任务发送带Range: bytesstart-end的请求只下载指定字节区间所有任务完成后把各段的字节写入同一个文件的对应偏移位置断点续传的原理同理下载过程中记录已完成的分段偏移量中断后重新启动时只下载剩余未完成的部分。下载工具/浏览器插件的多线程下载加速底层都是这套逻辑。这个场景是很好的多线程入门练习——有真实的并发逻辑、有共享变量的写入协调、能直观感受到并发带来的速度提升又不会涉及太复杂的业务规则。7.4 最经典的生产者消费者模型最后一个必须掌握的场景题用 wait/notify 实现生产者消费者模型。这个虽然老套但对理解线程间协作非常有价值class BlockingQueueDemo { private final QueueInteger queue new LinkedList(); private final int capacity 10; public synchronized void produce(int value) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满等待消费者消费 } queue.offer(value); notifyAll(); // 唤醒等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空等待生产者生产 } int value queue.poll(); notifyAll(); // 唤醒等待的生产者 return value; } }注意几个关键点判断条件必须用 while 而不是 if防止虚假唤醒wait() 会释放锁而 notify 不会立即释放锁只是唤醒对方让它进入可运行状态notifyAll 比 notify 安全因为单个 notify 如果唤醒了同类线程可能导致对方一直等不到资源而僵持。生产环境中一般直接用BlockingQueue的 put/take 方法代替手写 wait/notify但理解手写版本的原理才能明白阻塞队列是怎么工作的遇到问题才知道往哪查。关于多线程的内容到这里基本全覆盖了概念、三大问题、各种同步机制、语言差异、线程池、调试、面试实战。如果你能把这七块内容真正串起来——知道什么时候该用锁、什么时候用 volatile、线程池的参数该怎么调、线上出问题怎么排查——那不管在工作还是面试中这块都不会再拖你后腿。最后再分享一个我自己的亲身体会多线程代码写对了不算本事出问题后能一个小时之内定位到根本原因才是真的过关。所以一定要亲手去跑一跑那些经典案例两个线程同时 i、死锁复现、用 jstack 看线程状态。踩过几次坑之后你对多线程的理解会比看多少篇文章都深。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进