级别: 中级 Brian Goetz (brian@quiotix.com), 首席顾问, Quiotix
2006 年 9 月 07 日 上一期的 Java 理论与实践专栏 说明了如何充分利用 FindBugs 之类的静态分析工具来管理软件质量,并重点测试了 bug 的整个目录(而不是特定 bug 实例)。在本期专栏中,专职 bug 清除专家 Brian Goetz 将详细说明构造和优化不平凡的 bug 模式检测器的过程。
这一简短系列的 第 1 部分 介绍了如何进行有效测试,它构建了 FindBugs 插件来查找一个简单的 bug 模式(只需调用 System.gc() 即可)。Bug 模式会标识有问题的编码实践,它们常常位于 bug 所在的区域。当然,并不是所有出现 bug 模式的地方都一定出现 bug,但这并不能抹杀 bug 模式检测器的巨大作用。一个有效 bug 模式检测器的主要功能是发现更高百分比的可疑代码,使该模式具有更大的使用价值。创建 bug 模式检测器可以提高使用价值;创建检测器之后,无论是现在还是将来,您都可以在您需要的任何代码上运行它,并且您可能对发现的问题感到惊讶。例如,第 1 部分 中的简单检测器显示了对 System.gc() 的调用,在 JDK 1.4.2 中,它隐藏在 JPEG 图像 I/O 库中。
编写检测器可以查找对特定静态方法的调用,这并不困难,但是大多数的 bug 检测器都包含相当多的分析和实现。在这一期的文章中,您将开发一个称为
RuntimeException capture 的更小 bug 模式的检测器(目前,FindBugs 发行版中已包含此 bug 检测器。)
RuntimeException 捕获
用 Java™ 语言进行异常处理的一个优点是:异常是一些对象,try-catch 机制了解异常类型的分层结构,并在客户机如何处理错误处理方面提供实际灵活性。例如,如果不能找到文件,则 FileInputStream 构造函数会抛出
FileNotFoundException,该异常是
IOException 的一个子类。此传统用法允许客户机处理未发现文件的条件,这些条件是从其他与文件相关的条件中分离出来的(如果他们喜欢单独捕获 FileNotFoundException)。但是,他们还可以使用捕获 IOException 的方法处理所有与文件相关的错误条件。
另一方面,异常处理的主要缺陷是:在正确使用异常时,易于建立带有三行或四行业务逻辑以及 20 或 30 行异常处理的方法。因为错误恢复代码在测试时容易出现错误并且执行困难,使一部分专门用于异常处理的代码无所适从并容易出错。这种情况的典型示例如清单所示,其中带有两行 “真的” 代码的方法需要三个独立的捕获块,每个捕获块都执行完全相同的操作 —— 记录该异常:
清单 1. 多个相同的捕获块
public void addInstance(String className) {
try {
Class clazz = Class.forName(className);
objectSet.add(clazz.newInstance());
}
catch (IllegalAccessException e) {
logger.log("Exception in addInstance", e);
}
catch (InstantiationException e) {
logger.log("Exception in addInstance", e);
}
catch (ClassNotFoundException e) {
logger.log("Exception in addInstance", e);
}
}
|
请参见清单 1,您可能尝试将三个捕获块合并成捕获 Exception 的单独捕获块,因为每个捕获块的捕获恢复操作是相同的。乍一看,该策略似乎是一个好方法 —— 但代码副本有错误,所以整合这些复制路径应该是一种改进。不过,此 “改进” 常常会带来意想不到的结果。因为 RuntimeException 扩展了
Exception,将三个捕获块合并成一个捕获块(如清单 2 所示),所以这会更改语义,现在,未经检查的异常将被记录(而不传播)。此 bug 模式(其中 RuntimeException 容易被超大捕获块捕获)也称为 RuntimeException 捕获。
清单 2. RuntimeException 捕获 bug 模式 —— 不要执行此模式
public void addInstance(String className) {
try {
Class clazz = Class.forName(className);
objectSet.add(clazz.newInstance());
}
catch (Exception e) {
logger.log("Exception in newInstance", e);
}
}
|
bug 模式通常源自语言的模糊功能或类库;出现此 bug 模式是因为 RuntimeException 扩展了
Exception,这稍微有点违反常理。对 RuntimeException 捕获的修复非常容易 —— 您需要了解以下问题:首先捕获 RuntimeException,并在捕获 Exception 之前重新将其抛出,如清单 3 所示。不过,即使知道 bug 模式及其修复方法,在代码审查过程中也很容易忘记执行它或忽略它,并且编译器也不会通知您。这是引入 bug 模式的原因,帮助您避免违犯 “您已较好地了解” 之类的错误。
清单 3. 通过显式处理 RuntimeException 修复 RuntimeException 捕获
public void addInstance(String className) {
try {
Class clazz = Class.forName(className);
objectSet.add(clazz.newInstance());
}
catch (RuntimeException e) {
throw e;
}
catch (Exception e) {
logger.log("Exception in newInstance", e);
}
}
|
编写 RuntimeException 捕获检测器
正如您在 上一期 中所学的,编写 bug 模式的第一个步骤是清楚地标识 bug 模式。在这里,bug 模式是捕获 Exception 的 catch 块,这时不存在用于 RuntimeException 的相应捕获块,并且尝试块中的任何方法调用或
throw 语句都不会抛出
Exception。要检测此 bug 模式,则需要知道 try-catch 块的位置、try 块可能抛出的内容以及在
catch 块中将捕获的内容。
标识捕获的异常
像上个月的操作一样,您可以通过创建 BytecodeScanningDetector 基础类(可实现 Visitor 模式)的子类启动 bug 检测器。在
BytecodeScanningDetector 中有一个
visit(Code) 方法,并且在每次发现
catch 块时,该实现都会调用 visit(CodeException)。如果重写 visit(Code),并从那里调用 super.visit(Code),则当超类
visit(Code) 返回时,它将调用用于该方法中所有 catch 块的 visit(CodeException)。清单 4 了显示实现 visit(Code) 和 visit(CodeException) 的第一步,它将积累方法中所有 catch 块的信息。每个 CodeException 都包含相应 try 块的起始和终止的字节码偏移量,这样您可以方便地确定哪一个 CodeException 对象与 try-catch 块对应。
清单 4. 第一版 RuntimeException 捕获检测器可以收集某一方法中抛出的异常信息
public class RuntimeExceptionCapture extends BytecodeScanningDetector {
private BugReporter bugReporter;
private Method method;
private OpcodeStack stack = new OpcodeStack();
private List<ExceptionCaught> catchList;
private List<ExceptionThrown> throwList;
public void visitMethod(Method method) {
this.method = method;
super.visitMethod(method) }
public void visitCode(Code obj) {
catchList = new ArrayList<ExceptionCaught>();
throwList = new ArrayList<ExceptionThrown>();
stack.resetForMethodEntry(this);
super.visitCode(obj);
// At this point, we've identified all the catch blocks
// More to come...
}
public void visit(CodeException obj) {
super.visit(obj);
int type = obj.getCatchType();
if (type == 0) return;
String name =
getConstantPool().constantToString(getConstantPool().getConstant(type));
ExceptionCaught caughtException =
new ExceptionCaught(name, obj.getStartPC(), obj.getEndPC(), obj.getHandlerPC());
catchList.add(caughtException);
}
}
|
标识抛出的异常
此时,您已获得了您需要的一半信息:在何处捕获哪些异常。现在必须找出哪些异常被抛出。为此,您需要重写 BytecodeScanningDetector 的
sawOpcode() 方法,并处理与方法调用和异常抛出相对应的字节码。可以根据 athrow JVM 指令抛出异常。三个 JVM
指令分别用于调用以下方法:invokestatic、invokevirtual 和 invokespecial。就像使用
visit(CodeException) 一样,在调用超类 visit(Code) 时可以调用 sawOpcode,这样,如果在 sawOpcode() 中收集信息,那么在
super.visit(Code) 返回时,您将获得您需要的、有关捕获和抛出异常的所有信息。
清单 5 显示了 sawOpcode() 的实现,它将处理上述 JVM 指令。对于 athrow 指令,可以使用 FindBugs 的 OpcodeStack 帮助器类来了解 athrow 操作数的类型。对于方法调用指令,可以使用 Bytecode Engineering Library (BCEL) 类来提取方法声明抛出的已检查异常的类型。在任何一种情况下,都可以积累关于哪些异常在方法中的哪个字节码偏移量被抛出的信息,这样,在完成整个方法的处理后,可以将它们进行匹配。
清单 5. 标识受访问代码中抛出异常的位置
public void sawOpcode(int seen) {
stack.mergeJumps(this);
try {
switch (seen) {
case ATHROW:
if (stack.getStackDepth() > 0) {
OpcodeStack.Item item = stack.getStackItem(0);
String signature = item.getSignature();
if (signature != null && signature.length() > 0) {
if (signature.startsWith("L"))
signature = SignatureConverter.convert(signature);
else
signature = signature.replace('/', '.');
throwList.add(new ExceptionThrown(signature, getPC()));
}
}
break;
case INVOKEVIRTUAL:
case INVOKESPECIAL:
case INVOKESTATIC:
String className = getDottedClassConstantOperand();
try {
if (!className.startsWith("[")) {
JavaClass clazz = Repository.lookupClass(className);
Method[] methods = clazz.getMethods();
for (Method method : methods) {
if (method.getName().equals(getNameConstantOperand())
&& method.getSignature().equals(getSigConstantOperand())) {
ExceptionTable et = method.getExceptionTable();
if (et != null) {
String[] names = et.getExceptionNames();
for (String name : names)
throwList.add(new ExceptionThrown(name, getPC()));
}
break;
}
}
}
} catch (ClassNotFoundException e) {
bugReporter.reportMissingClass(e);
}
break;
default:
break;
}
} finally {
stack.sawOpcode(this, seen);
}
}
|
汇总结果
在获得所需的关于捕获和抛出异常的信息后,最后一步是汇总这些信息。在超类 visit(Code) 的调用返回后,将完全填充
throwList 和 caughtList 集合。它们包含关于方法中所有
try-catch 块的信息,所以您必须将抛出信息和捕获信息关联,以标识 bug 模式。
清单 6 显示了用于标识
RuntimeException 捕获的逻辑。它将迭代捕获块的列表,如果发现捕获 Exception 的块,它会再次查找捕获块,该捕获块将捕获字节码同一范围的 RuntimeException。它还可以查找在字节码的相应范围中抛出
Exception 的实例。如果没有捕获 RuntimeException,也没有抛出
Exception,则存在一个潜在的 bug。
清单 6. 合并捕获和抛出数据,以标识 RuntimeException 捕获
for (ExceptionCaught caughtException : catchList) {
Set<String> thrownSet = new HashSet<String>();
for (ExceptionThrown thrownException : throwList) {
if (thrownException.offset >= caughtException.startOffset
&& thrownException.offset < caughtException.endOffset) {
thrownSet.add(thrownException.exceptionClass);
if (thrownException.exceptionClass.equals(caughtException.exceptionClass))
caughtException.seen = true;
}
}
int catchClauses = 0;
if (caughtException.exceptionClass.equals("java.lang.Exception")
&& !caughtException.seen) {
// Now we have a case where Exception is caught, but not thrown
boolean rteCaught = false;
for (ExceptionCaught otherException : catchList) {
if (otherException.startOffset == caughtException.startOffset
&& otherException.endOffset == caughtException.endOffset) {
catchClauses++;
if (otherException.exceptionClass.equals("java.lang.RuntimeException"))
rteCaught = true;
}
}
int range = caughtException.endOffset - caughtException.startOffset;
if (!rteCaught) {
bugReporter.reportBug(new BugInstance(this, "REC_CATCH_EXCEPTION",
NORM_PRIORITY)
.addClassAndMethod(this)
.addSourceLine(this, caughtException.sourcePC));
}
}
}
|
要编写 bug 检测器,则需要了解 JVM 字节码和类文件的一些结构。BCEL 和 FindBugs 库将为您处理此任务,并从字节码中提取信息,在稍高级别上呈现它。遗憾的是,关于 BCEL 和 FindBugs 如何支持类分离的文档并不能满足您的需要。像使用许多开放源码项目一样,关于如何编写检测器的最佳信息源是参照执行类似任务的其他检测器。
优化检测器
使用静态分析的最大消耗是处理假警报。静态分析不一定精确,其目标不是发现 bug,而是只发现那些可能是 bug 的构造,这意味着有时会标记正确的代码出错。如果代码审核工具产生 95% 的假警报,那么任何人都不太可能想再次使用它;第一次发现报告新 bug 的假警报真的很痛苦。所以对于一个有效的 bug 模式检测器,它必须最小化假警报数量,最好使假情报不超过 50%。
优化检测器的最佳方法是在 JDK 类库 (rt.jar)、Eclipse 或 JBoss 之类的大型代码基址上运行它。所以在编写 bug 检测器后,应该试着在新的项目和示例上运行它,以查看它们是真实的 bug,还是假警报。对于非凡的检测器(比如这里开发的检测器),第一次体验常常有点让人失望—— 假警报比预期的多。
优化检测器的过程包括查找假警报和细化 bug 模式,以消除某些假警报,同时不要将太多的真实 bug 排除在外。为细化模式,可以执行的操作之一是消除以下情况:存在零或 try 块中抛出已经过检查的异常;在这些情况下,捕获 Exception 不可能导致尝试合并多个捕获块,而会导致反映对捕获未经检查的异常的真实愿望。此修改对假警报率有很大的影响。
优化检测器通常包括 “得分” 算法的使用,以确定是否将匹配报告为 bug。通过使用几个因素可执行其他调优,以增加或减少对给定实例的 “信心得分”。某些方面(如不存在任何已检查的异常)可以减少候选匹配的得分;其他方面,比如捕获异常失效方面(在捕获块中不使用),可以增加候选匹配的得分。清单 7 显示了对此检测器进行优化后形成的得分算法;它将优先级用作得分,因为在某一阈值上具有优先权的 bug 被 bug 报告的框架忽略(较高优先级的值指示的实际
bug 的严重性较低)。
清单 7. 优化后 RuntimeException 捕获检测器使用的得分算法
if (!rteCaught) {
int priority = LOW_PRIORITY + 1;
if (range > 300) priority--;
else if (range < 30) priority++;
if (catchClauses > 1) priority++;
if (thrownSet.size() > 1) priority--;
if (caughtException.dead) priority--;
bugReporter.reportBug(new BugInstance(this, "REC_CATCH_EXCEPTION",
priority)
.addClassAndMethod(this)
.addSourceLine(this, caughtException.sourcePC));
}
|
结束语
为静态代码分析工具(如 FindBugs)编写自定义 bug 检测器可以显著提高代码质量,并且有许多乐趣。尽管编写和优化 bug 检测器非常困难(优化它们对确保其能够使用非常重要),用检测器捕获 bug 模式的信息要付出高昂的代价,但是,使用这些信息能够花费少量工作来扫描任何项目中的 bug 模式,从而使您对最愚蠢的 bug 查找方式感到惊讶。
参考资料 学习
获得产品和技术
讨论
关于作者  | |  | Brian Goetz 在过去的 18 年里一直是一名专业软件开发人员。他是 Quiotix 的首席顾问,Quiotix 是位于美国加利福尼州洛斯拉图斯的一家软件开发和咨询公司。他还在几个 JCP 专家组任职。Brian 的新书 Java Concurrency In Practice 于 2006 年 5 月由 Addison-Wesley 出版。请参阅 Brian 在业界流行出版物上 已发表和即将发表的文章。 |
对本文的评价
|