异常捕获基础:Java异常体系,try-catch-finally,常见异常类型

做 Android 开发这么多年,我处理过最头疼的崩溃其实不是那些复杂的并发问题,而是一个最简单的 NullPointerException。说实话,很多新手对异常的理解就停在“try一下、catch一下”的层面,但等到线上出问题时,才发现连日志都打不全,定位半天也找不到根因。

这一节我们就把异常捕获的底子打牢。你想想看,Java 异常体系其实就是一个树形结构,掌握它的分类,你就知道哪些异常能提前预防,哪些必须用 try-catch 兜底。

Java 异常体系结构 Throwable Error(不可处理) Exception(可处理) OutOfMemoryError StackOverflowError NoClassDefFoundError Checked Exception Unchecked (Runtime) IOException SQLException NullPointerException IndexOutOfBounds ⚠ 重要:Unchecked 异常(RuntimeException)不需要显式 try-catch,但最常引发运行期崩溃 Checked 异常必须声明或捕获,编译期强制检查

1. 异常体系:一张图看懂

Java 把异常分成了两大类,根节点是 Throwable,下面两个分支 Error 和 Exception。说白了,Error 是虚拟机级别的严重问题,比如内存溢出、栈溢出,你连 catch 都最好别去碰——因为碰了也不一定能恢复。我在早期有个项目,线上 OOM 了还在 try-catch 里使劲兜,结果兜完又炸,白费力气。

真正我们要关心的是 Exception,它又分成 Checked Exception(受检异常) 和 Unchecked Exception(非受检异常),后者也叫 RuntimeException。

  • Checked Exception:比如 IOException,编译器会逼你写 try-catch 或者 throws。我记得有一次写文件读写,忘了处理 FileNotFoundException,编译直接报错,同事还笑我“编译器救了你一次”。
  • Unchecked Exception:比如 NullPointerException、IndexOutOfBoundsException,编译器不强制处理,但运行时一旦发生,App 立马闪退。这恰恰是 Android 线上崩溃的大头。

核心观点: Android 崩溃治理的重心,其实是 Unchecked Exception。因为 Checked Exception 编译期就能发现,而运行时异常往往藏在代码的犄角旮旯里,一不留神就炸。

2. try-catch-finally:究竟怎么用?

语法很简单,但实践中有不少坑。我先给你一个最标准的骨架:

try {
    // 可能抛出异常的代码
    riskyOperation();
} catch (SpecificException e) {
    // 处理指定异常
    Log.e("TAG", "发生特定异常", e);
} catch (Exception e) {
    // 兜底处理
    Log.e("TAG", "未知异常", e);
} finally {
    // 无论是否异常,都会执行
    closeResources();
}

这里有几个要点我必须强调:

  • catch 顺序很重要:子类异常放前面,父类异常放后面。比如先 catch FileNotFoundException,再 catch IOException。不然父类一接,子类就永远没机会了。
  • 千万不要 catch 了异常什么都不做!我见过不少代码,catch 块里只写了个空语句,或者打了一句没有上下文的日志。你想,线上出问题了你连堆栈都看不到,怎么修?
  • finally 块适合释放资源:关闭文件、数据库连接、网络 socket。但要注意,如果在 finally 里也抛异常,原来的异常会被覆盖。这个坑我栽过。

⚠ 避坑指南:我曾经在 finally 里调用了 close(),恰好那个方法也抛异常,结果 try 里的真正异常被吞掉了,花了整整两天才定位到原因。现在的推荐做法是用 try-with-resources(Java 7+),或者自己在 finally 里单独 try-catch 关闭操作。

3. 常见异常类型一览

Android 开发中最常碰到的异常,我按出现频率排了个表,你可以对照着检查自己的代码:

异常类型 所属分类 典型场景 严重程度
NullPointerException RuntimeException 访问未初始化的对象,比如 findViewById 返回 null 后再调用方法 ⭐⭐⭐⭐⭐
IndexOutOfBoundsException RuntimeException 集合或数组越界,比如用 for 循环 i 超过列表 size ⭐⭐⭐⭐
ClassCastException RuntimeException 类型转换错误,比如把一个 String 强转为 Integer ⭐⭐⭐
IllegalArgumentException RuntimeException 传给方法的参数不合法,比如负数当索引 ⭐⭐⭐
IOException Checked(或非 IO 子类) 文件不存在、网络读写失败、流关闭异常 ⭐⭐⭐
OutOfMemoryError Error 加载大图或大量对象导致堆内存爆满 ⭐⭐⭐⭐⭐

嗯,这里要注意一下:OutOfMemoryError 是 Error,不是 Exception。你 try-catch 它意义不大,因为即使 catch 住,堆内存仍然不足,后续代码大概率也跑不动。更有效的做法是预防——比如用 BitmapFactory.Options.inSampleSize 压缩图片。

4. 实战:一个典型的异常捕获案例

假设我们要从网络下载一张图片并显示到 ImageView 上。网络操作会抛 IOException,图片解析可能抛 OutOfMemoryError(注意不是 Exception),还有各种空指针风险。来看一段我实际项目中会写的代码:

public void loadImage(String url, ImageView imageView) {
    try {
        // 1. 先检查参数
        if (url == null || imageView == null) {
            throw new IllegalArgumentException("参数不能为null");
        }

        // 2. 网络请求(可能抛 IOException)
        InputStream is = new URL(url).openStream();

        // 3. 解析 Bitmap(可能抛 OutOfMemoryError)
        Bitmap bitmap = BitmapFactory.decodeStream(is);
        if (bitmap == null) {
            throw new IOException("图片解码失败");
        }

        // 4. 显示
        imageView.setImageBitmap(bitmap);
    } catch (IllegalArgumentException e) {
        Log.e("ImageLoad", "参数错误", e);
        showDefaultImage(imageView);
    } catch (IOException e) {
        Log.e("ImageLoad", "网络或IO异常", e);
        showErrorImage(imageView);
    } catch (OutOfMemoryError e) {
        Log.e("ImageLoad", "内存溢出", e);
        // 尝试释放缓存,但这里只是记录
        System.gc();
        showErrorImage(imageView);
    } finally {
        // 关闭流(单独 try-catch 防止覆盖异常)
        try {
            if (is != null) is.close();
        } catch (IOException ignored) {}
    }
}

💡 小技巧: 要捕获 OutOfMemoryError 也是可以的,但别指望 catch 后能继续正常跑,最多用来做一个降级显示。我个人习惯是不 catch Error,而是通过 onTrimMemory 提前做内存清理解决。

5. 多异常捕获的演进

Java 7 之后支持一次 catch 多个异常,用竖线隔开:

try {
    riskyOperation();
} catch (IOException | IllegalArgumentException e) {
    Log.e("TAG", "网络或参数异常", e);
}

这样做的好处是代码更简洁,也避免了重复的日志逻辑。但要注意,这里的 e 默认是 final 的,你不能修改它。

我个人主张在 Android 开发中,尽可能细化异常处理。不要一个 catch (Exception e) 包打天下,因为你不知道到底发生了什么错。细化以后,日志里的信息量就大了很多,线上排查时能省下不少时间。

最后总结一句话: 异常捕获不是“把代码包起来就好了”,而是要通过合理的分类和处理策略,让程序在出错时能优雅降级,同时保留足够的现场信息。下一轮我们就要开始讲如何利用平台工具和第三方框架来统一收集这些异常——但这是后面的话题,先把基础打牢。