Java核心技术笔记 异常、断言和日志

1,142 阅读16分钟

《Java核心技术 卷Ⅰ》 第7章 异常、断言和日志

  • 处理错误
  • 捕获异常
  • 使用异常机制的技巧
  • 记录日志

处理错误

如果由于出现错误而是的某些操作没有完成,程序应该:

  • 返回到一种安全状态,并让用户执行一些其他操作;或者
  • 允许用户保存所有操作,并以妥善方式终止程序

检测(或引发)错误条件的代码通常离:

  • 能让数据恢复到安全状态
  • 能保存用户的操作结果并正常退出程序

的代码很远。

异常处理的任务:将控制权从错误产生地方转移给能够处理这种情况的错误处理器

异常分类

在Java中,异常对象都是派生于Throwable类的一个实例,如果Java中内置的异常类不能满足需求,用户还可以创建自己的异常类。

Java异常层次结构:

  • Throwable
    • Error
      • ...
    • Exception
      • IOException
        • ...
      • RuntimeException
        • ...

可以看到第二层只有ErrorException

Error类层次结构描述了Java运行时系统的内部错误资源耗尽错误,应用程序不应该抛出这种类型的对象,这种内部错误的情况很少出现,出现了能做的工作也很少。

设计Java程序时,需关注Exception层次结构,这个层次又分为两个分支,RuntimeException和包含其他异常的IOException

划分两个分支的规则是:

  • 由程序错误导致的异常属于RuntimeException
  • 程序本身无问题,由于像I/O错误这类问题导致的异常属于其他异常IOException

派生于RuntimeException的异常包含下面几种情况:

  • 错误类型转换
  • 数组访问越界
  • 访问null指针

派生于IOException的异常包含下面几种情况:

  • 试图在文件尾部后面读取数据
  • 试图打开一个不存在的文件
  • 试图根据指定字符串查找Class对象,而这个字符串表示的类并不存在

Java语言规范将派生于Exception类和RuntimeException类的所有异常统称非受查(unchecked)异常,所有其他异常都是受查(checked)异常。

编译器将核查是否为所有的受查异常提供了异常处理器

声明受查异常

一个方法不仅要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误

异常规范(exception specification):方法应该在其首部声明所可能抛出的异常。

public FileInputStream(String name) throws FileNotFoundException

如果这个方法抛出了这样的异常对象,运行时系统会开始搜索异常处理器,以便知道如何处理这个异常对象。

当然不是所有方法都需要声明异常,下面4种情况应该抛出异常:

  1. 调用一个抛出受查异常的方法时
  2. 程序运行过程中发现错误,并且利用throw语句抛出一个受查异常
  3. 程序出现错误,一般是非受查异常
  4. Java虚拟机和运行时库出现的内部错误

出现前两种情况之一,就必须告诉调用者这个方法可能的异常,因为如果没有处理器捕获这个异常,当前执行的线程就会结束

如果一个方法有多个受查异常类型,就必须在首部列出所有的异常类,异常类之间用逗号隔开:

class MyAnimation
{
  ...
  public Image loadImage(String s) throws FileNotFoundException, EOFException
  {
    ...
  }
}

但是不需要声明Java的内部错误,即从Error继承的错误。

关于子类和超类在这部分的问题:

  • 子类方法声明的受查异常不能比超类中方法声明的异常更通用(即子类能抛出更特定的异常或者根本不抛出任何异常)
  • 如果超类没有抛出任何受查异常,子类也不能

