带你快速看完9.8分神作《Effective Java》—— 异常篇(你真的会用异常吗?)

简介: 我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。

我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。


最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。


接下来就针对此书列举一下我的收获与思考。


不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展 就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。


没有时间读原作的同学可以参考我这篇文章。


69 只针对异常的情况才使用异常


try {
  int i = 0;
  while ( true )
    range[i++].climb();
  } catch ( ArrayIndexOutOfBoundsException e ) {
}

当这个循环企图访问数组边界之外的第一个数组元素的时候,使用try-catch并且忽略ArrayIndexOutOfBoundsException异常的手段来达到终止循环的目的。

对于大多数程序员来说,下面的标准模式可读性就很高:


for ( Mountain m : range )
  m.climb();


第一串代码企图使用Java的错误判断机制来提高程序性能,因为VM对每次数组访问都要检查越界情况,这种想法有三个错误:


因为异常设计的初衷适用于不正常的情形,所有几乎没有JVM实现试图对他们进行优化

把代码放在try-catch块中反而阻止了现代JVM实现本可能执行的某些特定优化

对数据进行标准的for遍历并不会导致冗余的检查

实际上基于异常的模式比标准模式要慢得多



异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。



设计良好的API不应该强迫它的客户端为了正常的控制流程而使用异常,如果类中具有「状态相关」(state-dependent)的方法,这个类也应该具有一个单独的「状态测试」(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如Iterator接口含有状态相关的next方法,以及相应的状态测试方法hasNext。


for ( Iterator<Foo> i = collection.iterator(); i.hasNext(); ){
  Foo foo = i.next();
  ...
}


如果Iterator缺少hasNext方法,客户端将被迫改用下面的做法:


try {
  Iterator<Foo> i = collection.iterator();
  while ( true ){
    Foo foo = i.next();
    ...
  }
} catch ( NoSuchElementException e ) {}


另外一种提供单独状态测试的做法是,如果「状态相关」方法无法执行想要的计算,就可以让它返回一个零⻓度的optional值,或者返回一个可识别的null。


如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,就必须使用「optional或者可识别的null」

如果单独的「状态测试」方法必须重复「状态相关」方法的工作,从性能的⻆度考虑,就必须使用可被识别的返回值

其他情况,「状态测试」方法优于可被识别的null

70 对可恢复的情况使用受检异常,对编程错误使用运行时异常


Java 程序设计语言提供了三种 throwable:受检异常(checked exceptions)、运行时异常(runtime exceptions)和错误(errors)。对于什么情况适合使用哪种 throwable,是有一些一般性的原则提出了强有力的指导:


如果期望调用者能够合理的恢复程序运行,对于这种情况就应该使用受检异常。通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者把它传播出去。因此,方法中声明要抛出的每个受检异常都是对 API 用户的一个潜在提示:与异常相关联的条件是调用这个方法一种可能结果。



运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的 throwable。



用运行时异常来表明编程错误。大多数运行时异常都表示 API 的客户没有遵守 API 规范建立的约定,例如数组越界抛ArrayIndexOutOfBoundsException。



考虑资源枯竭的情形,这可能是由程序错误引起的,比如分配了一块不合理的过大数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大造成的,这种情况可能是可恢复的。API 设计者需要判断这样的资源枯竭是否允许恢复。如果你相信一种情况可能允许回复,就使用受检异常;如果不是,则使用运行时异常。如果不清楚是否有可能恢复,最好使用非受检异常。



好不要实现任何新的 Error 的子类,实现的所有非受检的 throwable 都应该是RuntimeExceptiond子类,也不应该抛出AssertionError异常。



因为受检异常往往指明了可恢复的条件,所以对于这样的异常,提供一些辅助方法尤其重要,通过这种方法调用者可以获得一些有助于程序恢复的信息。例如,假设因为用户资金不足,当他企图购买一张礼品卡时导致失败,于是抛出受检异常。这个异常应该提供一个访问方法,以便允许客户查询所缺的费用金额。


71 避免不必要的使用受检异常


如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首选应该返回一个optional值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。


受检异常强迫程序员处理异常的条件,大大增强了可靠性。过分使用受检异常会使API使用起来非常不方便。如果方法抛出受检异常,调用该方法代码就必须在catch块中处理,或者抛出异常。


这种负担在Java 8中更重了,因为抛出受检异常的方法不能直接在Stream中使用。



除非下面两种情况同时成立,否则更适合使用未受检异常:


正确地使用API并不能阻止这种异常条件的产生


一旦产生异常,程序员可以立即采取有效动作



作为一个石蕊测试,你可以试着问自己:程序员将如何处理该异常。下面的做法是最好的吗?


} catch ( TheCheckedException e ) {
  throw new AssertionError(); /* Can't happen! */
}


