聊聊Java线程:从“一个人干所有活”到“团队协作”
今天想和大家聊聊Java里一个非常基础但又特别重要的概念——线程。可能你刚学Java的时候写代码都是顺序执行的main方法里一行一行往下走像一条流水线。这在我们写“学生管理系统”或者“图书借阅系统”的时候完全够用。毕竟就那么几个用户谁在乎那几十毫秒的响应时间呢但是一旦你的程序要面向真实用户情况就完全不一样了。痛点一个请求里的“排队干活”太慢了假设你开发了一个实时数据分析面板。用户打开页面时后端需要同时获取三份数据从订单库统计今日销售额耗时1秒从用户库查询活跃用户数耗时1秒从日志系统拉取访问量耗时1秒如果按照常规写法代码是一个接一个顺序执行的// 串行调用三个接口 OrderData order orderService.getTodaySales(); // 1秒 UserData user userService.getActiveUsers(); // 1秒 LogData log logService.getPageViews(); // 1秒 // 总耗时 3 秒用户要等 3 秒才能看到完整面板在用户看来页面一直转圈体验很差。这时候你肯定会想“这三件事彼此独立为什么不能同时去做呢如果并行调用总时间不就缩短到大约1秒了吗”没错这就是多线程的价值所在——把一个大任务拆成多个独立子任务让它们并发执行总耗时约等于最慢的那个子任务而不是所有子任务之和。在Java里实现这种并行能力靠的就是线程Thread。每个线程像是一个独立的小工人主线程负责这个请求的线程可以把不同的子任务分别交给不同的工人去干然后主线程自己等待所有工人干完再合并结果返回给用户。那么问题来了怎么在Java里“招工人”呢环境说明需要 JDK8 或者以上为了可以看到控制台输出是哪个线程执行的下文的打印日志的代码log.info()、log.error()都用到slf4j日志库的依赖!-- 日志 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version2.0.17/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.17/version /dependency自己调试的时候加上这一句就好// import org.slf4j.Logger; // import org.slf4j.LoggerFactory; private static final Logger log LoggerFactory.getLogger(Test.class); // 写自己的类名创建线程的三种姿势方式一直接继承Thread类这是最原始的方式就像你直接招聘一个人告诉他“你就干这个”。Thread t1 new Thread(t1) { Override public void run() { log.info(hello我是新线程); } }; t1.start();这里我们重写了run()方法里面放的就是这个线程要执行的任务。然后调用start()线程就真正跑起来了。注意别直接调run()调run()相当于还是在主线程里执行根本没启动新线程。只有start()才会让操作系统去创建一个新线程。这种方式简单粗暴但有个问题Java是单继承的你继承了Thread就不能再继承别的类了。万一以后想用线程池这种写法也很别扭。方式二实现Runnable接口这是更推荐的方式。我们把要执行的代码和执行代码的线程 分开。// 定义任务使用匿名内部类实现 Runnable 接口 Runnable task new Runnable() { Override public void run() { log.info(hello我是任务); } }; // 把任务交给线程去执行 Thread t2 new Thread(task, t2); t2.start(); // ---------- Lambda表达式 改造 ------------- // 定义任务 Runnable task () - log.info(hello我是任务); // 把任务交给线程去执行 Thread t2 new Thread(task, t2); t2.start();这样Runnable只管“做什么”Thread只管“谁来做”。任务类可以继承其他类也更方便和线程池配合。方式三FutureTask配合Callable前两种方式任务执行完了就完了没法返回结果。如果我想让线程计算一个值然后主线程拿到这个结果怎么办呢这时候就需要FutureTask和Callable出场了。FutureTaskInteger task3 new FutureTask(() - { log.info(计算中...); return 100; }); Thread t3 new Thread(task3, t3); t3.start(); // 主线程阻塞等待结果 Integer result 0; try{ result task3.get(); } catch (InterruptedException | ExecutionException e) { log.error(获取结果失败{}, e.getMessage()); } log.info(结果是:{}, result);Callable和Runnable类似但它的call()方法有返回值还能抛出异常。FutureTask像个中间人它实现了Runnable所以能交给Thread去跑同时它内部又保存了Callable跑完的结果存下来等别人通过get()来取。这三者的关系我用一张图来展示简单来说Thread就是那个“工人”Runnable工人的“任务清单”没有返回值Callable工人的“任务清单”有返回值还可以抛异常FutureTask既是“任务清单”又带了个“保险箱”存计算结果有了线程我们就能并发执行了。但并发会带来新的问题。线程的协作join让主线程“等待”主线程启动了子线程去执行然后自己继续处理其他请求。但有时候主线程必须等子线程完成之后才能继续——比如子线程把数据加载到内存主线程才能去展示。这时候就需要join()。看下面这段代码static int r 0; public static void main(String[] args) { Thread t1 new Thread(() - { try { Thread.sleep(1000); // 模拟耗时1秒 } catch (InterruptedException e) { throw new RuntimeException(e); } r 10; }); t1.start(); log.info(结果为:{}, r); // 这里打印的是0因为t1还没执行完 }主线程和t1是并行跑的主线程立马打印r而t1要1秒后才能把r改成10所以打印出来是0。解决方法在t1.start()之后加上t1.join()。t1.start(); t1.join(); // 主线程在这里“等待”t1执行完毕 log.info(结果为:{}, r); // 一定能打印出10join让主线程“排队等着”直到t1干完活。如果多个线程可以分别join总耗时约等于最慢的那个线程的时间。有没有同学看到这里发现FutureTask配合Callable 与 join 都能阻塞主线程那他两有什么区别呢又要怎么选择性使用呢让主线程阻塞的区别FutureTask配合Callable 与 join 都能阻塞主线程但两个存在本质区别区别一等线程和等结果join() 等待的是“线程的死亡”。它不关心线程执行完产生了什么数据只关心线程对象的状态是不是变成了 TERMINATED。FutureTask.get() 等待的是“结果的交付”。它关心的是 Callable 里的 call() 方法有没有算出那个值。如果没算完我就等着算完了我立马拿走。从代码视角来看场景是计算一个值// 场景计算一个值 static int result 0; // 1. 使用 join只能依赖共享变量 Thread t1 new Thread(() - { // 假设这里算出了 100 result 100; }); t1.start(); t1.join(); // 主线程堵在这直到 t1 彻底死掉 log.info(拿到的结果{}, result); // 拿数据得靠外部变量极其不方便 // 2. 使用 FutureTask直接返回结果 FutureTaskInteger task new FutureTask(() - { return 100; // 直接返回 }); new Thread(task).start(); Integer result2 task.get(); // 主线程堵在这直到任务算完把数据递出来 log.info(拿到的结果{}, result2); // 数据直接到手干净利落区别二异常处理join() 对异常“装聋作哑”。如果子线程内部发生了空指针异常除非你自己在 run() 方法里 try-catch 了否则主线程的 join() 是感知不到的。主线程只会想“咦怎么等了这么久还没完” 导致程序卡死或静默失败。FutureTask.get() 会把异常“带回来”。子线程里只要抛了异常get() 方法立马抛出 ExecutionException。主线程可以清晰地捕获到这个异常知道“哦原来计算过程出错了” 极大地方便了故障定位。区别三控制的灵活性join()只能死等或限时等。虽然提供了join(long millis)但如果你想中途不想等了想把线程“干掉”join本身无能为力。你只能配合interrupt()但打断逻辑写起来很繁琐。FutureTask天生支持取消。你可以调用task.cancel(true)来打断正在运行的任务。get()方法也支持超时设置get(1, TimeUnit.SECONDS)如果1秒没算出结果直接抛出TimeoutException主线程可以决定是重试还是放弃代码写起来优雅得多。区别四底层“等待机制”不同这部分内容下文会介绍可以看完下面的内容之后再回来过一遍。join()底层依赖wait()。看Thread.join()的源码它本质上是一个synchronized代码块里调用了wait()。当子线程执行完JVM 会调用notifyAll()唤醒主线程。它是纯粹的线程状态监听。FutureTask.get()底层依赖LockSupport.park()/unpark()。FutureTask内部维护了一个状态stateNEW、COMPLETING、NORMAL、EXCEPTIONAL 等。get()方法会判断状态如果不是完成状态就调用awaitDone()进行park阻塞。当call()执行完毕并赋值给outcome后会调用unpark唤醒等待的线程。它是专门为“数据传递”设计的等待机制。怎么选呢一个最简单的判断标准如果子线程干完活不需要返回任何结果只是把某条记录插进数据库或者把某个缓存清空那你就用Threadjoin()简单省事。如果子线程干完活需要返回一个结果比如算了个总额、查了个用户信息或者你担心它可能会超时/报错想优雅地处理这些情况闭眼选FutureTaskCallable。在实际大型项目里join()几乎被淘汰了。因为现在大家都是用线程池ExecutorService提交任务返回的本身就是Future对象。既能拿到返回值又能做超时控制还能统一处理异常完美契合微服务下的 RPC 调用场景。让线程“睡一会儿”sleep有时候我们想主动让线程暂停一段时间比如每隔1秒去检查一次某个状态。这时候用Thread.sleep(1000)让当前线程休眠1秒。Thread.sleep(1000); // 单位毫秒sleep会让线程从运行状态进入阻塞状态把CPU时间让给其他线程。休眠结束后线程回到就绪状态等待调度器分配时间片所以睡眠结束后的线程未必会立刻得到执行需要等待时间分片。建议用TimeUnit.SECONDS.sleep(1)可读性更好底层还是sleep。小心sleep可以被中断如果一个线程正在sleep其他线程可以调用它的interrupt()方法打断它。Thread t1 new Thread(() - { try { Thread.sleep(2000); } catch (InterruptedException e) { log.error(被打断了赶紧处理); } }); t1.start(); // 主线程等1秒后打断 Thread.sleep(1000); t1.interrupt();sleep被打断后会抛出InterruptedException并且打断标记会被清除后面讲。实际开发中我们依然极力反对使用 sleep 轮询浪费内存和线程资源为了保持轮询你必须让一个线程一直活着或者在循环里一直睡。每个线程都需要分配独立的栈内存Java 中默认通常是 1MB。如果你有 1000 个这样的轮询任务就需要 1000 个线程白白占用约 1GB 内存。浪费响应时间假设你每隔 1 秒sleep一次来检查某个条件。如果条件刚好在你 sleep 的第 0.1 秒时满足了你的程序必须等到这 1 秒结束才能发现。这种“轮询间隔”带来了无法避免的最大延迟。浪费代码优雅性轮询是一种“拉Pull”模型程序像个无头苍蝇一样反复去问“好了没好了没”。而现代编程更推崇“推Push”模型即条件满足时主动通知程序。线程的“礼貌让座”yieldsleep是“我累了必须睡一会儿谁都别叫我”而yield则是“我暂时不急着用CPU你们谁想用就先拿去用”。yield 到底在干啥调用Thread.yield()方法是在提示注意是“提示”不是“命令”操作系统调度器“我现在这轮时间片用得差不多了如果别的线程有急事要处理我愿意把CPU让出来给它们先跑。”它会让当前线程从“运行状态”Running退回到“就绪状态”Runnable然后操作系统会重新进行一次CPU竞争。当前线程会和其他所有就绪线程一起公平地参与下一轮的“抢CPU时间片”大战。所以它只是个“建议”操作系统调度器可以完全无视这个请求。在 Linux 和 Windows 等主流系统上它的作用微乎其微甚至有时候直接等同于“啥也不干”。特别注意yield 与 sleep 一样不释放锁线程间的“暗号”wait 与 notify前面我们聊了sleep让线程自己休息join让一个线程等另一个线程结束。但还有一种更灵活的协作场景线程A需要等待某个条件满足才能继续而线程B负责改变这个条件并在改变后通知线程A。这种场景下sleep轮询太浪费资源join又太死板只能等线程结束。这时候就该wait()和notify()出场了。一个经典的“生产者-消费者”场景想象一个厨房厨师生产者做好菜放到出餐台上服务员消费者从出餐台取菜送到顾客桌上如果出餐台是空的服务员不能干等一直循环检查太费资源他应该休息等厨师做好菜通知他。代码怎么写public class Test { private static final Logger log LoggerFactory.getLogger(Test.class); public static void main(String[] args) { // 1. 共享对象出餐台 Object counter new Object(); // 2. 使用 AtomicBoolean 包装布尔值使其在 Lambda 中可修改 AtomicBoolean hasFood new AtomicBoolean(false); // 消费者服务员 Thread waiter new Thread(() - { synchronized (counter) { // 3. 使用 get() 获取值 while (!hasFood.get()) { try { System.out.println(出餐台为空服务员休息等待...); counter.wait(); // 释放锁进入等待状态 } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(服务员取走了菜); // 4. 使用 set() 修改值 hasFood.set(false); } }); // 生产者厨师 Thread cook new Thread(() - { synchronized (counter) { System.out.println(厨师正在做菜...); try { Thread.sleep(2000); // 模拟做菜耗时 } catch (InterruptedException e) { log.error(做菜失败{}, e.getMessage()); } // 5. 使用 set() 修改值 hasFood.set(true); System.out.println(厨师做好了菜通知服务员); counter.notify(); // 唤醒一个等待的线程 } }); waiter.start(); cook.start(); } }执行结果出餐台为空服务员休息等待... 厨师正在做菜... 厨师做好了菜通知服务员 服务员取走了菜wait() 和 sleep() 到底有什么不同这两兄弟太容易搞混了我列个表帮你理清楚特性sleep()wait()属于哪个类Thread的静态方法Object的实例方法是否释放锁不释放抱着锁睡觉释放锁把锁让出来必须配合 synchronized不需要必须否则抛异常如何唤醒时间到自动醒或被interruptnotify()/notifyAll()唤醒使用场景单纯让线程暂停一段时间等待某个条件满足wait()释放锁这个特性是它和sleep最本质的区别。sleep是“我虽然不干活但锁我还攥着不给你”wait()是“我没法干活了锁给你你们谁先干”。为什么 wait() 必须在 synchronized 块里因为wait()的设计初衷就是在持有锁的前提下暂时放弃锁并等待。如果没加锁就调用wait()JVM会直接抛出IllegalMonitorStateException。synchronized保证了调用wait()之前当前线程已经拿到了锁调用wait()之后锁被释放其他线程可以进入临界区被notify()唤醒后线程要重新竞争锁拿到锁才能继续执行为什么 wait() 要用 while 循环包裹而不是 if这是一个经典陷阱。// 错误写法 if (!hasFood) { wait(); } // 醒来后直接取菜 // 正确写法 while (!hasFood) { wait(); } // 醒来后再次检查条件原因有两个虚假唤醒Spurious Wakeup操作系统可能在没有notify()的情况下唤醒线程用while可以重新检查条件避免误判。多消费者竞争如果多个服务员在等菜厨师notify()唤醒一个这个服务员取走了菜hasFood又变成false。如果另一个服务员也被意外唤醒用while检查会发现没菜继续等待。记住口诀wait() 永远放在 while 循环里notify() 与 notifyAll()notify()随机唤醒一个在等待的线程notifyAll()唤醒所有在等待的线程如果只有一个消费者用notify()就够。如果有多个消费者用notifyAll()更安全——唤醒所有竞争者它们自己抢锁抢到的干活没抢到的继续等。收到我之前写的interrupt部分确实有点“照本宣科”没把背后的逻辑讲透尤其是**“两阶段终止”**这个模式代码虽然给了但没解释清楚“为什么要这么写”。这次我们重写这部分。核心思路是interrupt不是“杀死线程”而是“发送信号”。我们把信号机制讲透再聊“两阶段终止”就很自然了。以下是重写后的内容你可以直接替换原文中的对应章节3.9 优雅地叫停interrupt前面我们聊了怎么“招小弟”创建线程怎么让线程“睡觉”sleep和“排队等待”join等。但有个很棘手的问题还没解决如果小弟线程干活干到一半老板主线程想让他停下来该怎么办你可能会说“简单调用Thread.stop()不就行了”千万别stop()方法就像直接拔掉电脑电源太暴力了。如果线程正拿着锁操作共享数据你这一拔锁没来得及释放数据也可能写到一半其他线程再也进不来整个程序就僵住了。所以Java 提供了一个更优雅的方式interrupt()。它不“杀死”线程而是给线程发一个“中断信号”。收到信号的线程自己决定怎么处理——是立刻停、做完手头事再停还是干脆无视。interrupt 的本质发信号 解锁interrupt()底层干了两件事把线程内部的“打断标记”设为true相当于举了个小红旗。如果线程正处在某些阻塞状态比如sleep、wait、park就把它“拽”出来让它不再阻塞。注意打断标记就像个开关默认是false。interrupt()把它拨到true但线程到底停不停完全看线程自己的代码怎么处理这个标记。场景一打断正在“睡觉”的线程sleep / wait / join如果线程正在sleep或wait调用interrupt()会直接让它醒过来并抛出一个InterruptedException。有一个特别容易踩的坑抛异常的同时打断标记会被清空重置为false看这段代码Thread t1 new Thread(() - { try { Thread.sleep(2000); // 睡2秒 } catch (InterruptedException e) { // 被叫醒了 log.info(被打断了); } // 检查一下打断标记 log.info(标记状态{}, Thread.currentThread().isInterrupted()); }, t1); t1.start(); Thread.sleep(500); // 主线程等0.5秒 t1.interrupt(); // 发送中断信号输出结果[t1] INFO com.hpan.javaproject.Main - 被打断了 [t1] INFO com.hpan.javaproject.Main - 标记状态false -- 注意标记被清空了明明调用了interrupt()打印出来却是false。这是因为 JVM 在处理InterruptedException时自动把标记位清零了。这个时候如果你在catch块里啥也不做上层代码就再也感知不到“曾经有人想中断这个线程”了。这是个很隐蔽的坑。场景二打断正在“跑”的线程正常运行如果线程正在执行正常的业务逻辑没有阻塞interrupt()只是把标记设为true线程依然在跑毫发无伤。这时候需要线程自己“识趣”主动检查标记hread t2 new Thread(() - { while (true) { // 时不时看看有没有人叫我停 if (Thread.currentThread().isInterrupted()) { log.info(收到中断信号主动退出); break; } // 否则继续干活... } }, t2); t2.start(); Thread.sleep(5000); t2.interrupt(); // 把标记设为 true输出[t2] INFO com.hpan.javaproject.Main - 收到中断信号主动退出这种场景下标记不会被清空一直是true直到线程结束。实战两阶段终止模式让线程体面退场现在我们把上面的知识串起来解决一个实际问题。假设我们有一个监控线程每隔1秒保存一次数据无限循环。现在主程序要关闭了怎么让这个监控线程优雅地停止所谓“优雅”就是要给它一个“料理后事”的机会——比如把最后一批数据存盘、关闭数据库连接。错误示范stop() 和 System.exit()stop()直接杀死线程锁不释放数据不落盘极其粗暴。System.exit()直接把整个 JVM 都关了监控线程连遗言都没有。正确实现利用 interrupt 信号核心思路分两个阶段第一阶段发出通知主线程调用t.interrupt()。第二阶段处理善后监控线程收到信号后执行清理工作然后退出。看下面这个经典的写法特别注意catch块里的那行注释TPTInterrupt 类class TPTInterrupt { private Thread thread; private static final Logger log LoggerFactory.getLogger(TPTInterrupt.class); public void start() { thread new Thread(() - { while (true) { // 阶段2每次循环都检查一下有没有人叫我停 if (Thread.currentThread().isInterrupted()) { log.info(料理后事释放资源...); break; // 体面退出 } try { // 模拟监控每隔1秒保存一次数据 Thread.sleep(1000); log.info(数据已保存); } catch (InterruptedException e) { // 关键点sleep被打断时打断标记被清空了 // 如果不重置标记下次循环到顶部检查 isInterrupted 就是 false // 线程就会继续跑永远停不下来。 // 所以必须重新调用 interrupt() 把标记再设为 true Thread.currentThread().interrupt(); // 重置标记 } } }, 监控线程); thread.start(); } public void stop() { // 阶段1发出中断信号 thread.interrupt(); } }Main 类调用一下看看效果TPTInterrupt t new TPTInterrupt(); t.start(); // 监控线程启动每秒存一次数据 Thread.sleep(3500); // 主线程等3.5秒 t.stop(); // 主线程发出停止信号输出结果[监控线程] INFO com.hpan.javaproject.TPTInterrupt - 数据已保存 [监控线程] INFO com.hpan.javaproject.TPTInterrupt - 数据已保存 [监控线程] INFO com.hpan.javaproject.TPTInterrupt - 数据已保存 [监控线程] INFO com.hpan.javaproject.TPTInterrupt - 料理后事释放资源... -- 优雅退场为什么catch里必须调用Thread.currentThread().interrupt()因为sleep被打断时把标记清零了。如果不手动置回true循环回到顶部检查isInterrupted()就是false线程会觉得“没人叫我停啊”然后继续下一轮循环。这是个连环套路少一环都不行。补充打断 park 线程LockSupportLockSupport.park()也是阻塞线程的常用手段。打断park的线程不会清空打断标记。Thread t1 new Thread(() - { LockSupport.park(); // 阻塞 log.info(打断标记{}, Thread.currentThread().isInterrupted()); }); t1.start(); Thread.sleep(500); t1.interrupt(); // 打断 park输出[Thread-0] INFO com.hpan.javaproject.Main - 打断标记true但要注意一个陷阱如果打断标记已经是true再次调用park()会直接失效相当于没阻塞直接过去了。所以如果要用park记得配合Thread.interrupted()清除标记或者根据业务逻辑重新设计。线程的状态从出生到死亡线程从创建到结束会经历几种状态。从操作系统角度看分为五种初始、可运行、运行、阻塞、终止。【初始状态】仅是在语言层面创建了线程对象还未与操作系统线程关联【可运行状态】就绪状态指该线程已经被创建与操作系统线程关联可以由 CPU 调度执行【运行状态】指获取了 CPU 时间片运行中的状态当 CPU 时间片用完会从【运行状态】转换至【可运行状态】会导致线程的上下文切换【阻塞状态】如果调用了阻塞 API如 BIO 读写文件这时该线程实际不会用到 CPU会导致线程上下文切换进入 【阻塞状态】。等 BIO 操作完毕会由操作系统唤醒阻塞的线程转换至【可运行状态】与【可运行状态】的区别是对【阻塞状态】的线程来说只要它们一直不唤醒调度器就一直不会考虑 调度它们【终止状态】表示线程已经执行完毕生命周期已经结束不会再转换为其它状态但 Java 的API层面把它细分为六种NEW刚创建还没调用start()RUNNABLE调用了start()正在运行或等待CPU调度BLOCKED等待锁比如synchronizedWAITING无限期等待比如wait()、join()TIMED_WAITING有限期等待比如sleep(1000)、join(1000)TERMINATED执行结束注意RUNNABLE状态在操作系统层面可能被挂起时间片用完但在Java里我们并不关心反正它“随时可以运行”。守护线程默默在背后服务的“清洁工”默认情况下只要还有一个非守护线程在运行Java进程就不会退出。但有一种线程叫守护线程它是“配角”当所有非守护线程都结束了守护线程会被强制终止。比如垃圾回收器、Tomcat的后台线程都是守护线程。Thread daemon new Thread(() - { while (true) { // 做后台监控 } }); daemon.setDaemon(true); daemon.start();注意守护线程不应该访问共享资源比如文件、数据库因为它随时可能被强制结束导致资源没来得及释放。小结今天我们聊的内容有点多我帮你把核心脉络再拎一遍。整篇文章其实就围绕一个核心问题怎么让多个任务“同时”跑起来以及怎么让它们“配合”好。我们先是解决了“怎么创建线程”的问题——Thread、Runnable、CallableFutureTask三种方式层层递进从“把活和人绑死”到“把活和人分开”再到“干完活还能把结果递回来”。接着我们聊了“怎么让线程配合”——join让主线程等人sleep让自己休息yield客气地让出CPUwait/notify通过“暗号”协调彼此。它们各有各的适用场景别用混了。然后我们重点讲了“怎么优雅地叫停线程”——interrupt不是杀死线程而是发信号。真正决定停不停的是线程自己。两阶段终止模式就是这套思想的最佳实践。最后补充了线程的“生命周期”和“守护线程”这些底层认知帮你在排查问题时能更快定位。线程是Java并发编程的基石。理解这些基础知识后面学习线程池、锁、并发工具类就会轻松很多。如果这篇文章对你有帮助欢迎关注、点赞、收藏。咱们下期见