面试官:说下对 Java 中异常的理解(详解 Java 异常机制)

文摘   2024-12-09 09:01   山东  

引言

Java 中的异常与异常处理机制也是面试中常见的考察点。面试官不仅关注求职者对 Java 异常体系结构的理解,如区分Exception 以及Error 的能力,更希望通过了解你对异常处理的方式,来评估你的编程功底和实际项目中的处理经验。

所以,我们今天来看一下,Java 中的异常体系结构以及面试经常被问到的相关知识。

Java 中异常体系的层次结构

Java 的异常体系基于Throwable 类构建,它有两个主要的子类:ErrorException。这两个类分别代表了不同类型的异常情况。请看图:

Java 异常类层次结构

Throwable 类中的常用 API

  • String getMessage():返回异常发生时的简要描述
  • Throwable getCause():返回此异常原因,即导致此异常发生的一个Throwable 对象。
  • String toString():返回异常发生时的详细信息,通常包括异常类的名称和详细消息。
  • void printStackTrace():使用标准错误流打印Throwable 对象封装的异常信息,包括异常的类型、详细消息和调用堆栈。

Exception 与 Error

ExceptionError 是 Java 中异常和错误的两个顶层父类。

Exception 和 Error 的主要区别

它们两个的主要区别,我们从以下几个方面进行分析:

  • 严重程度
    • Exception:表示程序可以捕获并能够处理的异常情况。这类异常通常是可预见的,可以通过合理的异常处理机制来应对。例如,文件找不到 (FileNotFoundException) 或者网络连接失败 (SocketException)。
    • Error:表示应用程序无法处理的故障,出现此类异常 JVM 一般都会停止执行,如OutOfMemoryErrorStackOverflowError。这些问题通常不在应用程序的控制范围内,因此不建议被应用程序捕获或处理。
  • 处理方式
    • Exception:我们应当对Exception 进行适当的处理,以确保应用程序能够继续正常运行。编码时可以通过try-catch 块来捕获异常,或者通过throws 关键字声明方法抛出异常,让调用方自行处理。
    • Error:一般情况下,在程序中不建议尝试捕获Error,因为Error 代表问题的严重级别很高,通常意味着应用程序已经处于不健康的状态。当系统出现Error 时,可能需要配合我们的监控系统,采取快速响应措施,减少对业务的影响。
  • 适用场景
    • Exception:适用于开发过程中可能出现的异常情况,特别是那些可以通过合理设计和编码避免的情况。比如请求输入错误、资源不可用、文件找不到等。
    • Error:适用于超出应用控制范围的严重问题,如硬件故障、JVM 内部错误等。这些问题是编码时难以预见且难以修复的。

什么是 Checked Exception 与 Unchecked Exception

Java 中的Exception 分为两大类:Checked Exception(检查异常) 和 Unchecked Exception(非检查异常)。

  • Checked Exception:Checked Exception 是指那些必须在编译时被显式处理的异常,如果不处理这类异常,IDE 中的编译器一般会给出错误提示。如果一个方法可能会抛出 Checked Exception,那么该方法要么通过throws 声明抛出异常,要么在其内部使用try-catch 捕获异常。
FileNotFoundException
  • Unchecked Exception:Unchecked Exception 是指那些不需要在编译时显式处理的异常。RuntimeException 及其子类都是 Unchecked Exception。这类通常由编码错误引起,如空指针异常 (NullPointerException) 或数组越界访问异常 (ArrayIndexOutOfBoundsException)。编译器虽然不要求显示处理这些异常,但优秀的编码应该尽量避免抛出此类异常。

阿里开发手册中这样要求:

强制】Java 类库中定义的一类 RuntimeException 可以通过预先检查进行规避,而不应该通过 catch 来处理,比如:IndexOutOfBoundsExceptionNullPointerException 等等。

说明:无法通过预检查的异常除外,如在解析一个外部传来的字符串形式数字时,通过 catch NumberFormatException 来实现。

正例if (obj != null) {...}

反例try { obj.method() } catch (NullPointerException e) {...}

常见的 Checked Exception 与 Unchecked Exception

这里整理下常见的这两类异常,面试时有可能会被问到开发中常见的有哪些。

