首页 > 技术文章 > Java学习_异常处理

fjnuczq 2020-12-26 17:37 原文

  • Java的异常
  1. 计算机程序运行的过程中,总是会出现各种各样的错误。有一些错误是用户造成的,比如,希望用户输入一个int类型的年龄,但是用户的输入是abc。程序想要读写某个文件的内容,但是用户已经把它删除了。还有一些错误是随机出现,并且永远不可能避免的。比如:

    • 网络突然断了,连接不到远程服务器;
    • 内存耗尽,程序崩溃了;
    • 用户点“打印”,但根本没有打印机;
    • ……
  2. Java内置了一套异常处理机制,总是使用异常来表示错误。异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了。

     1 try {
     2     String s = processFile(“C:\\test.txt”);
     3     // ok:
     4 } catch (FileNotFoundException e) {
     5     // file not found:
     6 } catch (SecurityException e) {
     7     // no read permission:
     8 } catch (IOException e) {
     9     // io error:
    10 } catch (Exception e) {
    11     // other error:
    12 }
  3. Java的异常是class,它的继承关系如下:
  4. 从继承关系可知:Throwable是异常体系的根,它继承自ObjectThrowable有两个体系:ErrorExceptionError表示严重的错误,程序对此一般无能为力。
  5. Exception则是运行时的错误,它可以被捕获并处理。
  6. Exception又分为两大类:

    1. RuntimeException以及它的子类;
    2. RuntimeException(包括IOExceptionReflectiveOperationException等等)
  7. Java规定:
    • 必须捕获的异常,包括Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。

    • 不需要捕获的异常,包括Error及其子类,RuntimeException及其子类。

  8. 编译器对RuntimeException及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理RuntimeException。是否需要捕获,具体问题具体分析。
  9. 捕获异常

    • 捕获异常使用try...catch语句,把可能发生异常的代码放到try {...}中,然后使用catch捕获对应的Exception及其子类。

       1 import java.io.UnsupportedEncodingException;
       2 import java.util.Arrays;
       3 
       4 public class Main {
       5     public static void main(String[] args) {
       6         byte[] bs = toGBK("中文");
       7         System.out.println(Arrays.toString(bs));
       8     }
       9 
      10     static byte[] toGBK(String s) {
      11         try {
      12             // 用指定编码转换String为byte[]:
      13             return s.getBytes("GBK");
      14         } catch (UnsupportedEncodingException e) {
      15             // 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
      16             System.out.println(e); // 打印异常信息
      17             return s.getBytes(); // 尝试使用用默认编码
      18         }
      19     }
      20 }
    • 如果我们不捕获UnsupportedEncodingException(去掉try catch),会出现编译失败的问题。编译器会报错,错误信息类似:unreported exception UnsupportedEncodingException; must be caught or declared to be thrown,并且准确地指出需要捕获的语句是return s.getBytes("GBK");。意思是说,像UnsupportedEncodingException这样的Checked Exception,必须被捕获。
    • 这是因为String.getBytes(String)方法定义是(如下),在方法定义的时候,使用throws Xxx表示该方法可能抛出的异常类型(Runtime及其子类除外)。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
      public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
          ...
      }
    • toGBK()方法中,因为调用了String.getBytes(String)方法,就必须捕获UnsupportedEncodingException。我们也可以不捕获它,而是在方法定义处用throws表示toGBK()方法可能会抛出UnsupportedEncodingException,就可以让toGBK()方法通过编译器检查。
       1 import java.io.UnsupportedEncodingException;
       2 import java.util.Arrays;
       3 
       4 public class Main {
       5     public static void main(String[] args) {
       6         byte[] bs = toGBK("中文");
       7         System.out.println(Arrays.toString(bs));
       8     }
       9 
      10     static byte[] toGBK(String s) throws UnsupportedEncodingException {
      11         return s.getBytes("GBK");
      12     }
      13 }
    • 只要是方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。所有未捕获的异常,最终也必须在main()方法中捕获,不会出现漏写try的情况。这是由编译器保证的。main()方法也是最后捕获Exception的机会。
    • 如果是测试代码,上面的写法就略显麻烦。如果不想写任何try代码,可以直接把main()方法定义为throws Exception。因为main()方法声明了可能抛出Exception,也就声明了可能抛出所有的Exception,因此在内部就无需捕获了。代价就是一旦发生异常,程序会立刻退出。
    • toGBK()内部“消化”异常
      static byte[] toGBK(String s) {
          try {
              return s.getBytes("GBK");
          } catch (UnsupportedEncodingException e) {
              // 什么也不干
          }
          return null;
    • 这种捕获后不处理的方式是非常不好的,即使真的什么也做不了,也要先把异常记录下来:
      static byte[] toGBK(String s) {
          try {
              return s.getBytes("GBK");
          } catch (UnsupportedEncodingException e) {
              // 先记下来再说:
              e.printStackTrace();
          }
          return null;
    • 所有异常都可以调用printStackTrace()方法打印异常栈,这是一个简单有用的快速打印异常的方法。
  • 捕获异常
    1. 凡是可能抛出异常的语句,都可以用try ... catch捕获。把可能发生异常的语句放在try { ... }中,然后使用catch捕获对应的Exception及其子类。  
    2. 可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。

      简单地说就是:多个catch语句只有一个能被执行。

    3. 存在多个catch的时候,catch的顺序非常重要:子类必须写在前面。
    4. 无论是否有异常发生,都希望执行一些语句,例如清理工作,可以把执行语句写若干遍:正常执行的放到try中,每个catch再写一遍。

       1 public static void main(String[] args) {
       2     try {
       3         process1();
       4         process2();
       5         process3();
       6         System.out.println("END");
       7     } catch (UnsupportedEncodingException e) {
       8         System.out.println("Bad encoding");
       9         System.out.println("END");
      10     } catch (IOException e) {
      11         System.out.println("IO error");
      12         System.out.println("END");
      13     }
      14 }
    5. Java的try ... catch机制还提供了finally语句,finally语句块保证有无错误都会执行。上述代码可以改写如下。
      public static void main(String[] args) {
          try {
              process1();
              process2();
              process3();
          } catch (UnsupportedEncodingException e) {
              System.out.println("Bad encoding");
          } catch (IOException e) {
              System.out.println("IO error");
          } finally {
              System.out.println("END");
          }
      }
    6. finally有几个特点:

      1. finally语句不是必须的,可写可不写;
      2. finally总是最后执行。
    7. 某些情况下,可以没有catch,只使用try ... finally结构。
      void process(String file) throws IOException {
          try {
              ...
          } finally {
              System.out.println("END");
          }
      }

      因为方法声明了可能抛出的异常,所以可以不写catch

  • 抛出异常
    1. 查看Integer.java源码可知,抛出异常的方法代码如下。
      public static int parseInt(String s, int radix) throws NumberFormatException {
          if (s == null) {
              throw new NumberFormatException("null");
          }
          ...
      }
    2. 查看Integer.java源码可知,抛出异常的方法代码如下当发生错误时,例如,用户输入了非法的字符,我们就可以抛出异常。如何抛出异常?参考Integer.parseInt()方法,抛出异常分两步:

      1. 创建某个Exception的实例;
      2. throw语句抛出。
        void process2(String s) {
            if (s==null) {
                NullPointerException e = new NullPointerException();
                throw e;
            }
        }
        
        或:
        
        void process2(String s) {
            if (s==null) {
                throw new NullPointerException();
            }
        }
      3. 在代码中获取原始异常可以使用Throwable.getCause()方法。如果返回null,说明已经是“根异常”了。
      4. 捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场!
    3. try或者catch语句块中抛出异常,不会影响finally的执行。JVM会先执行finally,然后抛出异常。
    4. 如果在执行finally语句时抛出异常,那么,finally抛出异常后,原来在catch中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常(Suppressed Exception)。
      public class Main {
          public static void main(String[] args) {
              try {
                  Integer.parseInt("abc");
              } catch (Exception e) {
                  System.out.println("catched");
                  throw new RuntimeException(e);
              } finally {
                  System.out.println("finally");
                  throw new IllegalArgumentException();
              }
          }
      }
      
      输出:
      
      catched
      finally
      Exception in thread "main" java.lang.IllegalArgumentException
      at Main.main(Main.java:11)
    5. 在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用origin变量保存原始异常,然后调用Throwable.addSuppressed(),把原始异常添加进来,最后在finally抛出。(通过Throwable.getSuppressed()可以获取所有的Suppressed Exception。绝大多数情况下,在finally中不要抛出异常。因此,通常不需要关心Suppressed Exception。)
       1 public class Main {
       2     public static void main(String[] args) throws Exception {
       3         Exception origin = null;
       4         try {
       5             System.out.println(Integer.parseInt("abc"));
       6         } catch (Exception e) {
       7             origin = e;
       8             throw e;
       9         } finally {
      10             Exception e = new IllegalArgumentException();
      11             if (origin != null) {
      12                 e.addSuppressed(origin);
      13             }
      14             throw e;
      15         }
      16     }
      17 }
  • 自定义异常
    1. 在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。一个常见的做法是自定义一个BaseException作为“根异常”,然后,派生出各种业务类型的异常。BaseException需要从一个适合的Exception派生,通常建议从RuntimeException派生。

      public class BaseException extends RuntimeException {
      }

      //其他业务类型的异常就可以从BaseException派生:
      public class UserNotFoundException extends BaseException {
      }
      
      public class LoginFailedException extends BaseException {
      }
      ...

      //自定义的BaseException应该提供多个构造方法
      public class BaseException extends RuntimeException {
          public BaseException() {
              super();
          }
      
          public BaseException(String message, Throwable cause) {
              super(message, cause);
          }
      
          public BaseException(String message) {
              super(message);
          }
      
          public BaseException(Throwable cause) {
              super(cause);
          }
      }
    2. 上述构造方法实际上都是原样照抄RuntimeException。这样,抛出异常的时候,就可以选择合适的构造方法。通过IDE可以根据父类快速生成子类的构造方法。

  • NullPointerException

    • NullPointerException即空指针异常,俗称NPE。如果一个对象为null,调用其方法或访问其字段就会产生NullPointerException,这个异常通常是由JVM抛出的。
    • 指针这个概念实际上源自C语言,Java语言中并无指针。我们定义的变量实际上是引用,Null Pointer更确切地说是Null Reference。
    • NullPointerException是一种代码逻辑错误,遇到NullPointerException,遵循原则是早暴露,早修复,严禁使用catch来隐藏这种编码错误。  
    • 成员变量在定义时初始化使用空字符串""而不是默认的null可避免很多NullPointerException,编写业务逻辑时,用空字符串""表示未填写比null安全得多。
    • 如果调用方一定要根据null判断,比如返回null表示文件不存在,那么考虑返回Optional<T>
      public Optional<String> readFromFile(String file) {
          if (!fileExist(file)) {
              return Optional.empty();
          }
          ...
      }

      这样调用方必须通过Optional.isPresent()判断是否有结果。

    • 定位NullPointerException

      •   从Java 14开始,如果产生了NullPointerException,JVM可以给出详细的信息告诉我们null对象到底是谁。
      • 这种增强的NullPointerException详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加一个-XX:+ShowCodeDetailsInExceptionMessages参数启用它:

        java -XX:+ShowCodeDetailsInExceptionMessages Main.java
  • 使用断言

    • 断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。 
      public static void main(String[] args) {
          double x = Math.abs(-123.45);
          assert x >= 0;
          System.out.println(x);
      }

      语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError

    • 使用assert语句时,还可以添加一个可选的断言消息。 
      assert x >= 0 : "x must >= 0";

      断言失败的时候,AssertionError会带上消息x must >= 0,更加便于调试。

  • 使用JDK Logging

    • Java标准库内置了日志包java.util.logging,可以直接用。
      import java.util.logging.Level;
      import java.util.logging.Logger;
      
      public class Hello {
          public static void main(String[] args) {
              Logger logger = Logger.getGlobal();
              logger.info("start process...");
              logger.warning("memory is running out...");
              logger.fine("ignored.");
              logger.severe("process will be terminated...");
          }
      }
    • 日志的输出可以设定级别。JDK的Logging定义了7个日志级别,从严重到普通:

      • SEVERE
      • WARNING
      • INFO
      • CONFIG
      • FINE
      • FINER
      • FINEST
    • 因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。

  • Commons Logging(理解)

    • Commons Logging是一个第三方日志库,它是由Apache创建的日志模块。Commons Logging的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Loggin自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。

    • 使用Commons Logging只需要和两个类打交道,并且只有两步:第一步,通过LogFactory获取Log类的实例; 第二步,使用Log实例的方法打日志。

      import org.apache.commons.logging.Log;
      import org.apache.commons.logging.LogFactory;
      
      public class Main {
          public static void main(String[] args) {
              Log log = LogFactory.getLog(Main.class);
              log.info("start...");
              log.warn("end.");
          }
      }
  • 使用Log4j

  • 使用SLF4J和Logback

推荐阅读