Appearance
Java 基础:异常、断言和日志
1. 异常
1.1. 异常类层次结构
所有的异常都是由 Throwable 继承而来,但在下一层立即分解为两个分支:Error 和 Exception。

Throwable 的继承层次结构Error 类层次结构描述了 Java 运行时系统的内部错误和资源耗尽错误,如果出现了这样的内部错误,除了通知用户,并尽力妥善地终止程序之外,你几乎无能为力。
要重点关注的是 Exception 的层次结构。这个层次结构又分解为两个分支:一个分支派生于 RuntimeException;另一个分支包含其他异常。一般规则是:由编程错误导致的异常属于 RuntimeException;如果程序本身没有问题,但由于像 I/O 错误这类问题导致的异常属于其他异常。
派生于 RuntimeException 的异常包括以下问题:
- 错误的强制类型转换;
- 数组访问越界;
- 访问
null指针;
不是派生于 RuntimeException 的异常包括:
- 试图超越文件末尾继续读取数据;
- 试图打开一个不存在的文件;
- 试图根据给定的字符串查找
Class对象,而这个字符串表示的类并不存在;
“如果出现 RuntimeException 异常,那么就一定是你的问题” 这个规则很有道理。应该通过检测数组下标是否越界来避免 ArrayIndexOutOfBoundsException 异常;应该在使用变量之前通过检测它是否为 null 来杜绝 NullPointerException 异常的发生。如何处理不存在的文件呢?难道不能先检查文件是否存在再打开它吗?嗯,这个文件有可能在你检查它是否存在之后就立即被删除了。因此,“是否存在” 取决于环境,而不只是决于你的代码。
1.2. 非检查型/检查型异常
派生于 Error 类或 RuntimeException 类的所有异常称为非检查型(unchecked)异常,所有其他的异常称为检查型(checked)异常。
在下面这两种情况中,我们必须用 throws 子句声明那些可能抛出的异常:
调用了一个抛出检查型异常的方法,例如,
FileInputStream构造器:Javapublic FileInputStream(String name) throws FileNotFoundException检测到一个错误,并且利用
throw语句抛出一个检查型异常;
但是,不需要声明 Java 的内部错误,即从 Error 继承的异常,任何程序代码都有可能抛出那些异常,而我们对此完全无法控制。同样,也不应该声明从 RuntimeException 继承的那些非检查型异常。
总之,一个方法必须声明所有可能抛出的检查型异常,而非检查型异常要么在你的控制之外(Error), 要么是由从一开始就应该避免的情况所导致的(RuntimeException)。
Warning:如果在子类中覆盖了超类的一个方法,子类方法中声明的检查型异常不能比超类方法中声明的异常更通用(子类方法可以抛出更特定的异常,或者根本不抛出任何异常)。特别需要说明的是,如果超类方法没有抛出任何检查型异常,子类也不能抛出任何检查型异常。例如,如果覆盖
JComponent.paintComponent方法,由于超类中这个方法没有抛出任何检查型异常,所以,你的paintComponent也不能抛出任何检查型异常。
1.3. finally 子句
当 finally 子句包含 return 语句时,有可能产生意想不到的结果。假设利用 return 语句从 try 语句块中间退出。在方法返回前,会执行 finally 子句块。如果 finally 块也有一个 return 语句,这个返回值将会遮蔽原来的返回值。来看下面这个例子:
Java
public static int parseInt(String s) {
try {
return Integer.parseInt(s);
} finally {
return 0; // ERROR
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
看起来在 parseInt("42") 调用中,try 块的体会返回整数 42。不过,这个方法真正返回之前,会执行 finally 子句,这就使得方法最后会返回 0,而忽略原先的返回值。更糟糕的是,考虑调用 parseInt("zero")。Integer.parseInt 方法会抛出一个 NumberFormatException 然后执行 finally 子句,return 语句甚至会 “吞掉” 这个异常!
Tip:不要把改变控制流的语句(
return、throw、break、continue)放在finally子句中,而是将其用于清理资源。
1.4. try-with-resources 语句
任何实现了 AutoCloseable 接口的对象都可以使用 try-with-resources 语句:
Java
public interface AutoCloseable {
void close() throws Exception;
}1
2
3
2
3
另外,还有一个 Closeable 接口,它是 AutoCloseable 的子接口,也只包含一个 close 方法。主要区别如下:
AutoCloseable的close方法声明为抛出一个Exception,Closeable则是IOException,由此也可以得知Closeable主要用于关闭 IO 流;AutoCloseable不强制要求close方法的幂等性,而Closeable的close方法则必须为幂等的;
try-with-resources 语句的最简形式为:
Java
try (Resource res = ...) {
// work with res
}1
2
3
2
3
还可以指定多个资源:
Java
try (var in = new Scanner(new FileInputStream("/usr/share/dict/words"), StandardCharsets.UTF_8);
var out = new PrintWriter("out.txt", StandardCharsets.UTF_8)) {
while (in.hasNext())
out.println(in.next().toUpperCase());
}1
2
3
4
5
2
3
4
5
Tip:try-with-resources 语句自身也可以有
catch子句,甚至还可以有一个finally子句。这些子句会在关闭资源之后执行。
1.5. 分析堆栈轨迹元素
可以调用 Throwable 类的 printStackTrace 方法访问堆栈轨迹的文本描述信息。
Java
var t = new Throwable();
var out = new StringWriter();
t.printStackTrace(new PrintWriter(out));
String description = out.toString();1
2
3
4
2
3
4
Note:在代码中直接插入
Thread.dumpStack()也可以获得当前的堆栈轨迹。
一种更灵活的方法是使用 StackWalker 类,它会生成一个 StackWalker.StackFrame 实例流,其中每个实例分别描述一个栈帧(stack frame)。可以利用以下调用迭代处理这些栈帧:
Java
StackWalker walker = StackWalker.getInstance();
walker.forEach(frame -> analyze frame)1
2
2
以懒方式处理 Stream<StackWalker.StackFrame>:
Java
walker.walk(stream -> process stream)以下示例程序利用 StackWalker 打印了递归阶乘函数的堆栈轨迹:
Java
package stackTrace;
import java.util.*;
public class StackTraceTest {
public static int factorial(int n) {
System.out.println("factorial(" + n + "):");
var walker = StackWalker.getInstance();
walker.forEach(System.out::println);
int r;
if (n <= 1) r = 1;
else r = n * factorial(n - 1);
System.out.println("return " + r);
return r;
}
public static void main(String[] args) {
try (var in = new Scanner(System.in)) {
System.out.print("Enter n: ");
int n = in.nextInt();
factorial(n);
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Text
factorial(3):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:20)
stackTrace.StackTraceTest.main(StackTraceTest.java:36)
factorial(2):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:20)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:26)
stackTrace.StackTraceTest.main(StackTraceTest.java:36)
factorial(1):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:20)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:26)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:26)
stackTrace.StackTraceTest.main(StackTraceTest.java:36)
return 1
return 2
return 61
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2. 断言
断言机制允许在测试期间向代码中插入一些检查,而在生产代码中会自动删除这些检查。其语法如下所示:
EBNF
'assert' condition [':' expression] ';'如果结果为 false,则抛出一个 AssertionError 异常。如果提供了 expression,则其将作为参数传入 AssertionError 对象的构造器,并转换成一个消息字符串。
2.1. 启用或禁用断言
在默认情况下,断言是禁用的。可以在运行程序时用 -enableassertions 或 -ea 选项启用断言(-disableassertions/-da 禁用断言):
Bash
$ java -enableassertions MyAppNote:不必重新编译程序来启用或禁用断言,启用或禁用断言是类加载器(class loader)的功能。
也可以在某个类或整个包中启用断言,例如:
Bash
$ java -ea:MyClass -ea:com.mycompany.mylib MyApp这条命令将为 MyClass 类以及 com.mycompany.mylib 包和它的子包中的所有类打开断言。
不过,启用和禁用所有断言的 -ea 和 -da 开关不能应用到那些没有类加载器的 “系统类” 上。对于这些系统类,需要使用 -enablesystemassertions/-esa 开关启用断言。
Tip:记住:断言失败是致命的、不可恢复的错误。断言检查只是在开发和测试阶段打开。
3. 日志
3.1. 基本日志
要生成简单的日志记录,可以使用全局日志记录器(global logger)并调用其 info 方法:
Java
Logger.getGlobal().info("File->Open menu item selected");在默认情况下,会如下打印这个记录:
Text
May 10, 2013 10:12:15 PM LoggingImageViewer fileOpen
INFO: File->Open menu item selected1
2
2
但是,如果在适当的地方(如 main 的最前面)调用:
Java
Logger.getGlobal().setLevel(Level.OFF);将会取消所有日志。
3.2. 高级日志
可以调用 getLogger 方法创建或获取日志记录器:
Java
private static final Logger myLogger = Logger.getLogger("com.mycompany.myapp");Tip:未被任何变量引用的日志记录器可能会被垃圾回收。为了防止这种情况发生,要像上面的例子中一样,用静态变量存储日志记录器的引用。
对于包来说,包与父包之间没有语义关系,但是日志记录器的父与子之间将共享某些属性。例如,如果对日志记录器 com.mycompany 设置了日志级别,它的子日志记录器也会继承这个级别。
通常,有以下 7 个日志级别:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
在默认情况下,实际上只记录前 3 个级别。也可以设置一个不同的级别,例如:
Java
logger.setLevel(Level.FINE);现在,FINE 以及所有更高级别的日志都会记录。
默认的日志记录将显示根据调用堆栈得出的包含日志调用的类名和方法名。不过,如果虚拟机对执行过程进行了优化,就得不到准确的调用信息。此时,可以使用 logp 方法获得调用类和方法的确切位置,这个方法的签名为:
Java
void logp(Level l, String className, String methodName, String message)有一些用来跟踪执行流的便利方法,如 entering、exiting:
Java
int read(String file, String pattern) {
logger.entering("com.mycompany.mylib.Reader", "read", new Object[] { file, pattern });
// ...
logger.exiting("com.mycompany.mylib.Reader", "read", count);
return count;
}1
2
3
4
5
6
2
3
4
5
6
这些调用将生成 FINER 级别而且以字符串 ENTRY 和 RETURN 开头的日志记录。
可以使用 throwing 和 log 等方法记录关于异常信息的描述:
Java
if (...) {
var e = new IOException("...");
logger.throwing("com.mycompany.mylib.Reader", "read", e);
throw e;
}1
2
3
4
5
2
3
4
5
Java
try {
// ...
} catch (IOException e) {
logger.log(Level.WARNING, "Reading image", e);
}1
2
3
4
5
2
3
4
5
throwing 调用可以记录一条 FINER 级别的日志记录和一条以 THROW 开始的消息。
3.3. 修改日志管理器配置
可以通过编辑配置文件来修改日志系统的各个属性。在默认情况下,配置文件位于:
Text
conf/logging.propertiesNote:在 Java 9 之前,位于
jre/lib/logging.properties。
要想使用另一个配置文件,就要将 java.util.logging.config.file 属性设置为那个文件的位置,为此要用以下命令启动应用程序:
Bash
$ java -Djava.util.logging.config.file=yourpath MainClass日志管理器在虚拟机启动时初始化,也就是在 main 方法执行前。如果想要定制日志属性,但是没有用 -Djava.util.logging.config.file 命令行选项启动应用,可以在程序中调用 System.setProperty("java.util.logging.config.file", file)。不过,这样一来,你还必须调用 LogManager.getLogManager().readConfiguration() 重新初始化日志管理器。
要想修改默认的日志级别,就需要编辑配置文件,并修改以下命令行:
Properties
.level=INFO可以通过添加下面这一行来指定自定义日志记录器的日志级别:
Properties
com.mycompany.myapp.level=FINE稍后可以看到,日志记录器并不将消息发送到控制台,那是处理器的任务。处理器也有级别。要想在控制台上看到 FINE 级别的消息、就需要进行以下设置:
Properties
java.util.logging.ConsoleHandler.level=FINE在 Java 9 中,可以通过调用以下方法更新日志配置:
Java
LogManager.getLogManager().updateConfiguration(mapper);调用以上函数会从 java.util.logging.config.file 系统属性指定的位置读取一个新配置。然后应用这个映射器来解析老配置或新配置中所有键的值。映射器是一个 Function<String, BiFunction<String,String,String>> 它将现有配置中的键映射到替换函数。每个替换函数接收到与键关联的老值和新值(没有关联的值则为 null),生成一个替换,或者如果要在更新中删除这个键则返回 null。例如,你想更新以 com.mycompany 开头的键,而其他的键保持不变,则 mapper 可以写为:
Java
key -> key.startsWith("com.mycompany") ? ((oldValue, newValue) -> newValue) : ((oldValue, newValue) -> oldValue)3.4. 本地化
一个程序可以包含多个资源包,例如一个用于菜单,另一个用于日志消息。每个资源包都有一个名字(如 com.mycompany.logmessages)。要想为资源包增加映射,需要对应每个本地化环境提供一个文件。英文消息映射位于 com/mycompany/logmessages-en.properties 文件中;德文消息映射位于 com/mycompany/logmessages-de.properties 文件中(其中 en 和 de 是语言编码)。可以将这些文件与应用程序的类文件放在一起,以便 ResourceBundle 类自动找到它们。这些文件都是纯文本文件,包含如下所示的条目:
Properties
readingFile=Achtung! Datei wird eingelesen
renamingFile=Datei wird umbenannt1
2
2
请求一个日志记录器时,可以指定一个资源包:
Java
Logger logger = Logger.getLogger(loggerName, "com.mycompany.logmessages");然后,为日志消息指定资源包的键,而不是实际的日志消息字符串:
Java
logger.info("readingFile");通常需要在本地化的消息中增加一些参数,因此,消息可能包括占位符 {0}、{1} 等。例如,要想在日志消息中包含文件名,可以如下使用占位符:
Properties
readingFile=Reading file {0}.Properties
readingFile=Achtung! Datei {0} wird eingelesen.然后,通过调用下面的方法向占位符传递具体的值:
Java
logger.log(Level.INFO, "readingFile", fileName);3.5. 处理器
在默认情况下,日志记录器将记录发送到 ConsoleHandler,并由它输出到 System.err 流。与日志记录器一样,处理器也有日志级别。日志管理器配置文件将默认的控制台处理器的日志级别设置为:
Properties
java.util.logging.ConsoleHandler.level=INFO可以设置你自己的处理器:
Java
Logger logger = Logger.getLogger("com.mycompany.myapp");
logger.setLevel(Level.FINE);
logger.setUseParentHandlers(false);
var handler = new ConsoleHandler();
handler.setLevel(Level.FINE);
logger.addHandler(handler);1
2
3
4
5
6
7
2
3
4
5
6
7
在默认情况下,日志记录器将记录发送到自己的处理器和父日志记录器的处理器。我们的日志记录器是祖先日志记录器(名为 "")的子类,而这个祖先日志记录器默认的处理器为 ConsoleHandler。因为我们并不想两次看到这些记录,因此应该将 useParentHandlers 属性设置为 false。
日志 API 提供了两个很有用的处理器 FileHandler 和 SocketHandler。SocketHandler 将记录发送到指定的主机和端口。FileHandler 将记录收集到文件中,文件默认位于用户主目录的 javan.log 文件中,其中 n 是保证文件唯一的一个编号。默认情况下,记录会格式化为 XML。一个典型的日志记录形式如下:
XML
<record>
<date>2002-02-04T07:45:15</date>
<millis>1012837515710</millis>
<sequence>1</sequence>
<logger>com.mycompany.myapp</logger>
<level>INFO</level>
<class>com.mycompany.mylib.Reader</class>
<method>read</method>
<thread>10</thread>
<message>Reading file corejava.gif</message>
</record>1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
可以通过设置日志管理器配置文件中的不同参数(见表 3.1),或者使用其它构造器来修改文件处理器的默认行为。
| 配置属性 | 描述 | 默认值 |
|---|---|---|
java.util.logging.FileHandler.level | 处理器级别 | Level.ALL |
java.util.logging.FileHandler.append | 控制处理器应该追加到一个已经存在的文件末尾,还是应该为每个运行的程序打开一个新文件 | false |
java.util.logging.FileHandler.limit | 在打开另一个文件之前允许写入一个文件的近似最大字节数(0 表示无限制) | 在 FileHandler 类中为 0(表示无限制);在默认的日志管理器配置文件中为 50000 |
java.util.logging.FileHandler.pattern | 日志文件名的模式,参见表 3.2 中的模式变量 | %h/java%u.log |
java.util.logging.FileHandler.count | 循环序列中的日志记录数量 | 1(不循环) |
java.util.logging.FileHandler.filter | 要使用的过滤器类 | 不过滤 |
java.util.logging.FileHandler.encoding | 要使用的字符编码 | 平台的编码 |
java.util.logging.FileHandler.formatter | 记录格式化器 | java.util.logging.XMLFormatter |
| 变量 | 描述 |
|---|---|
%h | 系统属性 user.home 的值 |
%t | 系统临时目录 |
%u | 用于解决冲突的唯一编号 |
%g | 循环日志的生成号(如果指定了循环而且模式不包含 %g 时,使用 .%g 后缀) |
%% | % 字符 |
可以通过扩展 Handler 或 StreamHandler 自定义处理器:
Java
class WindowHandler extends StreamHandler {
public WindowHandler() {
// ...
var output = new JTextArea();
setOutputStream(new OutputStream() {
public void write(int b) {} // not called
public void write(byte[] b, int off, int len) {
output.append(new String(b, off, len));
}
});
}
// ...
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
以上写法会存在一个问题,那就是处理器会缓存记录,并且只有在缓冲区满的时候才将它们写入流中,因此,需要覆盖 publish 方法,以便在处理器获得每个记录之后刷新输出缓冲区。
Java
class WindowHandler extends StreamHandler {
// ...
public void publish(LogRecord record) {
super.publish(record);
flush();
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
如果希望编写更加复杂的流处理器,就应该扩展 Handler 类,并定义 publish、flush 和 close 方法。
3.6. 过滤器
在默认情况下,会根据日志记录的级别进行过滤。每个日志记录器和处理器都有一个可选的过滤器来完成附加的过滤。要定义一个过滤器,需要实现 Filter 接口并定义以下方法:
Java
boolean isLoggable(LogRecord record)要想将一个过滤器安装到一个日志记录器或处理器中,只需要调用 setFilter 方法就可以了。
3.7. 格式化器
要自定义格式只需要扩展 Formatter 类并覆盖下面这个方法:
Java
String format(LogRecord record)可以根据自己的需要以任何方式对记录中的信息进行格式化,并返回结果字符串。在 format 方法中,可以选择调用 formatMessage 方法,该方法对记录中的消息部分进行格式化,将替换参数并应用本地化处理:
Java
String formatMessage(LogRecord record)很多文件格式(如 XML)需要在已格式化的记录的前后加上一个头部和尾部。为此,要覆盖下面两个方法:
Java
String getHead(Handler h)
String getTail(Handler h)1
2
2
最后,调用 setFormatter 方法将格式化器安装到处理器中。