异常、断言和日志

为了避免由于程序的错误或一些外部环境的影响,导致用户在运行程序期间做的所有工作丢失,至少应该做到以下几

  • 向用户通知错误
  • 保存所有工作
  • 允许用户妥善地退出程序

对于异常情况,Java 使用了一种称为异常处理 exception handing 的错误不活机制

处理错误

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

  • 返回到一种安全状态,并能够让用户执行其他的命令;或者
  • 允许用户保存所有工作的结果,并以妥善的方式终止程序

异常处理的任务就是将控制权从产生错误的地方转移到能够处理这种情况的错误处理器。为了能够处理程序中的异常情况,有下列问题需要考虑

  • 用户输入错误。比如用户请求连接一个 URL,但这个 URL 语法不正确,你的代码应该对此进行检查
  • 设备错误。硬件经常会出问题
  • 物理限制。磁盘已满,你可能已经用尽了所有可用的存储空间
  • 代码错误。程序方法有可能没有正确地完成工作。例如,方法可能返回了一个错误的答案,或错误地调用了其他方法。

异常有自己的语法和特定的继承层次结构。

异常分类

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

所有的异常都是由 Throwable 继承而来,但在下一层立即分解为两个分支: ErrorException

Error 类层次结构描述了 Java 运行时系统内部错误和资源耗尽错误。你的应用程序不应该抛出这种类型的对象。你的应用程序不应该抛出这种错误,如果出现,除了通知用户,并尽力妥善地终止程序之外,你几乎无能为力

要重点关注 Exception 层次结构。这个层次结构有分解为两个分支:一个分支派生于 RuntimeExeption ;另一个分支包含其他异常。一般规则是:由编程错误导致的异常属于 RuntimeException ;而由于像 I/O 错误这类问题导致的异常属于其他异常

派生于 RuntimeException 的异常包括以下问题:

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

不是派生于 RuntimeException 的异常包括:

  • 试图超越文件末未继续读取数据
  • 试图打开一个不存在的文件
  • 试图根据指定的字符串查找Class对象,而这个字符串表示的类并不存在

java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非检查型异常。编译器将检查你是否为所有检查型异常提供了异常处理器

声明检查型异常

如果遇到了无法处理的情况,Java 方法可以抛出一个异常。这个道理很简单:方法不仅需要告诉编译器将要返回什么值,还要告诉编译器将要发生什么错误。

要在方法首部指出这个方法可能抛出一个异常,所以要修改方法首部,以反映这个方法可能抛出的检查型异常。比如,下面是标准类库中 FileInpugStream 类中的一个构造器的声明

public FileInputStream(String name) throws FileNotFoundException

在自己编写方法时,不必声明这个方法可能抛出的所有异常。至于什么时候需要在方法中 throw 子句声明异常,以及要用 throws 子句声明哪些异常,需要记住遇到下面 4 种情况时会抛出异常:

  • 调用了一个抛出检查型异常的方法,比如,FileInputStream构造器
  • 检测到一个错误,并利用throw语句抛出一个检查型异常
  • 程序出现错误,
  • Java 虚拟机或运行时库出现内部错误

出现前两种异常必须告诉调用这个方法的程序员有可能抛出异常。如果没有补货,执行的线程就会终止

有些 Java 方法包含在对外提供的类中,对于这些方法,应该通过方法首部的异常规范声明这个方法声明这个方法可能抛出异常

class MyAnimation {
    // 多个异常类可以用逗号分开
    public Image loadImage(String s) throws IOException, EOFException {
        //...
    }
}

但是不需要声明 Java 内部错误,即从 Error 或是 RuntimeException 继承的异常

总之,一个方法必须声明所有可能抛出的检查型异常,当然,你还可以捕获异常

如果超类方法没有抛出任何检查型异常,子类也不能抛出任何检查型异常

如何抛出异常

比如我们想要抛出一个 E0FException 异常,下面是抛出异常的语句

throw new E0FException();

或者是

var e = new E0FExcption();
throw e;

将这些代码连在一起

String readData(Scanner in) throws E0FException
{
    // ...
    if (n < len) throw new E0FException
}

E0FException 还有一个带一个字符串参数的构造器,你可以很好地利用这个构造器

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

创建异常类

