资深 Java 面试到 Agent 开发工程师 · 第 9 集
我自己做了一个 Java 并发小游戏《并发突围》,无广告、免费玩。点这里试玩。
接口返回“查询超时”,后台却还占着线程。原因是:get(timeout)只结束等待,不会自动取消任务。要让工作停下来,需要取消实际任务、让任务响应,并执行资源清理。
1. 超时以后,任务还会继续执行
用休眠两秒模拟耗时查询,调用方只等 100 毫秒。导入java.util.concurrent.*,把代码放进声明了throws Exception的main方法中:
ExecutorServicepool=Executors.newSingleThreadExecutor();try{Future<String>future=pool.submit(()->{Thread.sleep(2000);return"done";});try{future.get(100,TimeUnit.MILLISECONDS);}catch(TimeoutExceptione){System.out.println("等了 100 毫秒,还没有结果");}System.out.println(future.get());// 继续等,最终得到 done}finally{pool.shutdownNow();}main线程等结果,工作线程执行休眠。100 毫秒内没结果,main收到TimeoutException,工作线程仍会继续休眠。因此,第二次不带超时的get()还能等到done;它只是用来观察结果,实际接口不必继续等。
结果已经不需要时,可以在超时后申请取消:
try{returnfuture.get(1,TimeUnit.SECONDS);}catch(TimeoutExceptione){future.cancel(true);throwe;}这段只展示等待超时的分支。调用方若收到InterruptedException,也应按任务归属申请取消,再向上抛出异常或恢复中断标记。true表示允许尝试中断,任务需要配合。如果多个请求共用同一次查询,不能因其中一个超时就取消共享任务。
2. 收到中断以后,任务怎样退出
线程池submit通常返回 FutureTask。取消成功时,cancel(true)尝试中断工作线程。interrupt()会设置中断标记,任务检查它,或在可中断等待中处理异常。
下面的代码会忽略中断,继续循环:
while(true){try{Thread.sleep(1000);}catch(InterruptedExceptione){// 只记日志,然后继续循环}}sleep被中断时会抛出InterruptedException,并清除中断标记。这个catch在循环里面,处理完又睡下一轮。把它移到循环外,并检查标记:
try{while(!Thread.currentThread().isInterrupted()){Thread.sleep(1000);// 执行一小段工作}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{// 释放任务自己持有的资源}| 中断发生在哪里 | 这段代码怎样退出 |
|---|---|
正在sleep | 不必睡满一秒,抛出异常,进入循环外的catch,随后执行finally |
| 检查循环条件之前 | isInterrupted()返回true,结束循环,执行finally |
catch中的interrupt()恢复被清除的标记;恢复标记本身不会结束循环。方法允许抛出InterruptedException时,也可以交给调用者处理。
finally要写实际清理代码,例如关闭独占文件、归还借用的连接。清理完成、任务返回后,工作线程才能处理下一项任务;清理本身也可能耗时。
若调用方必须知道已开始的任务何时结束清理,可以单独使用CountDownLatch:任务在清理路径结束时调用countDown(),等待方用带超时的await()检查是否收到通知。通知只表示清理路径已走完;清理是否成功,还要单独记录结果。
上面的清理路径要求任务已经进入任务体。若任务仍在队列里就被取消,任务体和其中的finally都不会运行,也不会从那里发出“清理完成”的通知。任务内部才借用的资源尚未占用;提交前已取得的名额或资源,要由持有者协调归还。启动与取消可能同时发生,不能仅凭一次isDone()判断由谁清理。
一直计算的任务不会自动抛中断异常,需要主动检查:
for(inti=0;i<items.size();i++){if(Thread.currentThread().isInterrupted()){return;}process(items.get(i));}这段代码每项处理前检查一次。如果一次process卡住很久,也得等它返回才能再次检查。卡在网络或数据库调用里,还要用对应客户端支持的取消与超时机制。
3. 为什么 isDone 为 true,任务却还没退出
Future 的“完成”描述结果状态。FutureTask 取消成功后,isDone()、isCancelled()都为true,再get()会抛CancellationException,任务代码却可能继续运行:
OpenJDK 21 的实现如下。state记录 Future 状态,runner保存执行线程,参数mayInterruptIfRunning就是传入的true或false:
publicbooleancancel(booleanmayInterruptIfRunning){if(!(state==NEW&&STATE.compareAndSet(this,NEW,mayInterruptIfRunning?INTERRUPTING:CANCELLED)))returnfalse;try{if(mayInterruptIfRunning){try{Threadt=runner;if(t!=null)t.interrupt();}finally{STATE.setRelease(this,INTERRUPTED);}}}finally{finishCompletion();}returntrue;}| 源码位置 | 做了什么 |
|---|---|
state == NEW | 只有状态尚未被完成或取消修改,才继续尝试。任务已经运行时,也可以是NEW |
STATE.compareAndSet | STATE用来操作state。原子地检查并修改状态,别人抢先完成或取消,这次就返回false |
INTERRUPTING→runner.interrupt()→INTERRUPTED | 开始发送中断、通知执行线程、结束发送流程。这里没有等待任务退出 |
finishCompletion() | 唤醒等结果的线程,清理 Future 内部等待记录;不替任务关闭业务资源 |
传入false时不发中断。cancel(false)也可能成功,而已经开始的任务继续执行。取消状态和资源是否已清理,要分别确认。
4. 接口超时后,怎样取消查询、释放资源
先区分几个名字相近的操作:
| 操作 | 能做什么 | 是否会中断已运行的任务 |
|---|---|---|
Future.get(timeout) | 限制这次等结果的时间 | 不会 |
FutureTask.cancel(true) | 改为取消状态,尝试中断执行线程 | 会尝试,任务要响应 |
普通CompletableFuture.cancel(true),如supplyAsync返回值 | 把结果设为取消异常 | 不会,true在基类实现中不起作用 |
JDK 21 默认HttpClient.sendAsync返回的 Future,调用cancel(true) | 尝试取消实际 HTTP 请求 | 由客户端取消网络操作,不是靠中断某个计算线程 |
CompletableFuture.orTimeout(...) | 让结果以超时异常完成,Java 9 起提供 | 不会 |
普通 CompletableFuture 的基类实现如下;客户端定制的返回值,需要另看其约定:
publicbooleancancel(booleanmayInterruptIfRunning){booleancancelled=(result==null)&&internalComplete(newAltResult(newCancellationException()));postComplete();returncancelled||isCancelled();}result保存结果,AltResult包装取消异常,internalComplete尝试记录它,postComplete处理后续操作的完成。这里没有调用执行线程的interrupt()。例如,前面取消后,thenApply返回的 Future 也会异常完成,不再执行其中的函数。
实际查询应保留客户端的取消入口,或执行它的任务 Future。JDK 21 默认 HTTP 客户端对返回值定制了取消行为,所以不能只看变量类型;普通supplyAsync没有这项请求取消能力。对于已开始、仅服务本次请求的 FutureTask,取消成功后的流程如下:
查询交给 HTTP 或数据库客户端时,还要配置它支持的操作超时,必要时使用请求取消方法。是否中断读写、归还连接,要核对具体客户端约定;任务返回也不等于远端已经停止。清理只针对本任务持有的资源,不关闭共享连接池。
修改后,在测试环境让一次查询变慢,用同一个请求 ID 关联超时、取消、查询结束和清理记录:
| 观察到什么 | 接着检查哪里 |
|---|---|
| 接口超时,但没有取消记录 | 是否保存并取消了实际任务的 Future |
| 取消成功,查询却一直没结束 | 是否吞掉中断;客户端是否支持、启用了取消或超时 |
| 查询结束,连接仍未归还 | 响应体/连接的关闭及归还逻辑 |
| 排队任务已取消,队列却没变短 | 普通线程池可能还没移除包装对象;按需检查remove/purge,不必每次超时都扫描队列 |
如果底层调用没有中断、取消或有效超时支持,需要修改或更换调用方式。准备重试远端写入时,还要确认原请求结果或使用幂等设计;本地取消不能撤回已完成的写入。
不要用Thread.stop()强行结束查询。旧实现可能在共享数据只更新了一部分时释放锁,其他线程随后就会看到不完整状态;JDK 21 调用它会直接抛出UnsupportedOperationException。让任务在可退出的位置响应中断,才能执行上面讨论的清理流程。
5. 旧任务没停,新任务又来了
慢查询占着线程,新请求不断提交,队列就会积压。可以限制排队数量:
ThreadPoolExecutorpool=newThreadPoolExecutor(2,2,0L,TimeUnit.MILLISECONDS,newArrayBlockingQueue<>(20),newThreadPoolExecutor.AbortPolicy());2 和 20 只是容量示例,实际按下游承载、耗时与压测结果选择;服务中复用由应用管理的线程池。这个配置最多 2 项执行、20 项排队,满后抛RejectedExecutionException。线程池入队使用offer,队列满就失败,不会等空位。接收后的超时检查与拒绝后的处理如下:
图中 1 秒是整个请求的假设预算,包含排队。排队用了 800 毫秒,查询只剩约 200 毫秒,应使用客户端支持的总调用超时或截止时间。只把readTimeout设为 200 毫秒,不一定限制整次调用;例如 OkHttp 4.12 的读取超时针对单次读等待,callTimeout才覆盖整次调用及其中的重试。任务已过期就跳过,并让等待方收到超时结果。
| 措施 | 限制什么 | 怎么用在这个查询中 |
|---|---|---|
| 有界队列 | 最多积压多少任务 | 排队位置满了就拒绝,调用方处理繁忙 |
| 背压 | 根据处理速度放慢提交 | 后台每批处理完再读取下一批,每批数量有上限 |
| 速率限制 | 每秒接收多少请求 | 超出允许速率就拒绝或按约定等待 |
| 并发限制 | 同时有多少查询在执行 | 下游变慢时,仍限制同时调用的数量 |
例如每秒仍进来 10 个请求,查询从 100 毫秒变成 2 秒,未结束的查询会变多。因此,只限制请求速率还不够。并发名额若代表实际查询,就应在查询真正结束后归还,不能因调用方超时便提前释放。拒绝后立即反复重试,也会继续增加压力。
CallerRunsPolicy能让提交线程自己执行任务,从而减慢提交;但多个请求线程可能同时这样做,加上池内任务,查询并发就可能超过 2。它不能当作下游并发上限。
线程池的关闭是应用生命周期操作,不是单个请求的取消方式。shutdown()拒绝新任务,并让已提交任务继续执行;shutdownNow()尝试中断正在执行的任务,并返回尚未开始的排队任务。返回排队任务并不等于自动取消它们对应的所有 Future:持有者仍要处理这些任务,避免调用方一直等待。两种方法都不等待池完全退出;需要等待时,可以用有超时的awaitTermination()检查结果。
常见面试题怎么答?
怎样停止一个正在运行的线程?为什么不直接用 stop?
让任务响应中断,或检查有可见性保证的停止标志,再退出并清理。volatile停止标志可用于循环检查,但不能叫醒正在阻塞的线程。可中断等待则能通过InterruptedException响应。
旧版Thread.stop可能在线程只改了一半共享数据时强行终止,并释放锁,让其他线程看到不完整数据,因此被废弃。JDK 21 会直接抛UnsupportedOperationException。任务自行退出才能安排清理;卡在不响应取消的操作里,也暂时走不到finally。
Future.cancel(true) 和 CompletableFuture.cancel(true) 一样吗?
不一样。Future 接口允许实现尝试中断;FutureTask 会这样做,任务要响应。普通 CompletableFuture 通过取消异常完成结果,不中断已开始的计算;客户端可以定制取消行为,例如 JDK 21 默认HttpClient.sendAsync返回值能尝试取消 HTTP 请求。不能只凭类型名或isDone()判断底层工作和资源状态。
shutdown 和 shutdownNow 有什么区别?
shutdown()拒绝新任务,让已提交任务继续执行。shutdownNow()尝试中断执行中的任务,并取出、返回未开始的排队任务;这些任务对应的 Future 不会自动全被设为取消,需要处理,否则等待者可能一直等。
两者都不等所有任务结束再返回。关闭后,带超时的awaitTermination返回true才表示整个池已终止。单个请求超时应取消它的任务,不应关闭其他请求共用的线程池。
读完自测
cancel(true) 成功,isDone() 也为 true。能立刻把任务正在用的资源交给别人吗?
参考要点:不能。对已开始的任务,可在
finally清理后用CountDownLatch通知,等待方用带超时的await,并检查清理结果。若开始前就取消,任务体和finally都不会执行,需要单独处理未启动状态。取消后的get()不能用来等清理完成。总超时 1 秒,排队用了 900 毫秒,客户端读取超时还设 1 秒,有什么问题?
参考要点:排队已消耗预算,读取超时也不等于整个调用超时。应按同一截止时间算剩余时间,配合客户端支持的总调用超时或取消,已过期就跳过。获取连接、连接、读写和重试是否计入,要按客户端约定核对。
队列改成二十,满了就立即重试,能解决积压吗?
参考要点:不能。小队列只限制池中排队量,立即重试会增加提交。需要处理拒绝、限制并发,并约定次数有限的延迟重试;后台批处理可等本批完成再读下一批。
参考资料
- Future:等待、取消与完成状态
- Thread:中断和 stop 的约定
- CompletableFuture:取消与超时
- ThreadPoolExecutor:排队、拒绝和关闭
- JDK 21 HttpClient:中断与请求取消
- OkHttp 4.12:读取超时与总调用超时
- OpenJDK 21 FutureTask 源码
- OpenJDK 21 CompletableFuture 源码
源码按 OpenJDK 21(jdk-21+35)说明;文中 Java 8 可用的示例在 Zulu 8 上验证。orTimeout从 Java 9 起可用。