下面这种做法又如何?


} catch ( TheCheckedException e ) {
  e.printStackTrace(); /* Oh well, we lose. */
  System.exit( 1 );
}


如果觉得上面两种方式都不行的话,还是采用未受检的异常可能更合适。


石蕊测试:简单而具有决定性的测试


如果方法抛出的受检异常是唯一的,它给程序员带来的额外负担就会非常高:该方法必须放置于一个try块中,并且不能在Stream里用。



消除受检异常最容易的方法是,返回所要的结果类型的一个optional,但缺点是方法无法返回任何额外的详细信息


「把受检异常变成未受检异常」的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个boolean值,表明是否应该抛出异常。例如:


try {
  obj.action( args );
} catch ( TheCheckedException e ) {
  ... /* Handle exceptional condition */
}


被重构为:


if ( obj.actionPermitted( args ) ) {
  obj.action( args );
} else {
  ... /* Handle exceptional condition */
}


如果程序员知道调用将会成功,或者不介意由于调用失败而导致的线程终止,这种重构还允许以下这个更为简单的调用形式:


obj.action(args);

如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,这种重构就是不恰当的,因为在actionPermitted和action这两个调用的时间间隔之中,对象的状态有可能会发生变化。



72 优先使用标准的异常


Java平台类库提供了一组基本的未受检异常,它们满足了绝大多数API的异常抛出需求。


重用标准的异常有多个好处:


使API更易于学习和使用

对于用到这些API程序而言,它们的可读性会更好

异常类越少,意味着内存占用(footprint)就越小,装载这些类的时间开销也越少


以下是四种常用的异常:


IllegalArgumentException:


当调用者传递的参数值不合适的时候,往往就会抛出这个异常。比如,假设某一个参数代表了“某个动作的重复次数”,如果程序员给这个参数传递了一个负数,就会抛出这个异常。

IllegalStateException:


如果因为接收对象的状态而使调用非法,通常就会抛出这个异常。例如,如果在某个对象被正确地初始化之前,调用者就企图使用这个对象,就会抛出这个异常。

ConcurrentModificationException:


如果检测到一个专⻔设计用于单线程的对象,或者与外部同步机制配合使用的对象正在(或已经)被并发地修改,就应该抛出这个异常。

UnsupportedOperationException:


如果对象不支持所请求的操作,就会抛出这个异常。


不要直接重用Exception、RuntimeException、Throwable或者Error,对待这些类要像对待抽象类

一样。



如果希望稍微增加更多的失败一捕获(failure-capture)信息,可以放心地子类化标准异常,但要记住异常是可序列化的


73 抛出与抽象对应的异常


更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法称为异常转译(exception translation),如下代码所示:


try {
  ... /* Use lower-level abstraction to do our bidding */
} catch (LowerLevelException e) {
  throw new HigherLevelException(...);
}


下面的异常转译例子取自于AbstractSequentialList类,该类是List接口的一个⻣架实现(skeletal implementation)


/**
* Returns the element at the specified position in this list.
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= size()}).
*/
public E get(int index) {
  ListIterator<E> i = listIterator(index);
  try {
    return(i.next() );
  } catch (NoSuchElementException e) {
    throw new IndexOutOfBoundsException("Index: " + index);
  }
}

一种特殊的异常转译形式称为异常链(exception chaining),如果低层的异常对于调试导致高层异常的问题非常有帮助,使用异常链就很合适。低层的异常(原因)被传到高层的异常,高层的异常提供访问方法(Throwable的getCause方法)来获得低层的异常:


// Exception Chaining
try {
  ... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException cause) {
  throw new HigherLevelException(cause);
}


高层异常的构造器将原因传到支持链(chaining-aware)的超级构造器,因此它最终将被传给Throwable的其中一个运行异常链的构造器,例如Throwable(Throwable t) :


/* Exception with chaining-aware constructor */
class HigherLevelException extends Exception {
  HigherLevelException( Throwable cause ) {
    super(cause);
  }
}


