Java 全局异常捕获实战:别再让崩溃裸奔了

为什么会遇到这个问题

写 Java 最烦的事之一:线上跑得好好的,突然冒出个异常,日志一翻——空的。或者只看到一句 NullPointerException,根本不知道从哪蹦出来的。

更头疼的是,用户发来截图说「你的页面崩了」,你连哪个接口报的错都找不到。这种体验,做过后端的都懂。

Java 的异常机制本身很完善——try-catch、throws、finally——但实际项目里,总有一些漏网之鱼:

  • 开发忘了加 try-catch
  • 异步线程里的异常,主线程感知不到
  • 框架层抛出的异常,业务代码接不住

所以全局异常捕获不是一个「锦上添花」的功能,而是每个 Java 项目都应该有的基础设施。

全局异常捕获的本质

说白了,全局异常捕获就是给整个应用装一个「逃生网」。不管异常从哪冒出来,最终都能落到一个统一的地方处理,然后决定怎么响应——记录日志、返回友好的错误信息、或者做降级处理。

Java 层面提供了几个入口来做这件事,不同场景用不同的方案。

方案一:Thread.UncaughtExceptionHandler — 主线程最后的防线

这是 Java 最底层的全局异常捕获机制。当线程抛出异常但没有被 catch 时,JVM 会调用线程的 UncaughtExceptionHandler

最基本的用法

1
2
3
4
5
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
System.err.println("线程 [" + thread.getName() + "] 挂了,原因:");
throwable.printStackTrace();
// 这里可以发报警、写日志、做兜底
});

这段代码一写,整个 JVM 进程里所有线程未捕获的异常,都会走到这个回调里。

实际项目里的增强版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class GlobalExceptionHandler implements Thread.UncaughtExceptionHandler {

private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@Override
public void uncaughtException(Thread t, Throwable e) {
log.error("未捕获异常 - 线程: {} (id={}), 线程组: {}",
t.getName(), t.getId(), t.getThreadGroup() != null ? t.getThreadGroup().getName() : "null", e);

// 发送告警通知(邮件、钉钉、企业微信等)
alertService.sendAlert("线程 " + t.getName() + " 崩溃", e);

// 记录埋点
metricsCollector.increment("jvm.uncaught.exception");
}
}

启动时注册:

1
2
3
4
public static void main(String[] args) {
Thread.setDefaultUncaughtExceptionHandler(new GlobalExceptionHandler());
SpringApplication.run(YourApp.class, args);
}

容易踩的坑

setDefaultUncaughtExceptionHandler 设置的是全局默认处理器。如果某个线程自己调了 setUncaughtExceptionHandler 设置了专有处理器,那全局的这个不会覆盖它。这点要清楚——它不是万能的,它是兜底的兜底。

另外,守护线程的异常也会被捕获,但要注意守护线程不受 JVM 退出保护,它抛异常时 JVM 可能已经在退出了,handler 里的逻辑可能没跑完。

方案二:Spring Boot 的 @ControllerAdvice — Web 层的统一拦截

如果你的项目是 Spring Boot 或者 Spring MVC,@ControllerAdvice 是处理 Web 层异常最优雅的方式。

基本实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestControllerAdvice
public class GlobalWebExceptionHandler {

private static final Logger log = LoggerFactory.getLogger(GlobalWebExceptionHandler.class);

@ExceptionHandler(IllegalArgumentException.class)
public Result<Void> handleIllegalArgument(IllegalArgumentException e) {
log.warn("参数校验失败: {}", e.getMessage());
return Result.error(400, e.getMessage());
}

@ExceptionHandler(NullPointerException.class)
public Result<Void> handleNullPointer(NullPointerException e) {
log.error("空指针异常: ", e);
return Result.error(500, "服务器内部错误");
}

@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("未知异常: ", e);
return Result.error(500, "服务器内部错误");
}
}

配合自定义异常使用

光靠捕获内置异常不够灵活。实际项目里,大家都会搭配自定义业务异常:

1
2
3
4
5
6
7
8
9
10
11
public class BusinessException extends RuntimeException {
private final int code;
private final String message;

public BusinessException(int code, String message) {
super(message);
this.code = code;
}

public int getCode() { return code; }
}

然后在全局处理器里统一处理:

1
2
3
4
5
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}

从异常里获取更多上下文

Spring 的 @ExceptionHandler 还能注入更多参数,拿到请求上下文:

1
2
3
4
5
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
log.error("请求 {} {} 发生异常: ", request.getMethod(), request.getRequestURI(), e);
return Result.error(500, "服务器繁忙,请稍后重试");
}

不同异常返回不同 HTTP 状态码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
return Result.error(400, "缺少参数: " + e.getParameterName());
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public Result<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
return Result.error(405, "不支持的请求方法: " + e.getMethod());
}

@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Void> handleNotFound(NoHandlerFoundException e) {
return Result.error(404, "接口不存在");
}

处理顺序的坑

多个 @ExceptionHandler 的匹配规则是找最匹配的,不是按声明顺序。比如抛了 IllegalArgumentException,会优先命中专门的 handleIllegalArgument,而不是走 handleException

但要注意:如果你写了两个都能匹配到同一层级的处理器,顺序就不确定了。建议只保留一个宽泛的 Exception.class 兜底,其他的按具体异常类型写。

方案三:Filter + ErrorPage — Servlet 容器的保底

有些异常连 @ControllerAdvice 都抓不到——比如在 Filter 里抛的、在 Spring 的 DispatcherServlet 之前就炸了的。这时候就得靠 Servlet 容器级别的处理。

