#Java #代码规范 #架构设计 #避坑指南
上周 Code Review,一个实习生把整个 Service 层的异常全 catch 了,catch 块里只有一行
e.printStackTrace()。我问他为什么,他说:"老师说异常要处理,我处理了啊。" 那一刻我理解了为什么线上出了 bug 永远查不到根因。
先说结论
异常处理就两个选择,选错了代价完全不同:
java
异常发生了
│
├─ try-catch → 自己消化,调用者感知不到
│ 选错了 → 静默吞异常,线上埋雷
│
└─ throws → 抛给调用者,让上级决定
选错了 → 框架层崩溃,用户看到500
大部分人的纠结本质上是一个问题:这个异常,到底谁来处理更合适?
一、先搞清楚 Java 异常的分类
很多人写了三年 Java,异常体系都没理清:
php
Throwable
/ \
Error Exception
(不用管) / \
受检异常 RuntimeException
(编译器逼你处理) (非受检异常)
IOException NullPointerException
SQLException IndexOutOfBoundsException
FileNotFoundException
关键区分:
| 类型 | 编译器态度 | 你必须做的事 |
|---|---|---|
| 受检异常 | 编译不通过 | catch 或 throws,二选一 |
| 非受检异常 | 编译不管 | 想处理就处理,不想处理就不管 |
| Error | 编译不管 | 不要处理,处理了也没用 |
受检异常是 Java 设计上最有争议的一个决策。有观点认为,如果重来,设计者可能不会引入受检异常。
二、什么时候用 throws
原则:调用者比你更清楚该怎么处理
场景 1:异常是业务流程的一部分
java
// 错误写法:自己 catch 了,调用者完全不知道连接失败
public static Connection getConnection() {
try {
return ds.getConnection();
} catch (SQLException e) {
e.printStackTrace();
return null; // 调用者拿到 null,后面全是 NPE
}
}
// 正确写法:抛出去,让调用者决定是重试还是终止
public static Connection getConnection() throws SQLException {
return ds.getConnection();
}
为什么 getConnection() 该 throws?因为调用者必须知道"连接是否成功" ,才能决定下一步是重试、降级还是直接报错。你把异常吞了,调用者拿到一个 null,后面每一步都是 NPE,真正的异常源头被你埋掉了。
场景 2:你是工具方法,不该替别人做决定
java
// 工具类方法:不知道调用者想怎么处理,所以抛出去
public static User findById(int id) throws SQLException {
String sql = "select * from user where id = ?";
// ...
}
// 调用者 A:找不到直接抛异常 → 合理
// 调用者 B:找不到返回默认值 → 也合理
// 你作为工具方法,不应该替调用者做选择
场景 3:异常需要逐层上报,最顶层统一处理
java
// Dao 层 → throws
public User queryById(int id) throws SQLException {
// 只管查,出了问题往上抛
}
// Service 层 → throws
public User getUser(int id) throws SQLException {
return userDao.queryById(id); // 继续往上抛
}
// Controller 层 → 统一兜底
public Response handleRequest(int id) {
try {
User user = userService.getUser(id);
return Response.success(user);
} catch (SQLException e) {
log.error("查询用户失败, id={}", id, e); // 这里统一记录日志
return Response.error("系统繁忙");
}
}
每一层都不处理,一直抛到最顶层统一 catch ------ 这是 Spring、MyBatis 等框架的标准做法。Service 层一旦自己 catch 了,上层就永远感知不到问题。
三、什么时候用 try-catch
原则:你比调用者更清楚该怎么处理,或者调用者根本处理不了
场景 1:你有明确的 fallback 策略
java
// 数据库读不了?从缓存读,调用者不需要知道底层出了什么问题
public String getConfig(String key) {
try {
return readFromDatabase(key);
} catch (SQLException e) {
log.warn("数据库读取失败,降级到缓存, key={}", key);
return readFromCache(key); // 有备用方案
}
}
这里的 try-catch 是有价值的:你 catch 了,但你不是吞掉,而是走了备用方案。调用者只关心"能不能拿到配置",不关心底层走了数据库还是缓存。
场景 2:异常发生在这里,也只有这里能处理
java
// 关闭资源的操作
public static void close(ResultSet rs, Statement stmt, Connection conn) {
try {
if (rs != null) rs.close();
if (stmt != null) stmt.close();
if (conn != null) conn.close();
} catch (SQLException e) {
throw new RuntimeException("关闭资源失败", e);
// 关闭失败是严重问题,但调用者也处理不了
// 所以转成 RuntimeException 强制终止
}
}
关不掉连接你让调用者怎么办?调用者能做的也就是重新关一次。这种情况,catch 之后转
RuntimeException是合理选择。
场景 3:语法上必须处理
java
// FileInputStream 构造器声明了 throws FileNotFoundException
// 不 catch 就编译不过,没有选择余地
public static Properties loadConfig() {
Properties props = new Properties();
try {
props.load(new FileInputStream("config.properties"));
} catch (FileNotFoundException e) {
// 文件不存在,用默认配置(返回空 Properties,业务层按默认值处理)
log.warn("配置文件不存在,使用默认配置");
} catch (IOException e) {
// 读取失败,这个必须终止
throw new RuntimeException("读取配置文件失败", e);
}
return props;
}
注意这里有个细节:同一个方法里,不同的异常可以有不同的处理策略 。FileNotFoundException 有备用方案所以 catch 吞掉,IOException 是意外所以转 RuntimeException。
场景 4:静态代码块(语法限制)
java
// 静态代码块不能 throws,语法不允许
static {
try {
ds = DruidDataSourceFactory.createDataSource(properties);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
// 用 ExceptionInInitializerError 包装,比吞掉强
}
}
静态代码块里
throw只能抛RuntimeException或Error,不能抛受检异常。ExceptionInInitializerError是Error的子类,语法上合法。
四、throws 和 try-catch 能交叉使用吗?
能,但要分情况。四种组合只有一种没意义:
| 组合 | 意义 | 说明 |
|---|---|---|
Utils throws → 调用者 try-catch |
✅ 最常见,异常传递畅通 | 工具方法抛出,调用者按需处理 |
Utils throws → 调用者 throws |
✅ 异常链,继续往上抛 | 层层上抛,最顶层统一兜底 |
Utils try-catch → 调用者 try-catch |
✅ 各管各的异常 | Utils 处理自己的异常,调用者处理可能出现的 null 等后续问题 |
Utils try-catch → 调用者 throws |
⚠️ 能编译,但没意义 | Utils 已吞掉异常,调用者的 throws 等不到任何异常,形同虚设 |
核心规律 :异常只能从 throws 端传递到 try-catch 端,不能反向传递。Utils 吞了异常,调用者就接不到了。因此工具方法通常用 throws,让调用者决定怎么处理。
五、决策流程图
下次写代码之前,对着这张图走一遍:
php
异常发生了
│
├─ 语法上必须处理吗?(受检异常,编译器报红)
│ ├─ 是 ↓
│ │ ├─ 你有备用方案? → try-catch + fallback
│ │ ├─ 调用者能处理? → throws
│ │ └─ 都不能? → catch + RuntimeException
│ └─ 否(非受检异常)↓
│ ├─ 是编程错误(NPE/越界)? → 不要 catch,修复代码
│ └─ 是业务异常? → 抛出自定义 RuntimeException(如 BusinessException)
六、Code Review 高频踩坑点
踩坑 1:catch 吞异常
java
// 这是 Code Review 里我打回最多的写法
try {
doSomething();
} catch (Exception e) {
// 什么都不做
}
// 后果:线上出了问题,日志里干干净净,排查无从下手
java
// 至少要打日志
try {
doSomething();
} catch (Exception e) {
log.error("操作失败", e); // 打日志,保留现场
throw new RuntimeException(e);
}
踩坑 2:catch 了又原样抛出
java
// 没有任何意义
try {
doSomething();
} catch (SQLException e) {
throw e; // catch 了又原样抛出,不如直接 throws
}
// 要么处理,要么转换
try {
doSomething();
} catch (SQLException e) {
throw new BusinessException("操作失败", e); // 包装成业务异常
}
要么处理,要么转换。转换的意义不仅在于包装错误信息,更在于防止抽象泄露 。不要让底层的 SQLException 污染到全都是业务逻辑的 Service 层,用自定义的 RuntimeException 斩断这种技术栈耦合。在 Spring 项目中,转换后的自定义异常最终由 @ControllerAdvice 统一处理,形成完整的异常处理闭环。
踩坑 3:catch Exception 吃掉所有异常
java
// NPE、越界、业务异常全部被吞
try {
doSomething();
} catch (Exception e) {
log.error("出错了", e);
// 然后继续往下执行,仿佛什么都没发生
}
// 只 catch 你预期的异常
try {
doSomething();
} catch (SQLException e) {
// 只处理数据库异常
} catch (FileNotFoundException e) {
// 只处理文件不存在
}
// 其他异常正常往上抛,不要 catch Exception
踩坑 4:finally 里抛异常
java
// finally 里的异常会覆盖 try 块里的异常
Connection conn = null;
try {
conn = dataSource.getConnection();
// 业务逻辑...
} finally {
if (conn != null) {
conn.close(); // 如果这里抛异常,try 块里的异常就丢了
}
}
// 用 try-with-resources(JDK 7+)
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
// 业务逻辑...
// 自动关闭,不会吞异常
}
既然提到了
try-with-resources和底层资源的close()释放,这里面其实藏着极其容易翻车的致命陷阱如果你对 Java 基础的底层资源边界还不够清晰,强烈建议顺手补课本系列的高赞排雷文: 👉 《以为用了 try-with-resources 就稳了?这三个底层漏洞让TCP双向通讯直接卡死》 👉 《线上日志被清空?这段仅10行的 IO 代码里竟然藏着3个毒瘤》
踩坑 5:Utils 吞异常,调用者 throws 空等
java
// Utils 吞了异常
public static Connection getConnection() {
try {
return ds.getConnection();
} catch (SQLException e) {
e.printStackTrace();
return null;
}
}
// 调用者 throws,但等不到异常
public void doSomething() throws SQLException {
Connection conn = JDBCUtilsByDruid.getConnection();
// throws 声明形同虚设,异常到不了这里
}
// Utils throws,调用者 try-catch
public static Connection getConnection() throws SQLException {
return ds.getConnection();
}
public void doSomething() {
try {
Connection conn = JDBCUtilsByDruid.getConnection();
} catch (SQLException e) {
// 异常正常传递到这里
}
}
七、生产项目中的分层异常策略
java
┌─────────────────────────────────────────────────┐
│ Controller 层 │
│ try-catch → 统一兜底,返回友好响应 │
│ "系统繁忙,请稍后再试" + 记录完整日志 │
├─────────────────────────────────────────────────┤
│ Service 层 │
│ throws → 业务异常往上抛 │
│ try-catch → 只处理有 fallback 的情况 │
├─────────────────────────────────────────────────┤
│ Dao 层 │
│ throws → 数据库异常往上抛 │
│ 这一层不该处理异常,只负责操作数据库 │
├─────────────────────────────────────────────────┤
│ Utils 工具层 │
│ throws → 工具方法不替调用者做决定 │
└─────────────────────────────────────────────────┘
Spring Boot 项目里通常有一个全局异常处理器
@ControllerAdvice,所有未被 catch 的异常都会汇聚到这里,统一处理、统一格式化响应。这就是 throws 策略的终极形态。
八、面试防御
面试官问:"try-catch 和 throws 怎么选?"
高分回答:
核心判断标准是谁更有能力处理这个异常。
如果当前方法有明确的 fallback 策略(比如降级到缓存),就 try-catch。如果当前方法只是工具方法,不知道调用者想怎么处理,就 throws,把决定权交给调用者。
另外要注意异常传递的方向:异常只能从 throws 端传递到 try-catch 端。如果 Utils 层已经 catch 吞掉了异常,调用者写的 throws 声明就形同虚设。所以工具方法通常用 throws,让异常传递链保持畅通。
特别要注意的是,catch 之后不能吞异常 ,至少要打日志。我见过太多线上问题因为异常被吞掉,导致排查时完全没有线索。生产项目通常会在最顶层做统一异常处理,比如 Spring 的
@ControllerAdvice,这样 Service 和 Dao 层就可以放心 throws,保持代码干净。
你在 Code Review 中见过哪些"诡异"的异常处理?欢迎在评论区分享,一起避坑。