常见的 Checked Exception

  • IOException 相关的:与输入输出操作相关的异常。例如,读写文件或网络连接失败时抛出。包括子类如FileNotFoundExceptionEOFException
  • SQLException:在执行数据库操作时可能发生的异常。
  • ClassNotFoundException:当应用程序尝试加载某个类而在类路径中找不到对应的类文件时抛出。
  • ……

常见的 Unchecked Exception

  • NullPointerException:空指针异常,当访问对象方法或者字段而对象为 null 时抛出。
  • ArrayIndexOutOfBoundsException:数组越界访问异常,访问数组中不存在的索引时抛出。
  • ClassCastException:类型转换异常,当将一个对象强制转换为不是其实际类型的类时抛出。
  • IllegalArgumentException:非法参数异常,当接收到非法或不适合的参数时抛出,可在编码中手动抛出该异常提示参数问题。
  • NumberFormatException:数值格式化异常,比如使用 Long.parseLong 解析一个字符串数值"3.14"时会抛出该异常。
  • ArithmeticException:算术异常,比如在执行 1/0 时抛出 by zero 除零算术异常。
  • ConcurrentModificationException:在迭代集合的同时修改它(没有通过迭代器自身的移除方法),或者检测到并发修改时抛出。
  • ……

NoClassDefFoundError 和 ClassNotFoundException 有什么区别

首先,这两者从名字上就可以看出本质的不同:ClassNotFoundExceptionException,而NoClassDefFoundErrorError

看下 JDK 源码中的解释(摘抄自 JDK 1.8):

/**
* Thrown when an application tries to load in a class through its
* string name using:
* <ul>
* <li>The <code>forName</code> method in class <code>Class</code>.
* <li>The <code>findSystemClass</code> method in class
*     <code>ClassLoader</code> .
* <li>The <code>loadClass</code> method in class <code>ClassLoader</code>.
* </ul>
* <p>
* but no definition for the class with the specified name could be found.
*/

public class ClassNotFoundException {}

当应用程序尝试使用以下字符串类名加载类,但未找到指定名称的类的定义时抛出该异常:

  • Class.forName() 方法。
  • ClassLoader.findSystemClass() 方法。
  • ClassLoader.loadClass() 方法。
/**
* Thrown if the Java Virtual Machine or a <code>ClassLoader</code> instance
* tries to load in the definition of a class (as part of a normal method call
* or as part of creating a new instance using the <code>new</code> expression)
* and no definition of the class could be found.
* <p>
* The searched-for class definition existed when the currently
* executing class was compiled, but the definition can no longer be
* found.
*/

public class NoClassDefFoundError {}

Java 虚拟机或类加载器在尝试加载某个类的定义时(例如作为方法调用的一部分或使用 new 关键字创建新实例时),无法找到该类的定义,则会抛出 NoClassDefFoundError

在当前执行的代码在编译时能找到并引用了所需的类定义,但在运行时,JVM 却找不到这个类的定义,则会抛出 NoClassDefFoundError

下面,我们结合场景示例加深一下这两种异常的理解:

  • ClassNotFoundException 通常是在类加载阶段,使用Class.forName() 等方式加载类时,找不到类的字节码文件(.class 文件)导致的。

场景示例:加载一个不存在的类时

public class ClassNotFoundDemo {
   public static void main(String[] args) {
       try {
           // 不存在的类 com.johnny.NonExistentClass
           Class.forName("com.johnny.NonExistentClass");
       } catch (ClassNotFoundException e) {
           e.printStackTrace();
       }
   }
}

运行结果:

java.lang.ClassNotFoundException: com.johnny.NonExistentClass
at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:359)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at java.lang.Class.forName0(Native Method)
at java.lang.Class.forName(Class.java:264)
at com.tender.ClassNotFoundDemo.main(ClassNotFoundDemo.java:7)
  • NoClassDefFoundError 是在编译时类是存在的,但在运行时 JVM 无法找到该类的定义。

场景示例:编译时存在,运行时不存在

class HelperClass {
}
public class MainClass {
   public static void main(String[] args) {
       HelperClass h = new HelperClass();
   }
}

运行结果:

Exception in thread "main" java.lang.NoClassDefFoundError: com/johnny/HelperClass
at com.johnny.MainClass.main(MainClass.java:7)
Caused by: java.lang.ClassNotFoundException: com.johnny.HelperClass
at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:359)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
... 1 more

