
做并发编程这几年我见过太多“背得出API却写不对代码”的场面。提问时张口就是线程六种状态一说到 start() 和 run() 的区别就含糊更别提守护线程这种“隐性角色”了。实际上线程API本身并不多难的是理解每个方法背后的行为边界和适用场景。这篇就把线程常见方法API一个个拆开讲再把守护线程这个经常在后台默默干活、却总被忽略的家伙单独拎出来说透。内容以Java为主但思路对C、Python等语言的线程模型同样有参考价值适合刚过完语法关、准备系统性学习并发编程的新手也适合写了好几年业务代码、却一直没把线程API捋清楚的老手。1. 线程API全景拆解先搞清楚这些方法到底在干什么很多人在学线程时喜欢单个方法死记今天看 sleep明天看 join结果全看完了还是不知道什么时候该用哪个。我的建议是先把方法按“生活场景”分个类理解了每个方法解决什么问题再去记语法就不容易忘了。1.1 从线程的生命周期看懂API的分布线程从创建到销毁要经历新建、就绪、运行、阻塞、等待、终止这几个阶段。API就是驱动线程在这些状态之间切换的“操作杆”。新建到就绪new Thread() 创建线程对象调用 start() 让线程进入就绪队列。就绪到运行由操作系统线程调度器决定什么时候轮到它上CPU程序员控制不了。运行到阻塞/等待调用 sleep()、join()、wait()或者遇到 synchronized 锁被占用时会进入阻塞或等待状态。阻塞到就绪sleep 时间到了、join 等待的线程结束了、notify 唤醒了线程重新回到就绪队列等着被调度。运行到终止run() 方法正常执行完或者抛出未捕获异常线程生命周期结束。这套流程里程序员能主动调用的“开关”其实就那几个start、sleep、yield、join、interrupt、wait、notify。搞清楚它们分别用在哪个阶段比单纯背八股文有用得多。1.2 最火的贯穿知识点线程池、虚拟线程与API的关系写到这里必须先提一个容易混淆的点很多人用线程池之后就不太关心 Thread 类的这些API了。其实线程池底层同样是线程只是把创建和销毁的环节托管给了 ThreadPoolExecutor。Java 21 引入虚拟线程之后线程的创建成本大幅下降但像 join、interrupt、yield 这些协作API依然是核心只是底层实现变了。后面讲到具体方法时我会顺带提一句它在虚拟线程下的表现方便大家建立完整的知识地图。2. 核心方法API逐一拆解从原理到实践2.1 start() 与 run()99%新手栽在这里先看一段最常见的错误代码Thread thread new Thread(() - { System.out.println(Thread.currentThread().getName() 执行中); }); thread.run(); // 错误示范你要是直接调用 run()程序会在当前线程比如main线程里同步执行这段代码而不是在新建的线程里执行。换句话说从并发角度来看这和你写一行普通方法调用没有任何区别。正确姿势是调用 start()Thread thread new Thread(() - { System.out.println(Thread.currentThread().getName() 执行中); }); thread.start(); // 打印 Thread-0 执行中start() 会做两件事创建新的线程栈让 run() 在新线程里异步执行。线程的“并发”能力本质上是操作系统调度多个线程栈并行推进的结果而不是 run() 本身有什么魔力。我见过不少同学面试时脱口而出“调用 start 会执行 run 方法里的内容”这个说法对但不够本质。更准确地说start() 是在初始化一个可被调度执行的线程实体run() 只是这个实体启动后要执行的任务入口。这里有个开发中的小技巧如果搞不清当前代码跑在哪个线程里就在关键逻辑里打印 Thread.currentThread().getName()。我调线上问题时最常用的Debug手段就是靠这行日志快速定位线程归属尤其是排查多线程数据不一致时这一招能省下一大半时间。2.2 sleep() 与 yield()让出CPU的两种不同“姿势”sleep() 是线程API里使用频率最高的方法之一作用是让当前线程暂停指定毫秒数。但有两个细节很多人没注意sleep() 不会释放锁。如果线程在 synchronized 代码块里调用 sleep锁还是握在自己手里其他线程依然进不来。这就是“抱着锁睡觉”很容易成为死锁的温床。sleep() 会抛 InterruptedException。也就是说如果在 sleep 期间其他线程调用 interrupt()sleep 会立即结束并抛出异常。yield() 的含义是“礼让”告诉线程调度器当前线程愿意放弃CPU执行权让同样处于就绪状态的其他线程先跑。但注意调度器完全可以忽略这个信号继续执行当前线程所以 yield() 只是一个“建议”不保证一定有效。日常编码中我极少直接用 yield()因为它依赖具体平台调度器的实现可移植性差。如果你手头有一个任务需要定期执行千万别用 Thread.sleep 硬扛场景一多就乱套。更好的做法是用 ScheduledExecutorService后面讲线程池的时候会展开。2.3 join()等待一个线程“干完活”join() 的含义是当前线程等待调用 join 的线程执行完毕然后再继续执行。它通常解决的是“主线程过早结束导致子线程没跑完”的问题。Thread worker new Thread(() - { try { Thread.sleep(2000); System.out.println(worker 执行完成); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); worker.start(); worker.join(); // 主线程在这里等待 worker 执行完 System.out.println(main 继续执行);执行结果一定是先打印“worker 执行完成”再打印“main 继续执行”。join 还有一个带参版本 join(2000)表示最多等2秒超时后不等了。这个特性在做并发任务聚合时非常有用启动多个子线程并行处理最后在主线程里挨个 join等所有结果都回来再汇总。我在实际项目中用 join 最多的场景是“并行预热”比如系统启动时需要同时加载多个配置、缓存、模型每个耗时都不短但互不依赖开多个线程并行加载最后 join 等所有准备工作就绪再对外提供服务。这个方案对启动速度的优化立竿见影代码写起来也不复杂。2.4 interrupt()不是暴力打断而是“礼貌协商”interrupt() 是另一个高频API但它不是强行终止线程而是给目标线程打一个“中断标记”。被中断的线程需要自己在代码里检查这个标记然后决定怎么响应。Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { // 干活 } System.out.println(线程收到中断信号优雅退出); }); worker.start(); Thread.sleep(1000); worker.interrupt(); // 设置中断标记如果线程正卡在 sleep()、wait()、join() 等方法上调用 interrupt() 会让这些方法立即抛出 InterruptedException中断标记被清除。所以务实的处理方式是在 catch 块里重新设置中断标记保留中断信号。try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新打上中断标记 // 根据业务决定是继续跑还是退出 return; }这个习惯非常重要。如果吞掉了 InterruptedException 又不重新设置标记上层代码就无法感知线程曾被中断排查问题时极难定位。我见过线上服务线程“假死”的案例最后定位发现就是 catch 块里把异常打印后继续循环而中断标记已经被清掉了导致线程无法响应关闭指令。2.5 wait() 与 notify()线程间的“暗号”wait() 和 notify() 是 Object 类的方法它们必须配合 synchronized 一起使用而且必须在持有锁的代码块里调用否则会抛 IllegalMonitorStateException。wait()当前线程释放锁进入等待队列直到其他线程调用 notify() 或 notifyAll() 唤醒。notify()随机唤醒一个在该锁上等待的线程。notifyAll()唤醒所有在该锁上等待的线程让它们重新去竞争锁。这里有个经典的生产者消费者案例class TaskQueue { private final QueueString queue new LinkedList(); public synchronized void put(String task) { queue.offer(task); notifyAll(); // 通知消费者有任务了 } public synchronized String take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列为空释放锁等待 } return queue.poll(); } }注意 take 方法里用的是 while 而不是 if。原因是线程被唤醒后还需要再检查一次队列是否真的非空。如果用 if一旦多个消费者被唤醒其中一个抢到了任务另一个醒来直接 poll 就会拿到 null。这是面试高频考点也是实际编码最容易踩的坑。在虚拟线程模型下wait/notify 这套机制依然在用但更推荐用 ReentrantLock 搭配 Condition 来实现同样的功能因为 Condition 支持多条件队列语义更清晰而且支持 tryLock 等灵活操作。具体选择在讲线程互斥时再细说。3. 守护线程容易被忽略却处处存在的“后勤部队”3.1 什么是守护线程它和用户线程的本质区别JVM 里线程分两类用户线程和守护线程。用户线程是“主角”比如 main 线程、业务处理线程JVM 会等所有用户线程执行完毕后才会退出。守护线程是“配角”比如垃圾回收线程、GC、JIT编译器线程它们默默在后台干活一旦所有用户线程执行完毕JVM 不会等守护线程直接退出。这么说可能有点抽象打个比方用户线程就像舞台上正在表演的演员守护线程是台下的灯光师、音响师。演员全部退场了舞台灯再亮着也没意义整个剧场就关门了。这个“不等守护线程”的行为是很多线上事故的根源。最常见的就是项目里用守护线程做定时任务偶发任务刚执行到一半主线程退出JVM 直接关闭守护线程里的数据写入没完成造成数据丢失。下面详细讲讲原因和规避方式。3.2 如何正确创建守护线程创建守护线程很简单在 start() 之前调用 setDaemon(true) 即可Thread daemonThread new Thread(() - { while (true) { // 后台监控逻辑 } }); daemonThread.setDaemon(true); daemonThread.start();这里最重要的规则是setDaemon(true) 必须在 start() 之前调用否则会抛 IllegalThreadStateException。原因是线程启动后它到底是守护线程还是用户线程已经“定型”了不允许再修改。我遇到过一种容易出问题的情况在 Spring 容器启动时创建了一个守护线程做缓存定时刷新看起来一切正常。后来有次版本发布应用异常退出时这个守护线程正在写一批缓存数据到磁盘结果数据没写完用户一登录就“看到”了过期的脏缓存。排查了很久才发现是 setDaemon(true) 的锅。从那以后凡是要做持久化操作的逻辑我都不会放在守护线程里而是用线程池配合优雅关闭流程。3.3 守护线程实战一个简易的JVM指标采集器下面写一个我实际用过的守护线程案例启动一个后台守护线程每隔3秒采集一次 JVM 内存信息写入日志。这个程序在后台持续运行不影响主业务流程非常适合演示守护线程的使用方式。public class JvmMonitor { public static void startMonitor() { Thread monitor new Thread(() - { while (!Thread.currentThread().isInterrupted()) { Runtime runtime Runtime.getRuntime(); long totalMemory runtime.totalMemory() / 1024 / 1024; long freeMemory runtime.freeMemory() / 1024 / 1024; System.out.printf(JVM内存: 总计 %d MB, 空闲 %d MB, 已用 %d MB%n, totalMemory, freeMemory, totalMemory - freeMemory); try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, jvm-monitor-thread); monitor.setDaemon(true); monitor.start(); } public static void main(String[] args) throws InterruptedException { startMonitor(); // 主业务模拟 Thread.sleep(10000); System.out.println(主业务执行完毕); } }执行后你会发现主业务运行期间监控线程每3秒打印一次内存信息主业务结束、JVM 退出时监控线程也随之终止不会阻止程序退出。这正好体现了守护线程的典型使用场景监控、心跳、状态上报等辅助功能。如果把这套监控放到用户线程里主线程执行完了监控线程还在跑整个 JVM 会一直不退出你就得手动 shutdown非常麻烦。3.4 守护线程与线程池的关系一个容易忽略的细节很多同学第一次接触守护线程时会好奇ThreadPoolExecutor 里创建的线程到底是守护线程还是用户线程答案很明确默认是用户线程。ExecutorService 内部通过 ThreadFactory 创建线程默认线程工厂创建的是非守护线程。这意味着如果你用线程池执行周期任务忘调 shutdown()主线程结束后 JVM 可能不会退出因为线程池里的线程还活着。这是不少人写并发程序时卡住进程的隐形原因。解决办法有两种一是显式调用 shutdown() 或者是 shutdownNow()二是自定义 ThreadFactory把创建的线程设为守护线程ThreadFactory daemonThreadFactory new ThreadFactory() { private final AtomicInteger threadId new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(daemon-pool- threadId.getAndIncrement()); t.setDaemon(true); return t; } };这里要提醒一句让线程池线程变成守护线程是一种“偷懒”的做法不适合所有场景。如果池子里跑的是核心业务任务必须等它执行完才能退出那就不能用守护线程而要用 shutdown() 优雅关闭。判断标准很简单——任务失败会造成损失就不能用守护线程任务丢了也无妨但能防止进程挂起的可以考虑。4. 线程池配置与阻塞队列选择并发性能的核心开关线程池不是API但它直接决定了线程的生命周期和行为模式和守护线程的话题天然衔接。先搞清楚 ThreadPoolExecutor 的几个参数就不用再被网上一堆“线程池配置神文”绕晕了。4.1 ThreadPoolExecutor 核心参数构造函数里常见的7个参数corePoolSize核心线程数即使空闲也会保留。maximumPoolSize最大线程数超出核心线程数的部分会被创建但空闲超过 keepAliveTime 会被回收。keepAliveTime非核心线程的空闲存活时间。unit时间单位。workQueue任务队列存放当前无法立即执行的任务。threadFactory线程工厂可以自定义线程名和是否为守护线程。handler拒绝策略。其执行顺序可以总结成一句话核心线程先干活核心满了放队列队列满了开新线程线程达到最大值开始拒绝。这句话背下来面试问线程池基本能应付八成。4.2 阻塞队列怎么选五种方案对比阻塞队列的选择直接影响线程池行为这一点很多人会忽略。下面是我按场景整理的选择对照表队列类型特点适用场景ArrayBlockingQueue有界数组队列需要指定容量对任务数量有严格上限防止内存膨胀LinkedBlockingQueue无界链表队列默认容量 Integer.MAX_VALUE任务积压可容忍但不能无限增长否则OOMSynchronousQueue不存储任务直接提交给线程处理想尽最大可能创建线程处理任务的场景PriorityBlockingQueue优先级无界队列任务有优先级的场景比如紧急任务插队DelayedWorkQueue延迟队列ScheduledThreadPoolExecutor 专用定时任务、延迟任务我实际项目中最常用的组合是 core10、max20、队列选 ArrayBlockingQueue(1000)配合 CallerRunsPolicy 拒绝策略。原因很简单有界队列能限制任务积压量避免高并发瞬时涌入时内存被无界队列吃光拒绝策略选 CallerRunsPolicy 的意思是线程池饱和了就让“调用者线程”自己来执行任务相当于一个天然限流阀不会丢任务也不会把系统冲垮。这里补充一个踩坑经历之前服务高峰期接口响应变慢排查发现是用了无界 LinkedBlockingQueue任务无限排队导致平均等待时间飙升QPS反而下降。后来改成有界队列加执行超时控制情况立刻好转。队列的容量不是越大越好要根据任务处理耗时和可接受的排队等待时间来估算。4.3 线程池的优雅关闭shutdown 与 shutdownNowshutdown() 会等待所有已提交任务执行完毕后再关闭线程池期间不接受新任务。shutdownNow() 会尝试中断正在执行的任务并返回未执行的任务列表。ExecutorService executor Executors.newFixedThreadPool(4); // 提交任务... executor.shutdown(); // 优雅关闭 try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); // 等不及了就强制关闭 } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }这套“先优雅关闭超时强杀”的模板很实用。在 Spring Boot 项目里配合 PreDestroy 注解就可以在应用停机时先等线程池跑完存量任务超过时限再强制打断避免业务数据缺失。5. 线程互斥与死锁并发编程的“翻车现场”线程多了必然涉及共享资源的争夺。互斥是实现线程安全的基础而死锁则是线程协作中最常见的“事故”。搞懂这两块才算真正能驾驭并发编程而不仅仅是会new Thread。5.1 synchronized 与 Lock两种互斥方案的取舍synchronized 是JVM内置关键字使用简单自动释放锁Lock 是java.util.concurrent包下的接口功能更丰富。我直接用表格对比关键区别对比项synchronizedReentrantLock语法方法或代码块直接加锁Lock.lock() / Lock.unlock()释放锁自动释放必须在finally里手动释放中断响应不可响应中断lockInterruptibly 可响应中断超时控制不支持tryLock(timeout) 支持公平性非公平可指定公平或非公平日常业务里能用一个 synchronized 解决的问题绝不引入 Lock一旦你需要“超时获取锁”“可中断获取锁”或者“多个条件队列”ReentrantLock 才是合适的选择。盲目追求高版本API反而增加代码出错的概率。5.2 死锁的四个必要条件与实战排查死锁发生需要同时满足四个条件互斥、请求并保持、不可剥夺、循环等待。简单说就是“你等我释放我等你释放谁也不让谁于是全卡死”。来看一个经典死锁代码class DeadlockDemo { private final Object lockA new Object(); private final Object lockB new Object(); public void method1() { synchronized (lockA) { Thread.sleep(50); synchronized (lockB) { // 业务逻辑 } } } public void method2() { synchronized (lockB) { Thread.sleep(50); synchronized (lockA) { // 业务逻辑 } } } }线程A走 method1 持有 lockA 等 lockB线程B走 method2 持有 lockB 等 lockA两边互不相让就形成了死锁。排查死锁的实用手段是先用 jps 找到Java进程ID然后执行 jstack 看线程栈信息JVM会在输出里直接提示 “Found one Java-level deadlock”。很多团队做了线程池统一规范、禁止嵌套锁之后死锁问题至少减少一半。我自己团队里定了一套规矩获取多个锁时所有代码严格按相同的锁顺序获取比如总是先拿 lockA 再拿 lockB如果确实存在不可控的锁顺序就改用 tryLock 带超时时间拿不到锁就放弃而不是死等。5.3 常见问题速查表问题现象主要原因排查/解决思路程序退出但JVM没退出线程池未关闭或存在用户线程使用shutdown优雅关闭或自定义守护线程工厂调用 start 后 run 不异步执行误调用 run() 而不是 start()检查代码start才是启动线程入口wait 报 IllegalMonitorStateException没有在 synchronized 块内调用确保wait/notify在持有锁的代码块内线程卡死无响应死锁或线程池饱和jstack 抓取线程栈检查锁顺序和队列配置守护线程任务执行一半被终止JVM退出时守护线程直接消失关键任务改用用户线程优雅关闭6. 我对线程API和守护线程的几点实操心得做并发编程最深的体会就是API一个比一个简单组合起来一个比一个难。把 Thread 的每个方法单独拿出来看最多十分钟就能记住但真正到线上环境线程池、守护线程、互斥锁这些机制叠加在一起任何一点没想清楚都可能引发故障。给你一个少走弯路的建议先不要急着用高级并发工具。老老实实写几个用 Thread 控制并发的小例子把 start、join、interrupt、wait/notify 过一遍再去看线程池和并发工具类你会发现自己对底层逻辑的理解完全不一样。尤其是守护线程这套“后台后勤”机制在 Java 生态里无处不在GC线程、监控探针、任务调度背后几乎都是它的身影。但是记住一点需要落盘、需要事务保证的任务绝对不要丢给守护线程。我自己习惯在每个多线程模块里加一行启动日志打印线程名、是否守护线程、所属线程池参数。看似冗余真出问题时这行日志就是最快的定位入口。写并发代码先求稳定再求性能这个顺序任何时候都不能颠倒。