你的代码有可能遇到任何标准异常类都无法描述清楚的问题。此时可以创建自己的异常类。我们需要做的只是定义一个派生于 Exception 的类,或派生于 Exception 的某个子类,如 I0Exception 。习惯做法是,自定义的这个类应该包含两个构造器,一个是默认的构造器,另一个是包含详细描述信息的构造器

class FileFormatException extends I0Exception {
    public FileFormatException() {}
    public FileFormatException(String gripe) {
        super(gripe);
    }
}

捕获异常

有些代码必须捕获异常。捕获异常需要做更多规划

捕获异常

如果发生了某个异常,但没有在任何地方捕获这个异常,程序就会终止,并在控制台上打印一个消息,其中包括这个异常的类型和一个堆栈轨迹。想要捕获一个异常,需要设置 try/catch 语句块

try {
    // ...
} catch(Exception e) {
    // handle...
}

如果 try 语句块中抛出了 catch 语句中的一个异常类,那么

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

如果调用了一个抛出检查型异常的方法,就要捕获你知道如何处理的异常,而继续传播那些你不知道怎样处理的异常。但是,如果编写一个方法覆盖超类的方法,而这个超类的方法没有抛出异常,你就必须捕获你的方法代码中出现的每一个检查型异常

捕获多个异常

在一个 try 语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理。要为每个异常类型使用一个单独的 catch 子句,如下例

try {
	// code
} catch(FileNotFoundException e) {
    // handle
} catch(I0Exception e) {
	// handle
}

异常对象可能包含有关异常性质的信息,想要获得这个对象的更多信息,可以尝试使用

e.getMessage() 得到详细的错误消息

或者使用

e.getMessage().getName() 得到异常对象的实际类型

也可以合并 catch 子句:

try {
	// code
// 只有当捕获的异常类型彼此之间不存在子类关系时才需要这个特性
} catch(FileNotFoundException | I0Exception e) {
	// handle
}

捕获多个异常时,异常变量隐含为 final 变量

再次抛出异常与异常链

可以在 catch 子句中抛出一个异常。通常,希望改变异常的类型时会这样做。我们可以把原始异常设为新异常的原因

try {
    // code
} catch(SQLException original) {
	var e = new ServletException("database error");
    e.initCause(original);
    throw e;
}

可以使用下面的语句获取原始异常

Throwable original = caughtException.getCause();

这种包装技术可以在子系统中抛出高层异常,而不会丢失原始异常

如果在一个方法中发生了一个检查型异常,但这个方法不允许抛出检查型异常,那么包装技术可以捕获这个异常并包装成一个运行时异常

finally 子句

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

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

return 语句从 try 语句块中退出时,在返回前,会执行 finally 子句,而 finally 子句中如果也有一个 return 语句,会屏蔽原来的返回值。

finally 语句要用于清理资源。不要把改变控制流的语句 return, throw, break, continue 放在 finally 语句中

try-with-Resources 语句

try-with-resource 的最简形式为:

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

try 块退出时,会自动调用 res.close() ,就好像调用了 finally 一样

如果 try 块抛出一个异常,而且 close 方法也抛出一个异常,那么原来的异常会被抛出,而 close 方法抛出的异常会 被抑制 。这些异常将自动捕获,并由 addSuppressed 方法增加到原来的异常,可以调用 getSuppressed 方法捕获从 close 方法抛出的异常数组

分析堆栈轨迹元素

堆栈轨迹是程序执行过程中某个特定点上所有挂起的方法调用的一个列表,可以调用 Throwable 类的 printStackTrace 方法访问堆栈轨迹的文本描述信息

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

一种更重要的方式是使用 StackWalker 类,它会生成一个 StackWalker.StackFrame 实例流,其中每一个实例分别描述一个栈帧 stack frame ,可利用以下调用迭代处理这些栈帧

StackWalker walker = StackWalker.getInstance();
walker.forEach(frame -> analyze frame);

使用异常的技巧

下面给出使用异常的一些技巧

  1. 异常处理不能代替简单的测试,捕获异常会花费大量的时间
  2. 不要过分细化异常,可以使代码量大大减少
  3. 充分利用异常层次结果。不要只抛出RuntimeException异常。应该寻找一个适合的子类或创建自己的异常类。不要只捕获Throwable异常
  4. 不要压制异常。如果你认为一场都非常重要,就应该适当地进行处理
  5. 在检查错误时,“苛刻”要比放任更好。在该抛出的时候就该抛出,而不是换个方式抛出
  6. 不要羞于传递异常。