MainClassHelperClass 编译后会生成两个class 文件MainClass.classHelperClass.class,现手动将编译后的HelperClass.class 文件删除,然后执行MainClassmain 方法,执行结果会抛出NoClassDefFoundError,因为 JVM 无法找到HelperClass 的定义,因为它对应的.class 文件已经被删除。

场景示例:类的静态初始化失败,后续继续引用该类时

class FaultyClass {
   static {
       // 模拟类静态初始化失败的情况
       System.out.println(1/0);
   }

   public static void printMessage() {
       System.out.println("这里输出一个消息。");
   }
}

public class NoClassDefFoundErrorExample {
   static {
       try {
           // 加载 FaultyClass
           Class.forName("com.johnny.FaultyClass");
       } catch (ClassNotFoundException e) {
           // 这里不会触发,因为类确实存在
           e.printStackTrace();
       } catch (ExceptionInInitializerError e) {
           // 类静态初始化失败,会抛出该异常
           System.out.println("静态初始化失败: " + e.getCause().getMessage());
       }
   }

   public static void main(String[] args) {
       try {
           // 这里再次使用 FaultyClass 去调用它的静态方法,此时会抛出 NoClassDefFoundError
           System.out.println("类静态初始化失败后,再次使用 FaultyClass 的静态方法...");
           FaultyClass.printMessage();
       } catch (NoClassDefFoundError e) {
           // 捕获 NoClassDefFoundError
           System.out.println("捕获到 NoClassDefFoundError: " + e.getMessage());
           e.printStackTrace();
       }
   }
}

运行结果:

静态初始化失败: / by zero
类静态初始化失败后,再次使用 FaultyClass 的静态方法...
捕获到 NoClassDefFoundError: Could not initialize class com.johnny.FaultyClass
java.lang.NoClassDefFoundError: Could not initialize class com.johnny.FaultyClass
 at com.johnny.NoClassDefFoundErrorExample.main(NoClassDefFoundErrorExample.java:32)
Caused by: java.lang.ExceptionInInitializerError: Exception java.lang.ArithmeticException: / by zero [in thread "main"]
 at com.johnny.FaultyClass.<clinit>(NoClassDefFoundErrorExample.java:6)
 at java.lang.Class.forName0(Native Method)
 at java.lang.Class.forName(Class.java:264)
 at com.johnny.NoClassDefFoundErrorExample.<clinit>(NoClassDefFoundErrorExample.java:18)

从这个示例中,我们可以看到,当我们尝试使用Class.forName() 去加载FaultyClass 时,由于在该类的静态代码块中出现了除零异常,所以会导致类静态初始化失败,从而抛出ExceptionInInitializerError 错误,我们catch 了这个异常却没有做任何的处理,因此在后续的逻辑中再次引用该类时,便会抛出NoClassDefFoundError

总结:

  • ClassNotFoundException 是在加载阶段发生的,当 JVM 无法从外存储器找到并加载指定的类时抛出。
  • NoClassDefFoundError 是在链接阶段发生的,当 JVM 在内存中无法找到已经加载过的类的定义时抛出。

补充一下: 根据《Java 虚拟机规范》,类加载分为三个主要阶段:加载(Loading)、链接(Linking)、初始化(Initialization)。其中链接又细分为验证(Verification)、准备(Preparation)、解析(Resolution)。

Java 中的异常处理机制

在 Java 中,异常处理机制为:抛出异常,捕获异常。

try-catch-finally 语句

try-catch-finally 语句用于捕获异常,是 Java 中处理异常的核心机制,由trycatchfinally 3 个语句块组合而成。

每个语句块的作用

  • try 块:是try-catch-finally 语句结构中的必选部分,用于包裹可能出现异常的代码,后边可接零或多个catch 块,如果没有catch 块,则必须跟一个finally 块。
  • catch 块:捕获并处理由try 块中抛出的特定类型的异常,可以有多个catch 块来处理不同类型的异常,多个catch 块时,按异常类型逐一匹配,找到与之对应的catch 块进行处理,如果没有匹配的catch 块,则异常将传播到调用栈上的更高层。
  • finally 块:是可选的,无论是否发生异常都会执行的代码块,通常用于释放资源(如关闭文件流或数据库连接)。

语法示例