自定义 Filter 捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ExceptionCaptureFilter implements Filter {

private static final Logger log = LoggerFactory.getLogger(ExceptionCaptureFilter.class);

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
} catch (Exception e) {
log.error("Filter 层捕获异常: ", e);
HttpServletResponse resp = (HttpServletResponse) response;
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":500,\"message\":\"服务器内部错误\"}");
}
}
}

错误页面兜底

Spring Boot 在 application.yml 里可以配置:

1
2
3
4
5
server:
error:
path: /error
whitelabel:
enabled: false

然后自己写一个 /error 的 Controller:

1
2
3
4
5
6
7
8
9
10
@RestController
public class CustomErrorController implements ErrorController {

@RequestMapping("/error")
public Result<Void> handleError(HttpServletRequest request) {
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
String message = (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
return Result.error(statusCode != null ? statusCode : 500, message != null ? message : "未知错误");
}
}

这样连 404、405 这些 Spring 拦截不到的请求也能统一响应 JSON 了,而不是返回一堆丑陋的 HTML 错误页。

方案四:异步线程的异常捕获

开发中最容易被忽略的,就是线程池里跑飞了的异常。

线程池场景

1
2
3
4
5
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> {
// 如果这里抛异常,submit 能吞掉!
throw new RuntimeException("任务执行失败");
});

submit() 的返回值是 Future,异常被封装在 Future.get() 里。如果不调 get(),异常就被静默吞掉了。这是 Java 很多线上事故的根源。

正确做法

方案 A:确保调 get()

1
2
3
4
5
6
Future<?> future = executor.submit(task);
try {
future.get();
} catch (ExecutionException e) {
log.error("异步任务执行异常", e.getCause());
}

方案 B:使用 execute 代替 submit

1
2
3
4
5
6
7
8
executor.execute(() -> {
try {
// 业务逻辑
} catch (Exception e) {
log.error("任务执行失败", e);
throw e;
}
});

execute() 的异常会直接抛到线程的 UncaughtExceptionHandler 里(如果设置了的话)。

方案 C:装饰线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ExceptionAwareThreadPoolExecutor extends ThreadPoolExecutor {

public ExceptionAwareThreadPoolExecutor(int core, int max, long keepAlive, TimeUnit unit,
BlockingQueue<Runnable> queue) {
super(core, max, keepAlive, unit, queue);
}

@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?>) {
try {
((Future<?>) r).get();
} catch (CancellationException e) {
// 任务被取消,忽略
} catch (ExecutionException e) {
t = e.getCause();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (t != null) {
log.error("线程池任务异常", t);
}
}
}

afterExecuteThreadPoolExecutor 提供的钩子,能捕获任务执行后的异常——不管是用 submit 还是 execute

Spring @Async 的异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class AsyncConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) ->
log.error("异步方法 {} 执行异常,参数: {}", method.getName(), Arrays.toString(params), throwable);
}
}

方案五:日志记录的完善建议

全局捕获只是第一步,更关键的是把异常信息完整记录下来

一个合格的异常日志应该包含

1
2
3
4
5
6
7
8
[2026-06-01 10:23:45.678] [http-nio-8080-exec-3] ERROR c.y.p.GlobalWebExceptionHandler - 
请求: GET /api/user/123
参数: {}
用户: userId=456
异常: java.lang.NullPointerException: Cannot invoke "String.length()" because "name" is null
at com.yourproject.service.UserService.getUser(UserService.java:45)
at com.yourproject.controller.UserController.getUser(UserController.java:23)
...

能做到这个级别,线上排查效率会提升很多。

推荐实践

1
2
3
4
5
6
7
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
MDC.put("requestId", request.getAttribute("requestId").toString());
log.error("请求 [{} {}] 异常: ", request.getMethod(),
request.getRequestURI(), e);
return Result.error(500, "系统繁忙");
}

配合 MDC(Mapped Diagnostic Context)可以在日志里自动带上 traceId,把所有相关日志串起来。

完整的最佳实践方案

把上面这些串起来,一个相对完善的项目应该这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. 启动时注册 global UncaughtExceptionHandler
→ 兜住所有漏掉的线程级异常

2. @RestControllerAdvice 处理 Controller 层异常
→ 统一响应格式,返回 JSON 而不是错误页

3. Filter 层包装异常捕获
→ 兜住进入 Spring 之前的异常

4. 自定义 ErrorController 处理 404/405 等路径异常
→ 消灭白标页

5. 线程池重写 afterExecute
→ 异步任务异常不再静默吞掉

6. 统一日志格式 + MDC traceId
→ 异常可追溯,可复现

总结

全局异常捕获这个事,说起来不难,但真正做好需要覆盖各个层面:

  • JVM 层面:UncaughtExceptionHandler,兜住漏网之鱼
  • Web 层面:@ControllerAdvice + Filter + ErrorController,三层拦截
  • 异步层面:线程池 afterExecute + AsyncConfigurer 自定义处理器
  • 日志层面:统一格式 + traceId,让异常可追溯

把这些配好了,线上再出问题,你至少知道从哪查起。别让异常在暗处爆炸——给它一个明确的出口。

建议现在就去检查一下你的项目,看看有几个口子还漏着。


Java 全局异常捕获实战:别再让崩溃裸奔了
https://blog.280303.xyz/posts/java-global-exception-handling/
作者
lingyi
发布于
2026年6月1日
许可协议