对于没有支持链的异常,可以利用Throwable的initCause方法设置原因。异常链不仅让你可以通过程序(用getCause)访问原因,还可以将原因的堆战轨迹集成到更高层的异常中。



尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。


处理来自底层的异常通常有两种方法:


(推荐)在调用低层方法之前确保它们会成功执行,可以在给低层传递参数之前,检查更高层方法的参数的有效性


让更高层来悄悄地处理这些异常,从而将高层方法的调用者与低层的问题隔离开来。可以用某种适当的记录机制(如java.util.logging)将异常记录下来。这样有助于管理员调查问题,同时又将客户端代码和最终用户与问题隔离开来。


74 每个方法抛出的异常都需要创建文档


始终要单独地声明受检异常,并且利用Javadoc的@throws标签,准确地记录下抛出每个异常的条件。


如果一个公有方法可能抛出多个异常类,则不要声明它会抛出这些异常类的某个超类。



这条有一个例外:main方法它可以被安全地声明抛出Exception,因为它只通过虚拟机调用。



对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。



对于接口中的方法,在文档中记录下它可能抛出的未受检异常显得尤为重要。这份文档构成了该接口的通用约定(general contract)的一部分,它指定了该接口的多个实现必须遵循的公共行为。



使用Javadoc的@throws标签记录下一个方法可能抛出的每个未受检异常,但是不要使用throws关键字将未受检的异常包含在方法的声明中



如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可以接受的。


一个常⻅的例子是NullPointerException。若类的文档注释中有这样的描述:「All methods in this class throw a NullPointerException if a null object reference is passed in any parameter」



75 在细节消息中包含失败 - 捕获信息


当程序由于未被捕获的异常而失败的时候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法(string representation),即它的toString方法的调用结果。它通常包含该异常的类名,紧随其后的是细节消息(detail message)。



为了捕获失败,异常的细节信息应该包含“与该异常有关”的所有参数和字段的值。例如,IndexOutOfBoundsException异常的细节消息应该包含下界、上界以及没有落在界内的下标值。



注意两点:


在诊断和修正软件问题的过程中,许多人都可以看⻅堆栈轨迹,所以千万不要在细节消息中包含密码、密钥以及类似的信息。


关于失败的冗⻓描述信息通常是不必要的,这些信息可以通过阅读源代码而获得。



为了确保在异常的细节消息中包含足够的失败-捕捉信息,一种办法是在异常的构造器中引入这些信息。例如 IndexOutOfBoundsException 中使用如下构造器代替 String 构造器:


/**
* Constructs an IndexOutOfBoundsException.
*
* @param lowerBound the lowest legal index value
* @param upperBound the highest legal index value plus one
* @param index the actual index value
*/
public IndexOutOfBoundsException( int lowerBound, int upperBound, int index ) {
  // Generate a detail message that captures the failure
  super(String.format("Lower bound: %d, Upper bound: %d, Index: %d",lowerBound, upperBound, index ) );
  // Save failure information for programmatic access
  this.lowerBound = lowerBound;
  this.upperBound = upperBound;
  this.index = index;
}


76 保持失败的原子性


一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有失败原子性(failure atomic)。



有几种途径可以实现这种效果:

1. 设计一个不可变的对象


因为当每个对象被创建之后它就处于一致的状态之中,以后也不会再发生变化。


2. 对于可变对象,在执行操作之前检查参数的有效性


public Object pop() {
  if ( size == 0 )
    throw new EmptyStackException();
  Object result = elements[--size];
  elements[size] = null; /* Eliminate obsolete reference */
  return(result);
}


3. 调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生


以 TreeMap 的情形为例,它的元素被按照某种特定的顺序做了排序。为了向 TreeMap 中添加元素,该元素的类型就必须是可以利用 TreeMap 的排序准则与其他元素进行比较的。如果企图增加类型不正确的元素,在 tree 以任何方式被修改之前,自然会导致 ClassCastException 异常。


4. 在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容


5. 编写一段恢复代码(recovery code)


由它来拦截操作过程中发生的失败,以及便对象回滚到操作开始之前的状态上。这种办法主要用于永久性的(基于磁盘的)数据结构。



如果两个线程企图在没有适当的同步机制的情况下,并发地修改同一个对象,这个对象就有可能处在在不一致的状态中。因此,不能在捕获了ConcurrentModificationException异常之后再假设对象仍然是可用的。错误通常是不可恢复的,所以,当方法抛出 AssertionError 时,不需要再去保持失败原子性。