try {
 // 包裹可能会发生异常的程序代码
} catch (ExceptionType1 e1){
 // 捕获并处理 try 块中抛出的异常 e1
} catch (ExceptionType2 e2){
 // 捕获并处理 try 块中抛出的异常 e2
} finally {
 // 无论是否发生异常都会执行的语句块
}

finally 块中的代码一定会执行吗?

理论上,finally 块中的代码是无论是否发生异常都会执行的代码块,但是在一些特殊情况下,finally 块中的代码不会被执行。这些情况包括:

  • JVM 强制终止:比如 JVM 内存不足或其他严重的系统问题导致 JVM 非正常退出。
  • 线程死亡:如果应用程序所在的线程死亡或被中断,finally 块的代码也不会执行。
  • System.exit() 调用:代码中如果在finally 块前显示调用了System.exit()finally 块也不会执行。

不要在 finally 块中使用 return

阿里开发手册中要求:

【强制】不能在 finally 块中使用 returnfinally 块中的 return 返回后方法结束执行,不会再执行 try 块中的 return 语句。

因为在trycatch 块中有return 语句时,这个返回值会被暂时保存在本地变量中,当执行到finally 块中的return 时,它会覆盖之前本地变量中保存的返回值,并在方法执行结束时将这个返回值返回。

代码示例:

public class ReturnInFinallyExample {
   public static int methodWithReturnInFinally() {
       try {
           return 1; // 这个返回值会被记住,但不会立即返回
       } catch (Exception e) {
           return 2; // 如果异常发生,这个返回值也会被记住
       } finally {
           System.out.println("Finally block executed");
           return 3; // 最终这个返回值会覆盖之前的返回值
       }
   }

   public static void main(String[] args) {
       System.out.println("Returned value: " + methodWithReturnInFinally()); // 输出:Returned value: 3
   }
}

运行结果:

Finally block executed
Returned value: 3

throw 与 throws 关键字

throwthrows 关键字在 Java 中用来抛出异常。

  • throw:用来显式地抛出一个异常对象。当程序检测到错误条件时,可以通过throw 来创建并抛出一个异常实例,从而中断当前方法或语句块的执行,并将控制权传递给调用栈上的更高层来处理该异常。

语法:

throw new ExceptionType("Exception Message");

ExceptionType 是需要抛出的异常类型(如IllegalArgumentExceptionRuntimeException 等)。"Exception Message" 是可选的消息字符串,描述异常的具体原因。

代码示例:

public class ThrowExample {
   public static void validateAge(int age) {
       if (age < 0) {
           throw new IllegalArgumentException("Age cannot be negative.");
       }
       System.out.println("Valid age: " + age);
   }

   public static void main(String[] args) {
       try {
           validateAge(-5); // 将抛出异常
       } catch (IllegalArgumentException e) {
           System.err.println("Caught exception: " + e.getMessage());
       }
   }
}

在这个例子中,如果传入的年龄为负数,则会抛出IllegalArgumentException 异常。

  • throws:用于声明一个方法可能抛出的异常列表。它用来告诉编译器和调用者,这个方法内部可能会抛出某些类型的异常,调用者需要准备好处理这些异常。throws 只是声明,而不是实际抛出异常,实际抛出由throw 实现。

Checked Exception(检查异常)必须在方法声明使用 throws 进行声明,或者在其方法体内捕获;而 Unchecked Exception(非检查异常)不需要。

语法:

public returnType methodName(parameters) throws ExceptionType1, ExceptionType2 {
   // 方法体
}

ExceptionType1ExceptionType2 是声明的方法可能抛出的异常类型。

代码示例:

public class ThrowsExample {
   // 声明该方法可能会抛出 IOException
   public void readFile(String filePath) throws IOException {
       // 模拟文件读取操作,抛出 IOException
       if (!filePath.endsWith(".txt")) {
           throw new IOException("File must be a text file.");
       }
       System.out.println("Reading file: " + filePath);
   }

   public static void main(String[] args) {
       ThrowsExample example = new ThrowsExample();
       try {
           example.readFile("data.csv"); // 可能抛出异常
       } catch (IOException e) {
           System.err.println("Caught exception: " + e.getMessage());
       }
   }
}

在这个例子中,readFile 方法声明了它可能会抛出IOException。在main 方法中调用readFile 时,必须处理这个潜在的异常,否则代码无法通过编译。