使用断言

在一个具有自我保护能力的程序中,断言很常用

断言的概念

断言机制允许在测试期间向代码中插入一些检查,而在生产代码中会自动删除这些检查。Java 语言引入了关键字 assert ,这个关键字有两种形式:

assert condition;assert condition: expression;

这两个语句都会计算条件,如果结果为 false ,则抛出一个 AssertionError 异常。在第二个语句中,表达式将传入 AssertionError 对象的构造器,并转换成一个消息字符串

启用和禁用断言

在默认情况下,断言是禁用的。可以在运行程序时用 -enableassertions-ea 选项启用断言:

java -enableassertions MyApp

启用或禁用断言是类加载器的功能。禁用断言时,类加载器会去除断言代码

可以在某个类或整个包中启用断言

java -ea:MyClass -ea:com.mycompany.mylib MyApp

这条命令将为 MyClass 类以及 com.mycompany.mylib 包和它的子包中的所有类打开断言。选用 -ea 将打开无名包中所有类的断言

可以使用 -disableassertions-da 在某个特定类和包中禁用断言

对于系统类,需要使用 -enablesystemassertions/-esa 开关启用断言

使用断言完成参数检查

在 java 中,给出了 3 种处理系统错误的机制:

  • 抛出一个异常
  • 日志
  • 使用断言

什么时候使用断言呢?

  • 断言失败是致命的、不可恢复的错误
  • 断言检查只是在开发和测试阶段打开

日志

日志可以有效地替代 System.out.println 方法调用来帮助观察程序的行为。下面先讨论这个 API 的主要优点

  • 可以很容易地取消全部日志记录,或仅仅取消某个级别以下的日志,并且很容易地再次打开日志开关
  • 可以很简单地禁止日志记录
  • 日志记录可以被定向到不同的处理器,比如在控制台显示、写至文件等
  • 日志记录器和处理器都可以对记录进行过滤
  • 可采用不同的方式进行格式化,例如,纯文本或 XML
  • 应用程序可以使用多个日志记录器,它们使用与包名类似的有层次结构的名字,比如,com.mycompany.myapp
  • 日志系统的配置由配置文件控制

基本日志

要生成简单的日志记录,可以使用全局日志记录器 global logger 并调用其 info 方法:

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

如果在适当的地方调用

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

高级日志

你可以定义自己的日志记录器,可以调用 getLogger 方法创建或获取日志记录器:

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

未被任何变量引用的日志记录器可能会被垃圾回收,为了防止这种情况,需要用静态变量存储日志记录器的引用

日志记录器名也有层次结构,如果对日志记录器 com.mycompany 设置了日志级别,它的子日志记录器也会继承这个级别,有以下 7 个日志级别

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

在默认情况下,实际上只记录前 3 个级别。也可以设置一个不同的级别,例如

logger.setLevel(Level.FINE);

现在, FINE 以及所有更高级别的日志都会记录。还可用 Level.ALL 开启所有级别的日志记录,或使用 Level.OFF 关闭所有级别的日志记录。所有级别都有日志记录方法,如:

logger.warning(message);logger.fine(message);

还可以使用 log 方法指定级别

logger.log(Level.FINE, message);

默认的日志配置会记录 INFO 或更高级级别的所有日志,因此,应该使用 CONFIG、FINE、FINER 和 FINEST 级别来记录那些有助于诊断但对用户意义不大的调试信息

如果将记录级别设置为比如 INFO 更低的级别,还需要修改日志处理器的配置

默认的日志记录将显示根据调用堆栈得出的包含日志调用的类名和方法名,如果虚拟机对执行过程进行了优化,就得不到准确的调用信息。此时可以使用 logp 方法

记录日志的常见用途是记录那些预料之外的异常。可以使用下面两个便利方法在日志记录中包含异常的描述

void throwing(String className, String methodName, Throwable t)

void log(Level l, String message, Throwable t)

修改日志管理器配置

可以通过编辑配置文件来修改日志系统的各个属性。默认情况下,配置文件位于:

