尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JUC线程中断机制详解:interrupt()与InterruptedException实战

JUC线程中断机制详解:interrupt()与InterruptedException实战 JUC之线程中断做并发编程绕不开线程中断这个问题。很多人第一次接触JUC里的线程中断时都会觉得它有点“绕”——明明叫中断但interrupt()方法并不会像stop()那样直接把线程干掉它只是设置了一个标志位。更让人困惑的是Thread.sleep()里抛出的InterruptedException到底是什么时候抛的为什么捕获之后循环还在跑为什么有时候调了interrupt()却没有任何反应这篇文章就把线程中断这个话题彻底讲透。我会从底层机制入手再结合JUC实际场景把interrupt()、isInterrupted()、Thread.interrupted()这三个方法的区别、中断标志位的生命周期、InterruptedException的触发时机以及线程池环境下中断的正确姿势都串起来讲。无论你是刚接触Java并发的新手还是已经在生产环境踩过中断坑的老手这篇文章应该都能帮你补上一些认知盲区。1. 为什么Java要设计“线程中断”而不是“线程停止”1.1 强制杀线程的代价在早期的Java版本中Thread.stop()方法可以强制终止一个线程。听起来很方便但这个API很快就被标记为废弃至今都没有移除原因就是它太危险了。假设一个线程正在执行一段同步代码中途被stop()强行终止那么它持有的锁会被释放但是锁里面的数据可能只写了一半。比如一个银行转账操作先扣了转出账户的余额正在给转入账户加钱的时候线程被杀了。此时锁释放其他线程读到的就是一个中间状态账户余额凭空消失了一部分。这类问题比死锁还要难排查因为数据已经坏了日志却看不出异常。所以Java后续的设计思路非常明确线程的终止权应该交给线程自己。框架和调用方只能发出“请求中断”的信号真正做决定的是线程内部的代码。这个思路和JUC的核心设计理念是完全一致的——JUC里几乎没有强制机制全是协作式、基于状态和队列的协同方案。1.2 协作式中断请求与响应的博弈协作式中断可以类比成工作场景里的“交接”。领导说“你这项工作先停一下”你不会立刻把手里一堆没归档的文件扔掉而是把当前正在处理的文件放到一个明确的位置记录好进度然后才离开工位。如果文件处理到一半直接扔地上就走后面接手的人根本不知道之前发生了什么。线程中断做的事情类似Thread.interrupt()方法会设置线程的中断标志位相当于给线程发了一个“请停下来”的信号。线程自身运行的代码需要在合适的时机检查这个标志位决定是否停止当前工作、是否需要清理资源、是否要把中断状态传递给调用方。这种设计的核心优势有三个。第一资源安全。线程可以在合适的时机释放锁、关闭IO流、保存中间状态避免数据损坏。第二可控性。线程可以决定在哪个点响应中断比如一个批量处理任务可以等一条记录处理完再检查中断而不是在一条记录写到一半时被强行打断。第三可传递性。中断请求可以在调用链中传播上层捕获后可以决定是继续传播、恢复中断状态还是吞掉中断。2. 中断机制的核心中断标志位到底怎么运作2.1 三个关键方法一张表讲清楚先记住一个最底层的概念每个Thread对象内部维护了一个boolean类型的中断标志位。所有中断操作本质上都是对这个标志位的读写。JUC源码中大量使用了Thread.interrupted()来清理和获取这个状态。方法作用是否清除中断标志位thread.interrupt()设置目标线程的中断标志位为true不清除thread.isInterrupted()查询目标线程的中断标志位不清除Thread.interrupted()查询当前线程的中断标志位清除恢复为falseinterrupt()是静态方法还是实例方法这里很容易搞混。interrupt()是实例方法是给指定线程发中断信号interrupted()是静态方法只能查当前线程的状态而且查询后会清除状态。isInterrupted()是实例方法只查询不修改。2.2 中断标志位的生命周期下面用一个简单的例子说明这个标志位怎么流转。这段代码里主线程启动了一个子线程t然后调用t.interrupt()子线程里做的事情是每隔一毫秒循环检查自己的中断状态。public class InterruptFlagDemo { public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { while (true) { if (Thread.currentThread().isInterrupted()) { System.out.println(检测到中断信号退出循环); break; } // 模拟一些轻量工作 System.out.println(工作了...); } }); t.start(); Thread.sleep(5); t.interrupt(); } }这个例子能在非常基础的层面说明问题。t.interrupt()把t线程的中断标志位置为truet线程在下次循环时通过isInterrupted()读到true然后break退出。这个例子虽然简单但它揭示了一个很多人在面试时答不出来的问题如果线程处于非阻塞状态也就是在执行普通代码没有调用sleep()、wait()等阻塞方法那么interrupt()只是设置了标志位线程不会自动停止也不会收到任何异常。线程能不能退出完全取决于代码里有没有检查这个标志位。2.3 阻塞状态下中断的行为差异如果线程正处于阻塞状态情况就完全不同了。这里说的阻塞状态主要是指三类Object.wait()/Thread.join()/Thread.sleep()这类导致线程进入WAITING或TIMED_WAITING状态的方法。BlockingQueue上的take()、put()等阻塞操作。**LockSupport.park()** 以及JUC里基于AbstractQueuedSynchronizer构建的锁等待过程。当线程处于这几种阻塞状态时另一个线程调用interrupt()会做两件事。第一把中断标志位清除重新置为false。第二让阻塞中的线程抛出InterruptedException。注意这个顺序很关键先清除标志位再抛异常。这意味着你在捕获到InterruptedException之后再调用Thread.currentThread().isInterrupted()读到的很可能是false。这个设计容易让人掉坑里。很多人处理InterruptedException时喜欢打印日志然后什么都不干这样中断信号就丢失了。正确做法应该是要么在catch块里调用Thread.currentThread().interrupt()恢复中断状态要么把中断异常向上抛通知调用链上的其他代码。3. 实操编写正确的可中断线程逻辑3.1 非阻塞任务的中断检查姿势非阻塞任务的中断检查核心是选择一个合适的检查频率。检查太频繁CPU空转浪费性能检查太少响应中断就会迟钝。我之前接手过一个数据处理服务启动了一个线程从数据库批量拉取待处理任务每批处理一万条后再拉下一批。最初的实现完全没有检查中断导致每次发布维护时必须靠失败重试机制来兜底服务才能停下来。改造后的代码结构大致是这样public class BatchDataHandler implements Runnable { private volatile boolean running true; Override public void run() { while (running) { ListDataEntity batch DataRepository.pullBatch(10000); if (batch.isEmpty()) { sleepUntilNextPoll(); continue; } for (DataEntity entity : batch) { if (Thread.currentThread().isInterrupted()) { System.out.println(收到中断信号保存当前位置并退出); saveCheckpoint(entity.getId()); return; } processEntity(entity); } // 每批次完成之后保存一个检查点方便下次启动续跑 saveCheckpoint(batch.get(batch.size() - 1).getId()); } } }这里有几个地方值得展开说。第一检查点放在每条数据处理完之后这样中断发生时不会出现一条数据只处理了一半的情况。第二检查频率被控制在“每处理一条检查一次”这个量级下isInterrupted()的性能开销可以忽略不计。第三退出前保存最后一条记录的位置让下次重启时可以直接续跑不用重头扫一遍。3.2 处理InterruptedException的正确姿势处理InterruptedException时要同时做两件事响应中断以及不吞掉中断状态。我见过最典型的错误写法是这样try { Thread.sleep(3000); } catch (InterruptedException e) { // 什么都不做 }这个写法的可怕之处在于它把中断信号彻底丢弃了。上层调用方的interrupt()请求完全无效你的线程表面上是“捕获了异常”实际上是把请求拦截下来销毁了。这会让线程陷入一种诡异的“假死”状态——既不响应中断又还在运行别人都不知道它到底有没有收到信号。正确姿势有两种。第一种如果当前方法无法处理中断就向上抛public void process() throws InterruptedException { Thread.sleep(3000); }第二种如果方法签名不允许抛受检异常就需要手动恢复中断标志位public void process() { try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(线程被中断, e); } }这里手动调用interrupt()不是“重新中断”而是恢复中断标志位。因为前面说过抛出InterruptedException时JVM已经把标志位清了你不手动恢复上层代码无论怎么检查都看不到中断发生过。这一行Thread.currentThread().interrupt()是无数并发Bug的根源——少了它中断信号就在调用链里静默消失了。3.3 InterruptedException的触发机制再深挖一层很多人好奇sleep()方法在读中断标志位的时候是轮询还是事件触发这个问题其实可以看HotSpot虚拟机的源码sleep()的实现最终会调用底层操作系统的定时器并且同时监听中断事件。当线程在睡眠过程中被interrupt()时底层会立即唤醒线程然后抛出InterruptedException。这就是为什么Thread.sleep(3000)遇到中断时不会乖乖睡满三秒而是几乎瞬间就抛异常了。JUC里CountDownLatch.await()、Semaphore.acquire()这些阻塞方法也是一样的机制——它们内部都委托给AbstractQueuedSynchronizerAQS在检测到中断时会让阻塞线程立即返回并抛出InterruptedException。有一个容易被忽略的细节是如果线程在进入阻塞方法之前就已经被设置了中断标志位那么阻塞方法会立刻抛出InterruptedException。这个特性在实际项目中很有用。比如你想让一个正在等待队列元素的线程退出等待可以直接在外部调用thread.interrupt()前提是等待逻辑正确处理了中断。4. 线程池场景下的中断别再只盯着shutdownNow了4.1 线程池如何响应外部中断JUC里最常用的ThreadPoolExecutor对外提供shutdown()和shutdownNow()两个关闭方法。shutdown()是温柔的它拒绝新任务提交但让已经在执行中的任务自然跑完。shutdownNow()是激进的它会尝试停止所有正在执行的任务实现方式就是对工作线程逐个调用interrupt()。遗憾的是很多团队对shutdownNow()有着不切实际的期望以为提交给线程池的任务一定会被打断。实际上shutdownNow()只是对所有工作线程调用了interrupt()任务代码如果完全不检查中断标志位也不处理阻塞状态下抛出的InterruptedException那么线程依旧会继续执行服务关闭依然要等任务自然结束。public class ThreadPoolShutdownDemo { public static void main(String[] args) { ExecutorService pool Executors.newFixedThreadPool(2); pool.submit(() - { while (true) { try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println(任务线程被中断准备清理退出); Thread.currentThread().interrupt(); break; } System.out.println(工作线程还在执行任务...); } }); // 让任务跑一会儿再关闭线程池 try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } pool.shutdownNow(); } }这个案例里任务写在while(true)循环里如果catch块里没有break或者return即使线程被中断了循环还是会继续跑。如果任务是一个普通的数据库查询循环每次查询可能需要几秒钟那么在这些查询操作期间中断标志位虽然被设置了但任务代码只有在查询返回后检查标志位才能发现被中断了。所以shutdownNow()能保证的是“尝试”中断能不能真正停得干净取决于每个任务的实现质量。4.2 自定义ThreadFactory来感知中断状态线程池场景下还有一个很少被聊到但非常实用的技巧通过自定义ThreadFactory给工作线程加一个“中断退出”的钩子。比如有些业务需要在线程被中断时执行一些清理操作比如关闭数据库连接、清理ThreadLocal中的用户上下文等。public class CustomThreadFactory implements ThreadFactory { private final String poolName; public CustomThreadFactory(String poolName) { this.poolName poolName; } Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(poolName - t.getId()); // 可以给线程设置一个默认的UncaughtExceptionHandler t.setUncaughtExceptionHandler((thread, throwable) - { if (throwable instanceof InterruptedException || thread.isInterrupted()) { System.out.println(线程 thread.getName() 被中断执行清理); // 这里可以释放ThreadLocal绑定的资源 } }); return t; } }我在一些长连接服务里用过这个模式。比如服务里维护了一批客户端连接每个连接占用一个线程当服务关闭时希望这些线程被中断后能主动关闭连接把连接资源交还给操作系统。通过UncaughtExceptionHandler配合中断检查可以比较干净地处理这类场景。4.3 中断状态在父子任务间的传播实际业务中很常见的一个痛点是一个任务线程内部又提交了子任务到另一个线程池那么中断信号应该如何传递比如有一个消息处理服务主任务从消息队列中拉取一条消息然后拆成几个子任务提交到另一个线程池并行处理。此时主任务被中断了如果主任务继续等待子任务完成很可能会因为子任务迟迟不结束而无法响应中断。这种场景下比较好的做法是主任务捕获InterruptedException后调用子任务池的shutdownNow()向所有子任务广播中断。主任务不再无限等待子任务而是设置一个超时时间超时后放弃等待直接退出。子任务代码同样需要正确处理中断状态否则广播是无效的。这说明中断具有传播属性。设计不好上层的中断信号会卡死在下层的某个阻塞调用里。JUC里ExecutorService的invokeAll、Future.get()的带超时版本都是辅助实现这种传播的常用工具。Future.get()在任务执行期间如果当前线程被中断会抛InterruptedException而有超时版本的get(long timeout, TimeUnit unit)还能防止无限期阻塞。5. 常见问题与排查技巧实录5.1 高频踩坑速查表问题现象根本原因解决方案调用了interrupt()但线程一直运行不退出线程处于非阻塞状态代码没有检查中断标志位在业务循环中增加isInterrupted()检查捕获InterruptedException后中断丢失catch块没有恢复中断状态在catch块中调用Thread.currentThread().interrupt()shutdownNow()后线程池没有立即停止任务代码没有正确处理中断信号改造任务代码让阻塞操作和循环都响应中断Thread.interrupted()返回true但下次调用返回false静态方法interrupted()会清除中断状态如需保留状态改用isInterrupted()阻塞队列的take()一直在等外部中断没反应队列的阻塞等待对中断的处理依赖AQS可能没有做中断恢复检查代码是否在catch里重新中断并退出循环5.2 问题一中断状态被误清除静态方法Thread.interrupted()的语义是“查询并清除”。这个方法的“副作用”经常引发诡异Bug。我遇到过一个典型案例业务代码里用while (!Thread.interrupted())作为循环条件然后循环体内又有一段代码也调用了Thread.interrupted()做检查结果第二次调用返回的是false因为第一次调用已经把状态清掉了。分析这段代码的执行流程第一次进入while循环时如果线程已经被中断Thread.interrupted()返回true取反后为false循环不进入逻辑正常。但如果循环体内再次依赖中断状态做判断就会发现在第一次调用后状态已经被重置了。解决办法是改用isInterrupted()。这个方法的语义是“只查询不清除”不会破坏其他地方的判断。// 错误示范循环条件里调用会清除状态的静态方法 while (!Thread.interrupted()) { // doWork(); if (Thread.currentThread().isInterrupted()) { // 这里的判断有意义因为是实例方法不清除状态 break; } } // 改进建议如果只是为了查询优先用isInterrupted() while (!Thread.currentThread().isInterrupted()) { // doWork(); }5.3 问题二假死的生产者线程还遇到过一个经典的生产者-消费者场景。生产者线程从数据库批量取数据然后通过BlockingQueue.put()放入内存队列。消费者线程消费这批数据。服务关闭时系统对生产者调用了interrupt()生产者线程确实捕获到了InterruptedException但它只是打印了日志就继续往下执行了。现象就是数据库连接已经关闭了队列也已经被清空了但生产者线程还活着并且在日志里疯狂输出“队列已满等待空闲空间”的警告。其实是因为put()方法每被中断一次就抛一个异常catch块只记日志不跳出循环线程又立刻进入下一次put()等待而put()在进入阻塞之前发现中断标志位已经被清了所以又没有异常线程就卡在队列满的等待上。这种问题排查起来很有迷惑性因为日志里能看到明显的InterruptedException给人的直觉是“已经捕获了应该会退出”。实际上捕获之后必须显式退出循环。修改方法是catch块里break或者returnwhile (running) { try { queue.put(buildMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println(收到中断信号生产者退出); break; } }5.4 问题三锁等待的中断响应JUC里的锁实现如ReentrantLock的lock()方法不响应中断但lockInterruptibly()是响应中断的。这两个方法的差异非常关键尤其在设计需要取消的并发任务时。简单说lock()方法即使线程被中断了也会继续等待锁不会抛异常中断状态会保留在标志位中。lockInterruptibly()则不同如果线程在等待锁的过程中被中断会立刻抛出InterruptedException。业务上应该怎么选我给你一个简单的判断标准如果这是一个必须拿到锁才能往下走的核心流程中断不应该打断你那用lock()如果这是一个可以取消的辅助任务用户在界面上点了取消等待锁的线程应该尽快退出那用lockInterruptibly()。ReentrantLock lock new ReentrantLock(); try { lock.lockInterruptibly(); // 执行受锁保护的业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println(锁等待被中断); } finally { // 中断后不能贸然释放锁只有加锁成功才释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有一个细节必须提一下finally块里释放锁之前要判断当前线程是否持有锁否则你没拿到锁就执行unlock()会抛IllegalMonitorStateException。这也是一个容易让人抓狂的隐藏问题。5.5 中断并不是万能的还得靠协作JUC里的FutureTask是支持取消的它的cancel()方法核心逻辑就是依赖中断机制。FutureTask.cancel(true)会对执行任务的线程调用interrupt()。所以如果你的任务代码没有响应中断Future.get()在调用cancel(true)后依然会继续阻塞等待执行结果。有一种性能优化思路是在任务中定期执行Thread.onSpinWait()或Thread.yield()给其他线程让出CPU同时让出时间片也意味着中断检测的间隔更短。这种手段不常用但在编写高频检查的自旋任务时可以考虑。经验之谈一个高质量的任务代码应该做到以下三点所有阻塞操作都能响应中断包括sleep()、wait()、阻塞队列、锁等待、CountDownLatch.await()等。业务循环中定期检查中断标志位检查频率和单次业务操作耗时成反比。单次操作越耗时检查越频繁。捕获InterruptedException后要么恢复中断状态要么重新抛出绝不能让中断信号在自己的眼里消失。6. 一些个人实操体会把线程中断彻底摸透之后再回头看JUC的很多东西会有一种豁然开朗的感觉。比如Future.cancel()、ExecutorService.shutdownNow()、ForkJoinPool的任务取消底层全都是在操作这个小小的中断标志位。平时困扰大家的“调用cancel()为什么没有用”本质上就是中断没有被正确响应线程还沉浸在自己的业务逻辑里。我自己写并发代码时现在会默认养成几个习惯所有的Thread.sleep调用都捞一眼有没有可能被中断所有的InterruptedException捕获后除非方法签名明确标了throws InterruptedException否则一律手动恢复中断状态线程池关闭时先用shutdown()再确认任务是否全部结束必要时配合awaitTermination()等待超时后再动用shutdownNow()并且要明确地知道shutdownNow()不是银弹。对于初学线程中断的人来说不要把精力全耗在背方法签名上面。先把那个“中断标志位”的模型在脑子里建立起来——什么操作会置位、什么操作会清除、谁在什么时候读取这个标志位这三件事想清楚了绝大多数中断相关的Bug都能一眼定位。线程中断并不是多么神秘的机制它只是JVM给上层代码提供的一根控制线踩不踩这根线、什么时候踩全看你的代码怎么配合。这也是Java并发设计中“协作优于强制”理念最直观的一次体现。
返回列表