77 不要忽略异常


要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块:


try {
  ...
} catch ( SomeException e ) {
}


空的catch块会使异常达不到应有的目的,每当⻅到空的catch块时,应该警惕。


有些情形可以忽略异常。比如,关闭FileinputStream 的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,即使在这种情况下,把异常记录下来还是明智的做法,可以调查异常的原因。如果选择忽略异常,catch块中应该包含一条注释,说明为什么可以这么做,并且变量应该命名为ignored:


Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // Default: guaranteed sufficient for any map
try {
  numColors = f.get( 1L, TimeUnit.SECONDS );
} catch ( TimeoutException | ExecutionException ignored ) {
  // Use default: minimal coloring is desirable, not required
}


相关文章
|
24天前
|
Java
在 Java 中捕获和处理自定义异常的代码示例
本文提供了一个 Java 代码示例,展示了如何捕获和处理自定义异常。通过创建自定义异常类并使用 try-catch 语句,可以更灵活地处理程序中的错误情况。
48 1
|
24天前
|
Java
在 Java 中,如何自定义`NumberFormatException`异常
在Java中,自定义`NumberFormatException`异常可以通过继承`IllegalArgumentException`类并重写其构造方法来实现。自定义异常类可以添加额外的错误信息或行为,以便更精确地处理特定的数字格式转换错误。
30 1
|
25天前
|
IDE 前端开发 Java
怎样避免 Java 中的 NoSuchFieldError 异常
在Java中避免NoSuchFieldError异常的关键在于确保类路径下没有不同版本的类文件冲突,避免反射时使用不存在的字段,以及确保所有依赖库版本兼容。编译和运行时使用的类版本应保持一致。
60 7
|
27天前
|
Java 编译器
如何避免在 Java 中出现 NoSuchElementException 异常
在Java中,`NoSuchElementException`通常发生在使用迭代器、枚举或流等遍历集合时,尝试访问不存在的元素。为了避免该异常,可以在访问前检查是否有下一个元素(如使用`hasNext()`方法),或者使用`Optional`类处理可能为空的情况。正确管理集合边界和条件判断是关键。
53 6
|
29天前
|
Java
Java异常捕捉处理和错误处理
Java异常捕捉处理和错误处理
20 1
|
1月前
|
Java 编译器 开发者
Java异常处理的最佳实践,涵盖理解异常类体系、选择合适的异常类型、提供详细异常信息、合理使用try-catch和finally语句、使用try-with-resources、记录异常信息等方面
本文探讨了Java异常处理的最佳实践,涵盖理解异常类体系、选择合适的异常类型、提供详细异常信息、合理使用try-catch和finally语句、使用try-with-resources、记录异常信息等方面,帮助开发者提高代码质量和程序的健壮性。
49 2
|
1月前
|
Java
如何在 Java 中处理“Broken Pipe”异常
在Java中处理“Broken Pipe”异常,通常发生在网络通信中,如Socket编程时。该异常表示写入操作的另一端已关闭连接。解决方法包括:检查网络连接、设置超时、使用try-catch捕获异常并进行重试或关闭资源。
91 5
|
1月前
|
存储 安全 Java
如何避免 Java 中的“ArrayStoreException”异常
在Java中,ArrayStoreException异常通常发生在尝试将不兼容的对象存储到泛型数组中时。为了避免这种异常,确保在操作数组时遵循以下几点:1. 使用泛型确保类型安全;2. 避免生类型(raw types)的使用;3. 在添加元素前进行类型检查。通过这些方法,可以有效防止 ArrayStoreException 的发生。
33 3
|
2月前
|
人工智能 Oracle Java
解决 Java 打印日志吞异常堆栈的问题
前几天有同学找我查一个空指针问题,Java 打印日志时,异常堆栈信息被吞了,导致定位不到出问题的地方。
39 2
|
2月前
|
Java 索引
如何避免在 Java 中引发`StringIndexOutOfBoundsException`异常
在Java中,处理字符串时若访问了不存在的索引,会抛出`StringIndexOutOfBoundsException`异常。为避免此异常,应确保索引值在有效范围内,例如使用`length()`方法检查字符串长度,并确保索引值不小于0且不大于字符串长度减1。
26 3