用 enum 代替 int 常量

Java 枚举类型的基本想法非常简单:这些类通过公有的静态 final 域为每个枚举常量导出一个实例。枚举类型没有可以访问的构造器,所以它是真正的 final 类。客户端不能创建枚举类型的实例,也不能对它进行扩展,因此不存在实例,而只存在声明过的枚举常量。枚举类型还保证了编译时的类型安全。

在枚举类型中还可以声明抽象方法,并在特定于常量的类主体中,用具体的方法覆盖每个枚举常量的抽象方法。这种方法被称作特定于常量的方法实现。

用实例域代替序数

所有的枚举都有一个ordinal 方法,它返回每个枚举常量在类型中的数字位置。你可以试着从序数中得到与枚举关联的 int 值。但如果枚举常量进行重新排序,序数值就会遭到破坏。如果要再添加一个与已经用过的 int 值关联的枚举常量,就没那么走运了。

因此,永远不要根据枚举的序数导出与它关联的值,而是要将它保存在一个实例域中。实际上,大名数程序员都不需要这个方法。它是设计用于像 EnumSet 和 EnumMap 这种基于枚举的通用数据结构的。除非你在编写的是这种数据结构,杏则最好完全避免使用 ordinal 方法。

注解优先于命名模式

坚持使用 @Override 注解

Lambda 优先于匿名类

匿名类满足了传统的面向对象的设计模式对函数对象的需求,最著名的有策略模式。比如 Comparator 接口代表一种排序的抽象策略。但是,匿名类的烦琐使得在 Java 中进行函数编程的前景变得十分黯淡。

在 Java 8 中,形成了“带有单个抽象方法的接口是特殊的,值得特殊对待”的观念。这些接口现在被称作函数接口,Java 允许利用 Lambda 表达式创建这些接口的实例。Lambda 类似于匿名类的函数,但比它简洁得多。

你可能会认为,在 Lambda 时代,匿名类已经过时了。但仍有一些工作用 Lambda 无法完成,只能用匿名类才能完成。Lambda 限于函数接口。如果想创建抽象类的实例,可以用匿名类来完成,而不是用 Lambda。同样地,可以用匿名类为带有多个抽象方法的接口创建实例。最后,Lambda 无法获得对自身的引用。在 Lambda 中,关键字 this 是指外围实例,这个通常正是你想要的。在匿名类中,关键字 this 是指匿名类实例。如果需要从函数对象的主体内部访向它,就必须使用匿名类。

方法引用优先于 Lambda

坚持使用标准的函数接口

JDK 在 java.util.function 包内已经为 Lambda 提供了大量标准的函数接口。只要标准的函数接口能够满足需求,通常应该优先考虑,而不是专门再构建一个新的函数接口。这样会使 API 更加容易学习,通过减少它的概念内容,显著提升互操作性优势,因为许多标准的函数接口都提供了有用的默认方法。如 Predicate 接口提供了合并断言的方法。

谨慎使用 Stream

在 Java 8 中增加了 Stream APl,简化了串行或并行的大批量操作。Stream 中的元素可能来自任何位置,常见的有集合、数组、文件、正则表达式模式匹配器、伪随机数生成器,以及其他 Stream。Stream 中的数据元素可以是对象引用或基本类型值。它支持三种基本类型:int、long 和 double。

一个 Stream pipeline 中包含一个源 Stream,接者是 0 个或多个中间操作和一个终止操作。每个中间操作都会通过某种方式对 Stream 进行转换,所有的中间操作都是将一个 Stream 转换成另一个 Stream,其元素类型可能与输入的 Stream 一样,也可能不同。终止操作会在最后一个中间操作产生的 Stream 上执行一个最终的计算,例如将其元素保存到一个集合中,或者打印出所有元素等。

Stream pipeline 通常是 lazy 的:直到调用终止操作时才会开始计算,对于完成终止操作不需要的数据元素,将永远都不会被计算。正是这种 lazy 计算,使无限 Stream 成为可能。注意,没有终止操作的 Stream pipcline 将是一个静默的无操作指令,因此千万不能忘记终止操作。