conf/logging.properties

要想使用另外一个配置文件,就要将 java.util.logging.config.file 属性设置为那个文件的位置,为此要用以下命令启动应用:

java -Djava.uril.logging.config.file=configFile MainClass

要想修改默认的日志级别,就需要编辑配置文件,并修改以下命令行:

.level=INFO

可通过下面一行来指定自定义日志器的日志级别:

com.mycompany.myapp.level=FINE

也就是说在日志记录器后面追加后缀 .level

日志记录器并不将消息发送到控制台,那是处理器的任务。处理器也有级别,要想在控制台上看到 FINE 级别的消息,需要进行以下设置: java.util.logging.ConsoleHandler.level=FINE

调试技巧

假设你写了一个程序,捕获并且恰当地处理了所有的异常以保证它万无一失。然后运行这个程序还是出现问题应该怎么办。

最好有一个方便且功能强大的调试器,不过,在使用调试器之前,还有一些值得尝试的技巧

  1. 可以用下面的方法打印或记录任意变量的值

System.out.println("x=" + x);logger.getGlobal().info("x=" + x);

  1. 可以在每个类中放置一个单独的main方法。这样就可以提供一个单元测试桩stub,能够独立地测试类
   public class MyClass {
       // methods and fields ...
   	public static void main(String[] args) {
   		// test code
       }
   }

可以建立一些对象,调用所有方法,检查每个方法是否能够正确地完成工作。另外,可以保留所有这些 main 方法,然后分别对各个文件启用 Java 虚拟机来运行测试。在运行 applet 时这些 main 方法不会被调用,而在运行应用程序时,Java 虚拟机只调用启动类的 main 方法

  1. 如果喜欢使用前面介绍的那个技巧,应该到http://junit.org网站上查看JUnit

  2. 日志代理logging proxy是一个子类的对象,它可以街区方法调用,记录日志,然后调用超类中的方法。

   var generator = new Random()
   {
       // 只要调用下面这个方法就会生成一个日志消息
       public double nextDouble() {
           double result = super.nextDouble();
           Logger.getGlobal().info("nextDouble:" + result);
           return result;
       }
   }
  1. 利用Throwable类的printStackTrace方法,可以从任意的对象获得堆栈轨迹。下面的代码将捕获任意的异常,打印整个异常对象和堆栈轨迹,然后,重新抛出异常,以便找到相应的处理器
   try {
       // ...
   } catch (Throwable t) {
       t.printStackTrace();
       throw t;
   }

也可以在代码的某个位置插入下面语句获得堆栈轨迹

Thread.dumpStack();

  1. 一般来说,堆栈轨迹显示在System.err上,如果想要记录或显示堆栈轨迹,可以如下将它捕获到一个字符串中:
   var out = new StringWriter();
   new Throwable().printStackTrace(new PrintWriter(out));
   String description = out.toString();
  1. 通常,将程序错误记入一个文件会很有用。不过,错误会发送到System.err,而不是System.out,因此,不能通过运行下面的命令来获取:

java MyProgram > errors.txt

而应当如下捕获错误流

java MyProgram 2> errors.txt

想要在同一个文件中同时捕获 System.errSystem.out ,需要使用以下命令:

java MyProgram 1> errors.txt 2>&1

  1. System.err中显示未捕获的异常的堆栈轨迹并不是一个理想的方法。更好的方法是将这些消息记录到一个文件中,可以用静态方法Thread.setDefaultUncaughtExceptionHandler改变未捕获异常的处理器:
   Thread.setDefaultUncaughtExceptionHandler(
   	new Thread.UncaughtExceptionHandler() {
   		public void uncaughtException(Thread t, Throwable e) {
               // save information in log file
           }
       }
   )
  1. 如果想要观察类的加载过程,启动 Java 虚拟机时可以使用-verbose标志

  2. -Xlint告诉编译器找出常见的代码问题

  3. Java 虚拟机增加了对 Java 应用程序的监控monitoring和管理management支持,允许在虚拟机中安装代理来跟踪内存消耗、线程使用、类加载等情况。

  4. Java 任务控制器Java Mission Control是一个专业级性能分析和诊断工具,包含在Oracle JDK中,可以免费用于开发