throw vs. throws

关键字
throw
throws
目的
显式抛出一个异常实例
声明一个方法可能抛出的异常类型
使用位置
在方法体内
在方法声明上
影响
直接导致方法或语句块的执行被中断
不影响方法体内的逻辑,仅作为接口契约的一部分

try-with-resources 语法

try-with-resources 语法是 Java 7 引入的一项重要特性,为了简化资源管理并确保资源的自动关闭。适用于那些实现了java.lang.AutoCloseable 接口的资源(如文件流、数据库连接等),从而避免了手动关闭这些资源的繁琐操作,并可以避免因忘记关闭资源而导致的内存泄漏的问题。它是一种语法糖,对使用try-with-resources 包裹的代码进行反编译后,可以看到仍然是try-catch-finally 结构。

try-with-resources 的基本用法

try (ResourceType resource = new ResourceType()) {
   // 使用资源
} catch (ExceptionType e) {
   // 处理异常
}

ResourceType 需实现AutoCloseable 接口,资源声明和初始化直接放在try 括号内,当try 块执行完毕后,无论是否发生异常,所有声明的资源都将被自动关闭。

为什么需要 try-with-resources

在 Java 7 之前,我们使用像InputStreamOutputStream 这样的资源时,通常需要遵循以下步骤:

  • 打开资源。
  • 使用资源。
  • finally 块中关闭资源。

比如:

public static void main(String[] args) {
   BufferedReader br = null;
   try {
       br = new BufferedReader(new FileReader("data.txt"));
       String line;
       while ((line = br.readLine()) != null) {
           System.out.println(line);
       }
   } catch (IOException e) {
       // 处理异常
   } finally {
       if (br != null) {
           try {
               br.close();
           } catch (IOException ex) {
               // 处理 close() 方法抛出的异常
           }
       }
   }
}

这中编码不仅冗长,而且容易出错。如果我们在开启资源后立即抛出异常的话,则可能会跳过finally 块中的资源关闭逻辑;另外,我们还需要处理close() 方法本身可能抛出的异常。

try-with-resources 通过引入更简洁的语法来解决这些问题,确保每个资源都会被正确关闭,即使发生异常也不会遗漏。使用try-with-resources 后,代码简化如下:

public static void main(String[] args) {
       try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
           String line;
           while ((line = br.readLine()) != null) {
               System.out.println(line);
           }
       } catch (IOException e) {
           System.err.println("Caught exception: " + e.getMessage());
       }
   }

同时,try-with-resources 语句可以同时管理多个资源,只需将它们放在try 括号内并用分号隔开即可,每个资源都会按照声明的逆序依次关闭

什么是逆序关闭,比如程序中先开启了ResourceA ,又开启了ResourceB,那么执行关闭的时候,先关闭ResourceB,再关闭ResourceA,这就像我们回家开门进屋一样,从大门依次开门进屋,出去的时候要先从里边开始向外锁门。

为什么逆序关闭?

因为开启的资源间可能会有依赖关系,有依赖关系时,通常是先关闭依赖于其他资源的资源。

比如操作数据库时:打开数据库连接 (Connection)-->创建语句对象 (Statement)-->执行查询并获取结果集 (ResultSet)。

在这种情况下,ResultSet 依赖于 Statement,而 Statement 又依赖于 Connection。如果按照打开资源的顺序关闭这些资源时,比如先关闭了 Connection,那么当我们再去关闭 Statement 时,可能已经失去了对 Statement 的访问权限,这很可能导致资源泄露。

try-with-resources 的优势

  • 自动资源管理:不再需要显式地调用close() 方法,简化了代码结构。
  • 减少错误风险:即使发生异常,资源也会被正确关闭,避免了资源泄露的风险。
  • 提高可读性:代码更加紧凑且易于理解,减少了不必要的样板代码。

结语

好了,写到这里,关于 Java 中异常体系相关的知识基本就介绍完了,希望通过这篇文章能帮助大家更好地理解和运用 Java 中的ExceptionError。如果你有任何进一步的问题或需要更详细的解释,欢迎评论区留言!

您的鼓励对我持续创作非常关键,如果本文对您有帮助,请记得点赞、分享、在看哦~~~谢谢!


Java驿站
这里是【Java驿站】,一个Java编程学习与交流平台。
 最新文章