Strcam API 是流式的:所有包含 pipeline 的调用可以链接成一个表达式。事实上,多个 pipeline 也可以链接在一起,成为一个表达式。在默认情况下,Stream pipeline 是按顺序运行的。要使 pipeline 并发执行,只需在该
pipeline 的任何 Stream 上调用 parallel 方法即可,但是通常不建议这么做。

Stream API 包罗万象,足以用 Stream 执行任何计算,但是“可以”并不意味着“应该”。如果使用得当,Stream 可以使程序变得更加简洁、清晰;如果使用不当,会使程序变得混乱且难以维护。

优先选择 Stream 中无副作用的函数

总而言之,编写 Stream pipeline 的本质是无副作用的函数对象。这适用于传入 Stream 及相关对象的所有函数对象。终止操作中的 forEach 应该只用来报告由 Stream 执行的计算结果,而不是让它执行计算。为了正确地使用 Stream,必须了解收集器。最重要的收集器工厂是 toList、toSet、toMap、groupingBy 和 joining。

谨慎使用 Stream 并行

总而言之,尽量不要并行 Stream pipeline,除非有足够的理由相信它能保证计算的正确性,并且能加快程序的运行速度。如果对 Stream 进行不恰当的并行操作,可能导致程序运行失败,或者造成性能灾难。如果确信并行是可行的,并发运行时一定要确保代码正确,并在真实环境下认真地进行性能测试。

谨慎设计方法签名

  • 谨慎地选择方法的名称
  • 不要过于追求提供便利的方法
  • 避免过长的参数列表
  • 对于参数类型,要优先使用接口而不是类

    慎用重载

    慎用可变参数

    返回零长度的数组或集合,而不是 null

    将局部变量的作用域最小化

    for-each 循环优于传统的 for 循环

    与传统的 for 循环相比,for-each 循环在简洁性、灵活性以及出错预防性方面都占有绝对优势,并且没有性能惩罚的问题。因此,当可以选择的时候,for-each 循环应该优先于 for 循环。

    基本类型优于装箱基本类型

    在基本类型和装箱基本类型之间有三个主要区别。第一,基本类型只有值,而装箱基本类型则具有与它们的值不同的同一性。换句话说,两个装箱基本类型可以具有相同的值和不同的同一性。第二,基本类型只有函数值,而每个装箱基本类型则都有一个非函数值,除了它对应基本类型的所有函数值外,还有个null。最后一点区别是基本类型通常比装箱基本类型更节省时间和空间。

    如果其他类型更合适,则尽量避免使用字符串

  • 字符串不适合代替其他的值类型

  • 字符串不适合代替枚举类型
  • 字符串不适合代替聚合类型

    通过接口引用对象

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

    异常应该只用于异常的情况下,它们永远不应该用于正常的控制流,也不要编写迫使它们这么做的 API。

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

    在决定使用受检异常或是未受检异常时,主要的原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检异常。通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者将它传播出去。API 的设计者让 API 用户面对受检异常,以此强制用户从这个异常条件中恢复。用户可以忽视这样的强制要求,只需捕获异常并忽略即可,但这往往不是个好办法。

有两种未受检的可抛出结构:运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的可抛出结构。如果程序抛出未受检的异常或错误,往往就属于不可恢复的情形,继续执行下去有害无益。如果程序没有捕捉到这样的可抛出结构,将会导致当前线程中断并出现适当的错误消息。

避免不必要地使用受检异常

如果方法抛出受检异常,调用该方法的代码就必须在一个或者多个 catch 块中处理这些异常,或者它必须声明拋出这些异常,并让它们传播出去。无论使用哪一种方法,都给程序员增添了不可忽视的负担。这种负担在 Java
8 中更严重,因为抛出受检异常的方法不能直接在 Stream 中使用。