如果类中的一个方法声明抛出一个异常,而这个异常是某个特定类的实例时:

  • 这个方法可能抛出一个这个类的异常(比如IOExcetion
  • 或抛出这个类的任意一个子类的异常(比如FileNotFoundException

如何抛出异常

假设程序代码中发生了一些很糟糕的事情。

首先要决定应该抛出什么类型的异常(通过查阅已有异常类的Java API文档)。

抛出异常的语句是:

throw new EOFException();
// 或者
EOFException e = new EOFException();
throw e;

一个名为readData的方法正在读取一个首部有信息Content-length: 1024的文件,然而读到733个字符之后文件就结束了,这是一个不正常的情况,希望抛出一个异常。

String readData(Scanner in) throws EOFException
{
  ...
  while(...)
  {
    if(!in.hasNext()) // EOF encountered
    {
      if(n < len)
        throw new EOFException();
    }
    ...
  }
  return s;
}

EOFException类还有一个含有一个字符串类型参数的构造器,这个构造器可以更加细致的描述异常出现的情况。

String gripe = "Content-length:" + len + ", Received:" + n;
throw new EOFException(gripe);

对于一个已经存在的异常类,将其抛出比较容易:

  1. 找到一个合适的异常类
  2. 创建这个类的一个对象
  3. 将对象抛出

一旦抛出异常,这个方法就不可能返回到调用者,即不必为返回的默认值或错误代码担忧。

创建异常类

实际情况中,可能会遇到任何标准异常类不能充分描述的问题,这时候就应该创建自己的异常类。

需要做的只是定义一个派生于Exception的类,或者派生于Exception子类的类。

习惯上,定义的类应该包含两个构造器:

  • 一个是默认的构造器
  • 另一个是带有详细描述信息的构造器(超类ThrowabletoString方法会打印出这些信息,在调试中有很多用)
class FileFormatException extends IOException
{
  public FileFormatException() {}
  public FileFormatException(String gripe)
  {
    super(gripe);
  }
}

捕获异常

捕获异常

要想捕获一个异常,必须设置try/catch语句块。

try
{
  code
  ...
}
catch(ExceptionType e)
{
  handler for this type
}

如果try语句块中任何代码抛出了一个在catch子句中说明的异常类,那么:

  1. 程序将跳过try语句块的其余代码
  2. 程序将执行catch子句中的处理器代码

如果没有代码抛出任何异常,程序跳过catch子句。

如果方法中的任何代码抛出了一个在catch子句中没有声明的异常类型,那么这个方法就会立即退出

// 读取数据的典型代码
public void read(String filename)
{
  try
  {
    InputStream in = new FileInputStream(filename);
    int b;
    while((b = in.read()) != -1)
    {
      // process input
      ...
    }
  }
  catch(IOException exception)
  {
    exception.printStackTrace();
  }
}

read方法有可能抛出一个IOException异常,这种情况下,将跳出整个while循环,进入catch子句,并生成一个栈轨迹

还有一种选择就是什么也不做,而是将异常传递给调用者

public void read(String filename) throws IOException
{
  InputStream in = new FileInputStream(filename);
  int b;
  while((b = in.read()) != -1)
  {
    // process input
    ...
  }
}

编译器严格地执行throws说明符,如果调用了一个抛出受查异常的方法,就必须对它进行处理,或者继续传递。

两种方式哪种更好

通常,应该捕获那些知道如何处理的异常,将那些不知道怎么样处理的异常进行传递。

这个规则也有一个例外:如果编写一个覆盖超类的方法,而这个方法又没有抛出异常,那么这个方法就必须捕获方法代码中出现的每一个受查异常;并且不允许在子类的throws说明符中出现超过超类方法所列出的异常类范围。

捕获多个异常

为每个异常类型使用一个单独的catch子句:

try
{
  code
  ...
}
catch(FileNotFoundException e)
{
  handler for missing files
}
catch(UnknownHostException e)
{
  handler for unknown hosts
}
catch(IOException e)
{
  handler for all other I/O problems
}

异常对象可能包含与异常相关的信息,可以使用e.getMessage()获得详细的错误信息,或者使用e.getClass().getName()得到异常对象的实际类型。

在Java SE 7中,同一个catch子句中可以捕获多个异常类型,如果动作一样,可以合并catch子句:

try
{
  code
  ...
}
catch(FileNotFoundException | UnknownHostException e)
{
  handler for missing files and unknown hosts
}
catch(IOException e)
{
  handler for all other I/O problems
}

只有当捕获的异常类型彼此之间不存在子类关系时才需要这个特性。

再次抛出异常与异常链

catch子句中可以抛出一个异常,这样做的目的是改变异常的类型

try
{
  access the database
}
catch(SQLException e)
{
  throws new ServletException("database error:" + e.getMessage());
}

ServletException用带有异常信息文本的构造器来构造。

不过还有一种更好的处理方法,并将原始异常设置为新异常的“原因”

try
{
  access the database
}
catch(SQLException e)
{
  Throwable se = new ServletException("database error");
  se.initCause(e);
  throw se;
}

当捕获到异常时,可以使用下面这条语句重新得到原始异常:

Throwable e = se.getCause();

这样可以让用户抛出子系统中的高级异常,而不会丢失原始异常的细节。

finally子句

当代码抛出一个异常时,就会终止方法中剩余代码的处理,并退出这个方法的执行。

如果方法获得了一些本地资源,并且只有这个方法自己知道,又如果这些资源在退出方法之前必须被回收(比如数据库连接的关闭),那么就会产生资源回收问题。

一种是捕获并重新抛出所有异常,这种需要在两个地方清除所分配的资源,一个在正常代码中,另一个在异常代码中。

Java有一种更好地解决方案,就是finally子句。

不管是否有异常被捕获,finally子句的代码都会被执行。

InputStream in = new FileInputStream(...);
try
{
  // 1
  code that might throw exception
  // 2
}
catch(IOException e)
{
  // 3
  show error message
  // 4
}
finally
{
  // 5
  in.close();
}
// 6

上面的代码中,有3种情况会执行finally子句:

  1. 代码没有抛出异常,执行序列为1、2、5、6
  2. 抛出一个在catch子句中捕获的异常
    1. 如果catch子句没有抛出异常,执行序列为1、3、4、5、6
    2. 如果catch子句抛出一个异常,异常将被抛回这个方法的调用者,执行序列为1、3、5(注意没有6)
  3. 代码抛出了一个异常,但是这个异常没有被捕获,执行序列为1、5

try语句可以只有finally子句,没有catch子句。

有时候finally子句也会带来麻烦,比如清理资源时也可能抛出异常。

如果在try中发生了异常,并且被catch捕获了异常,然后在finally中进行处理资源时如果又发生了异常,那么原有的异常将会丢失,转而抛出finally中处理的异常。

这个时候的一种解决办法是用局部变量Exception ex暂存catch中的异常:

  • try中进行执行的时候加入嵌套的try/catch,并在catch中暂存ex并向上抛出
  • finally中处理资源的时候加入嵌套的try/catch,并且在catch中进行判断ex是否存在来进一步处理
InputStream in = ...;
Exception ex = null;
try
{
  try
  {
    code that might throw exception
  }
  catch(Exception e)
  {
    ex = e;
    throw e;
  }
}
finally
{
  try
  {
    in.close();
  }
  catch(Exception e)
  {
    if(ex == null)throw e;
  }
}

下一节会介绍,Java SE 7中关闭资源的处理会容易很多。

带资源的try语句

对于以下代码模式:

open a resource
try
{
  work with the resource
}
finally
{
  close the resource
}

假设资源属于一个实现了AutoCloseable接口的类,Java SE 7位这种代码提供了一个很有用的快捷方式,AutoCloseable接口有一个方法:

void close() throws Exception

带资源的try语句的最简形式为:

try(Resource res = ...)
{
  work with res
}

try块退出时,会自动调用res.close()

try(Scanner in = new Scanner(new FileInputStream("..."), "UTF-8"))
{
  while(in.hasNext())
    System.out.println(in.next());
}

这个块正常退出或存在一个异常时,都会调用in.close()方法,就好像使用了finally块一样。

还可以指定多个资源:

try(Scanner in = new Scanner(new FileInputStream("..."), "UTF-8");
  PrintWriter out = new PrintWriter("..."))
{
  while(in.hasNext())
    System.out.println(in.next().toUpperCase());
}

不论如何这个块如何退出,inout都会关闭,但是如果用常规手动编程,就需要两个嵌套的try/finally语句。

之前的close抛出异常会带来难题,而带资源的try语句可以很好的处理这种情况,原来的异常会被重新抛出,而close方法带来的异常会“被抑制”。

分析堆栈轨迹元素

堆栈轨迹(stack trace)是一个方法调用过程的列表,包含了程序执行过程中方法调用的特定位置。

可以调用Throwable类的printStackTrace方法访问堆栈轨迹的文本描述信息。

Throwable t = new Throwable();
StringWriter out = new StringWriter();
t.printStackTrace(new PrintWriter(out));
String description = out.toString();

一种更灵活的方法是使用getStackTrace方法,会得到StackTraceElement对象的一个数组,可以在程序中分析这个对象数组:

StackTraceElement[] frames = t.getStackTrace();
for(StackTraceElement frame : frames)
  analyze frame

StackTraceElement类含有能够获得文件名和当前执行的代码行号的方法,同时还含有能获得类名和方法名的方法,toString方法会产生一个格式化的字符串,其中包含所获得的信息。

静态的Thread.getAllStackTraces方法,可以产生所有线程的堆栈轨迹。

Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();
for(Thread t : map.keySet())
{
  StackTraceElememt[] frames = map.get(t);
  analyze frames
}

java.lang.Throwable

  • Throwable(Throwable cause)
  • Throwable(String message, Throwable cause)
  • Throwable initCause(Throwable cause):将这个对象设置为“原因”,如果这个对象已经被设置为“原因”,则抛出一个异常,返回this引用。
  • Throwable getCause():获得设置为这个对象的“原因”的异常对象,如果没有则为null
  • StackTraceElement[] getStackTrace():获得构造这个对象时调用堆栈的跟踪
  • void addSuppressed(Throwable t):为这个异常增加一个抑制异常
  • Throwable[] getSuppressed():得到这个异常的所有抑制异常

java.lang.StackTraceElement

  • String getFileName()
  • int getLineNumber()
  • String getClassName()
  • String getMethodName()
  • boolean isNativeMethod():如果这个元素运行时在一个本地方法中,则返回true
  • String toString():如果存在的话,返回一个包含类名、方法名、文件名和行数的格式化字符串,如StackTraceTest.factorial(StackTraceTest.java:18)

使用异常机制的技巧

1.异常处理不能代替简单的测试

在进行一些风险操作时(比如出栈操作),应该先检测当前操作是否有风险(比如检查是否已经空栈),而不是用异常捕获来代替这个测试。

与简单的测试相比,捕获异常需要花费更多的时间,所以:只在异常情况下使用异常机制

2.不要过分细分化异常

如果可以写成一个try/catch(s)的语句,那就不要写成多个try/catch

3.利用异常层次结构

不要只抛出RuntimeException异常,应该寻找更适合的子类或创建自己的异常类。

不要只抛出Throwable异常,否则会使程序代码可读性、可维护性下降。

4.不要压制异常

在Java中,倾向于关闭异常。

public Image loadImage(String s)
{
  try
  {
    codes
  }
  catch(Exception e)
  {}
}

这样代码就可以通过编译了,如果发生了异常就会被忽略。当然如果认为异常非常重要,就应该对它们进行处理。

5.检测错误时,“苛刻”要比放任更好

6.不要羞于传递异常

有时候传递异常比捕获异常更好,让高层次的方法通知用户发生了错误,或者放弃不成功的命令更加适宜。

断言

这部分和测试相关,以后有需要的话单独开设一章进行说明。

记录日志

不要再使用System.out.println来进行记录了

使用记录日志API吧

基本日志

简单的日志记录,可以使用全局日志记录器(global logger)并调用info方法:

Logger.getGlobal().info("File->Open menu item selected");

默认情况下会显示:

May 10, 2013 10:12:15 ....
INFO: File->Open menu item selected

如果在适当的地方调用:

Logger.getGlobal().setLevel(Level.OFF);

高级日志

可以不用将所有的日志都记录到一个全局日志记录器中,也可以自定义日志记录器:

private static final Logger myLogger = Logger.getLogger("com.mycompany.myapp");

未被任何变量引用的日志记录器可能会被垃圾回收,为了避免这种情况,可以用一个静态变量存储日志记录器的一个引用。

与包名类似,日志记录器名也具有层次结构,并且层次性更强。

对于包来说,包的名字与其父包没有语义关系,但是日志记录器的父与子之间共享某些属性。

例如,如果对com.mycompany日志记录器设置了日志级别,它的子记录器也会继承这个级别。

通常有以下7个日志记录器级别Level

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

默认情况下,只记录前三个级别。

另外,可以使用Level.ALL开启所有级别的记录,或者使用Level.OFF关闭所有级别的记录。

对于所有的级别有下面几种记录方法:

logger.warning(message);
logger.info(message);

也可以使用log方法指定级别:

logger.log(Level.FINE, message);

如果记录为INFO或更低,默认日志处理器不会处理低于INFO级别的信息,可以通过修改日志处理器的配置来改变这一状况。

默认的日志记录将显示包含日志调用的类名和方法名,如同堆栈所显示的那样。

但是如果虚拟机对执行过程进行了优化,就得不到准确的调用信息,此时,可以调用logp方法获得调用类和方法的确切位置,这个方法的签名为:

void logp(Level l, String className, String methodName, String message)

记录日志的常见用途是记录那些不可预料的异常,可以使用下面两个方法提供日志记录中包含的异常描述内容:

if(...)
{
  IOException exception = new IOException("...");
  logger.throwing("com.mycompany.mylib.Reader", "read", exception);
  throw exception;
}

还有

try
{
  ...
}
catch(IOException e)
{
  Logger.getLogger("com.mycompany.myapp").log(Level.WARNING, "Reading image", e);
  z
}

调用throwing可以记录一条FINER级别的记录和一条以THROW开始的信息。

剩余部分暂时不做介绍,初步了解到这即可,一把要结合IDE一起来使用这个功能。如果后续的高级知识部分有需要的话会单独开设专题来介绍。

Java异常、断言和日志总结

  • 处理错误
  • 异常分类
  • 受查异常
  • 抛出异常
  • 创建异常类
  • 捕获异常
  • 再次抛出异常与异常链
  • finally子句
  • 在资源的try语句
  • 分析堆栈轨迹元素
  • 使用异常机制的技巧
  • 基本日志与高级日志

个人静态博客: