Walt You - 行是知之始

《Effective Java》学习日志(九)76:为保证失败的原子性而奋斗

2019-01-13


学习资料主要参考: 《Effective Java Third Edition》,作者:Joshua Bloch



在对象抛出异常之后,通常希望对象仍处于明确定义的可用状态,即使故障发生在执行操作中。对于已检查的异常尤其如此,调用者应从该异常中恢复。一般来说,失败的方法调用应该使对象处于调用之前的状态。具有此属性的方法被称为失败原子。

有几种方法可以达到这种效果。最简单的是设计不可变对象(第17项)。如果对象是不可变的,则失败原子性是免费的。如果操作失败,则可能会阻止创建新对象,但它永远不会使现有对象处于不一致状态,因为每个对象的状态在创建时都是一致的,之后无法修改。

对于对可变对象进行操作的方法,实现失败原子性的最常用方法是在执行操作之前检查参数的有效性(第49项)。这导致在对象修改开始之前抛出大多数异常。例如,考虑第7项中的Stack.pop方法:

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

如果消除了初始大小检查,则该方法在尝试从空堆栈中弹出元素时仍会抛出异常。 但是,它会使size字段处于不一致(负)状态,从而导致对象的任何未来方法调用失败。 另外,pop方法抛出的ArrayIndexOutOf- BoundsException对抽象是不合适的(Item 73)。

实现失败原子性的一种密切相关的方法是对按照顺序计算,以便任何可能失败的部分发生在修改对象的任何部分之前。 当不执行部分计算时无法检查参数时,此方法是前一个方法的自然扩展。 例如,考虑TreeMap的情况,其元素按照某些顺序排序。 为了向TreeMap添加元素,元素必须是可以使用TreeMap的顺序进行比较的类型。 在以任何方式修改树之前,尝试添加错误键入的元素自然会因为在树中搜索元素而导致ClassCastException失败。

实现失败原子性的第三种方法是对对象的临时副本执行操作,并在操作完成后用临时副本替换对象的内容。 当数据已经存储在临时数据结构中时,可以更快地执行计算,这种方法自然发生。 例如,某些排序函数在排序之前将其输入列表复制到数组中,以降低访问排序内部循环中元素的成本。 这样做是为了提高性能,但作为额外的好处,它确保在排序失败时输入列表不会受到影响。

实现故障原子性的最后且不太常见的方法是编写恢复代码,该代码拦截在操作中发生的故障,并使对象将其状态回滚到操作开始之前的点。 此方法主要用于持久(基于磁盘)的数据结构。

虽然通常需要失效原子性,但并不总是可以实现。例如,如果两个线程尝试在没有适当同步的情况下同时修改同一对象,则该对象可能处于不一致状态。因此,在捕获ConcurrentModificationException之后假设对象仍然可用是错误的。错误是不可恢复的,因此在抛出AssertionError时甚至不需要尝试保留失败原子性。

即使在可能存在故障原子性的情况下,也并非总是如此。对于某些操作,它会显着增加成本或复杂性。也就是说,一旦你意识到这个问题,通常都可以自由而轻松地实现故障原子性。

总之,作为规则,任何生成的异常都是方法规范的一部分,应该使对象处于方法调用之前的状态。违反此规则的地方,API文档应该清楚地指出该对象将处于什么状态。遗憾的是,许多现有的API文档无法实现这一理想。


Content