如果正确地使用 API 并不能阻止这种异常条件的产生,并且一旦产生异常,使用 API 的程序员可以立即采取有用的动作,这种负担就被认为是正当的。除非这两个条件都成立,否则更适合于使用未受检异常。

优先使用标准的异常

Java 平台类库提供了一组基本的未受检异常,它们满足了绝大多数 API 的异常抛出需求。重用标准的异常可以使 API 更易于学习和使用,并且它们的可读性也会更好。

不要直接重用 Exception、RuntimeException、Throwable 或者 Error。对待这些类要像对待抽象类一样。你无法可靠地测试这些异常,因为它们是可能抛出的其他异常的超类。下表概括了最常见的可重用异常:

IllegalArgumentException 非 null 的参数值不正确
IllegalStateException 不适合方法调用的对象状态
NullPointerException 在禁止使用 null 的情况下参数值为 null
IndexOutOfBoundsException 下标参数值越界
ConcurrentModificationException 在禁止并发修改的情况下,检测到对象的并发修改
UnsupportedOperationException 对象不支持用户请求的方法

选择重用哪一种异常并非总是那么精确,因为上表中的使用场合并不是相互排斥的。但如果没有非常正当的理由,千万不要自己编写异常类。

抛出与抽象对应的异常

如果方法拋出的异常与它所执行的任务没有明显的联系,这种情形将会使人不知所措。当方法传递由低层抽象拋出的异常时,往往会发生这种情况。除了使人感到困惑之外,这也污染了具有实现细节的更高层的 API。为了避免这个问题,更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法称为异常转译(Exception Translation),如下代码所示:

  1. try {
  2. ...
  3. } catch(LowerLevelException e) {
  4. throw new HighLevelException(...);
  5. }

尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但也不能滥用它。如有可能,处理来自低层异常的最好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们抛出异常。比如可以在给低层传递参数前,检查更高层方法的参数的有效性,从而避免低层方法拋出异常。如果无法阻止来自低层的异常,其次的做法是,让更高层来悄悄地处理这些异常,从而将高层方法的调用者与低层的问题隔离开来。

努力使失败保持原子性

一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有失败原子性。有几种途径可以实现这种效果:最简单的莫过于设计一个不可变对象,如果对象是不可变的,失败原子性就是显然的。另一种办法是,调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生。最后一种办法没有那么常用,做法是编写一段恢复代码,由它来拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。这种办法主要用于永久性的(基于磁盘的)数据结构上。

不要忽略异常

不管异常代表了可预见的异常条件,还是编程错误,用空的 catch 块忽略它,都将导致程序在遇到错误的情况下悄然地执行下去。然后,有可能在将来的某个点上,当程序不能再容忍与错误源明显相关的问题时,它就会失败。正确地处理异常能够彻底避免失败。只要将异常传播给外界,至少会导致程序迅速失败,从而保留了有助于调试该失败条件的信息。

同步访问共享的可变数据

当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败和安全性失败。这样的失败是最难调试的。

避免过度同步

为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更通俗地讲,要尽量将同步区域内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。

executor、task 和 stream 优先于线程

并发工具优先于 wait、notify

始终应该使用 wait 循环模式来调用 wait 方法,永远不要在循环之外调用 wait 方法。

线程安全性的文档化

每个类都应该利用字斟句酌的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。synchronized 修饰符与这个文档毫无关系。有条件的线程安全类必须在文档中指明哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁。

慎用延迟初始化

延迟初始化(Lazy Initialization)是指延迟到需要域的值时才将它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。延迟初始化就像一把双刃剑,它降低了初始化类或创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域的哪个部分最终需要初始化、初始化这些域要多少开销,以及每个域多久被访问一次,延迟初始化实际上降低了性能。

如果出于性能的考虑而需要对静态域使用延迟初始化,就使用 Lazy Initialization holder class 模式:

  1. private static class FieldHolder {
  2. static final FieldType field = computeFieldValue();
  3. }
  4. private static FieldType getField() {
  5. return FieldHolder.field;
  6. }

如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式:

  1. private volatile FieldType field;
  2. private FieldType getField() {
  3. FieldType result = field;
  4. if (result == null) {
  5. synchronized(this) {
  6. if (field == null) {
  7. field = result = computeFieldValue();
  8. }
  9. }
  10. }
  11. return result;
  12. }

不要依赖于线程调度器

当有多个线程可以运行时,由线程调度器(Thread Scheduler)决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或性能要求的程序,很有可能都是不可移植的。

其他方法优先于 Java 序列化

序列化是很危险的,应该予以避免。如果是重新设计一个系统,一定要用跨平台的结构化数据表示法代替,如 JSON 或者 protobuf。不要反序列化不被信任的数据,如果必须这么做,就要使用对象的反序列化过滤(ObjectInputFilter),它可以在数据流被反序列化之前,为它们定义一个过滤器。但要注意的是,它并不能确保阻止所有的攻击。

谨慎地使用 Serializable 接口

实现 Serializable 接口而付出的最大代价是,一旦一个类被发布,就大大降低了改变这个类的实现的灵活性。如果一个类实现了 Serializable 接口,它的字节流编码(或者说序列化形式)就变成了它导出的 API 的一部分。一旦这个类被广泛使用,往往必须永远支持这种序列化形式

序列化会使类的演变受到限制,每个可序列化的类都有一个唯一标识号(serialversion UID)与它相关联。如果没有显式指定该标识号,系统就会对这个类的结构运用一个加密的散列函数自动产生该标识号。这个自动产生的值会受到类名称、它所实现的接口的名称,以及所有公有的和受保护的成员的名称所影响。如果你通过任何方式改变了这些信息,自动产生的序列版本 UID 也会发生变化。因此,如果你没有声明一个显式的序列版本 UID,兼容性将会遭到破坏,在运行时导致 InvalidClassException 异常。

实现 Serializable 接口还增加了出现 Bug 和安全漏洞的可能性。通常情况下,对象是利用构造器来创建的;序列化机制是一种语言之外的对象创建机制。无论你是接受了默认的行为,还是覆盖了默认的行为,反序列化机制都是一个隐藏的构造器,具备与其他构造器相同的特点。

考虑使用自定义的序列化形式

当你决定要将一个类做成可序列化的时候,请仔细考虑应该采用什么样的序列化形式。只有当默认的序列化形式能够合理地描述对象的逻辑状态时,才能使用默认的序列化形式,否则就要设计一个自定义的序列化形式,通过它合理地描述对象的状态。

即使你确定了默认的序列化形式是合适的,通常还必须提供一个 readObject 方法以保证约束关系和安全性。不管你选择了哪种序列化形式,都要为自己编写的每个可序列化的类声明一个显式的序列版本 UID。这样可以避免序列版本 UID 成为潜在的不兼容根源。而且这样也会带来小小的性能好处。如果没有提供显式的序列版本 UID,就需要在运行时通过一个高开销的计算过程来产生一个序列版本 UID。

保护性地编写 readObject 方法

当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的对象引用,那么在它的 readObject 方法中,就必须要做保护性拷贝,这是非常重要的。

对于实例控制,枚举类型优先于 readResolve

如果一个单例类的声明中加上了 implements Serializable 的字样,它就不再是一个单例。无论该类使用了默认的序列化还是自定义的序列化都没有关系,也跟它是否提供了显式的 readObject 方法无关。任何 readObject 方法,不管是显式的还是默认的,都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例。

readResolve 特性允许你用 readObject 创建的实例代替另一个实例。对于一个正在被反序列化的对象,如果它的类定义了一个 reaaResolve 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 readResolve 方法就会被调用。然后,该方法返回的对象引用将被返回,取代新建的对象。

对于单例类,通过下面的 readResolve 方法就足以保证它的单例属性:

  1. private Object readResolve() {
  2. return INSTANCE;
  3. }

该方法忽略了被反序列化的对象,只返回该类初始化时创建的那个实例。