Effective Java 学习笔记

1. 创建和销毁对象

1. 1 用静态工厂方法代替构造器

对于类而言,为了让客户端获取它自身的一个实例,最传统的方法就是提供 个公有 的构造器 还有 种方法,也应该在每个程序员的工具箱中占有一席之 类可以提供一个 公有的静态工厂方法( static factory method ),它只是一个返回类的实例的静态方法 下面是个来自 Boolean (基本类型 boolean 装箱类)的简单示例 这个方法将 boolean 基本类型值转换成了 Boolean 对象引用:

java 复制代码
public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}

优点

  1. **静态工厂方法与构造器不同的第一大优势在于,它们有名称。**如果构造器的参数本 身没有确切地描述正被返回的对象,那么具有适当名称的静态工厂会更容易使用,产生的 客户端代码也更易于阅读 例如,构造器 BigInteger (int , int, Random )返回的 BigInteger 可能为素数,如果用名为 BigInteger.probablePrime 的静态工厂方法 来表示,显然更为清楚。
  2. **静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。 **这使得不可变类可以使用预先构建好的 例,或者将 构建好的实例缓存起来, 进行重复利用,从而避免创建不必要的重复对象 Boolean. valueOf(boolean)方法说明了这项技术 它从来不创建对象 这种方法类似于享元 (Flyweight )模式,如果程序经常请求 建相同的对象,并且创建对象的代价 很高,则这项技术可以极大地提升性能。
  3. **静态工厂方法比构造器的第三大优势是它们可以返回任何子类型的对象,增加了返回对象类型的灵活性。**通过这种方式,API可以返回对象而不公开其类,实现类隐藏使API简洁。这种技术适用于基于接口的框架,接口作为静态工厂方法的返回类型。
  4. **静态工厂的第四大优势在于,返回对象的类可根据调用时的参数值变化,只要是返回类型的子类型即可,且随版本变化。**比如,EnumSet通过静态工厂方法,根据枚举元素数量返回不同子类(RegularEnumSet或JumboEnumSet)。这种实现对客户端透明,允许在未来版本中优化或替换子类,而不影响客户端使用。
  5. **静态工厂的第五大优势是返回对象的类在编写时可以不存在,这为服务提供者框架提供了基础,如JDBC。**服务提供者框架包括服务接口、提供者注册API和服务访问API,后者是灵活的静态工厂。服务访问API让客户端选择实现,或返回默认实现。可选的服务提供者接口表示生成服务接口实例的工厂对象。JDBC的Connection是服务接口的一部分,DriverManager.registerDriver是提供者注册API,DriverManager.getConnection是服务访问API,Driver是服务提供者接口。Java 6起,java.util.ServiceLoader提供了通用的服务提供者框架,不再需要自行编写。

缺点

  1. **静态工厂方法的主要缺点在于,类如果不含公有的或者受保护的构造器,就不能被子类化。**例如,要想将Collections Framework中的任何便利的实现类子类化,这是不可能的。但是这样也许会因祸得福,因为它鼓励程序员使用复合(composition),而不是继承,这正是不可变类型所需要的。
  2. **静态工厂方法的第二个缺点在于,程序员很难发现它们。**在API文档中,它们没有像构造器那样在API文档中明确标识出来,因此,对于提供了静态工厂方法而不是构造器的类来说,要想查明如何实例化一个类是非常困难的。Javadoc工具总有一天会注意到静态工厂方法。同时,通过在类或者接口注释中关注静态工厂,并遵守标准的命名习惯,也可以弥补这一劣势。

1. 2 遇到多个构造器参数时要考虑使用构建器

静态工厂和构造器有个共同的局限性:它们都不能很好地扩展到大量的可选参数。比如用一个类表示包装食品外面显示的营养成分标签。这些标签中有几个域是必需的:每份的含量、每罐的含量以及每份的卡路里。还有超过20个的可选域:总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠,等等。大多数产品在某几个可选域中都会有非零的值。

重叠构造器模式

重叠构造器(telescoping constructor)模式,在这种模式下,提供的第一个构造器只有必要的参数,第二个构造器有一个可选参数,第三个构造器有两个可选参数,依此类推,最后一个构造器包含所有可选的参数。下面有个示例,为了简单起见,它只显示四个可选域:

例如

java 复制代码
public class NutritionFacts {
    private final int servingSize; // (mL)required
    private final int servings;// (per container)required
    private final int calories;// (per serving)optional
    private final int fat; //(g/serving)optional
    private final int sodium; //(mg/serving) optional
    private final int carbohydrate; //(g/serving) optional

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

当你想要创建实例的时候,就利用参数列表最短的构造器,但该列表中包含了要设置的所有参数

java 复制代码
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35 , 27); 

当有许多参数的时候,客户端代码会很难编写并且仍然较难以阅读。如果读者想知道那些值是什么意思,必须很仔细地数着这些参数来探个究竟。一长串类型相同的参数会导致一些微妙的错误。如果客户端不小心颠倒了其中两个参数的顺序,编译器也不会出错,但是程序在运行时会出现错误的行为。

Java Beans 模式

JavaBeans 模式,在这 种模式下,先调用一个无参构造器来创建对象,然后再调用 sette 方法来设置每个必要的参数,以及每个相关的可选参数:

java 复制代码
public class NutritionFacts {
    private int servingSize = -1;
    private int servings = -1;
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;
    
    public NutritionFacts() {}
    // Setters
    public void setServingSize(int servingSize) {this.servingSize = servingSize;}
    public void setServings(int servings) {this.servings = servings;}
    public void setCalories(int calories) {this.calories = calories;}
    public void setFat(int fat) {this.fat = fat;}
    public void setSodium(int sodium) {this.sodium = sodium;}
    public void setCarbohydrate(int carbohydrate) {this.carbohydrate = carbohydrate;}
}

这种模式弥补了重叠构造器模式的不足 说得明白一点,就是创建实例很容易,这样 产生的代码读起来也很容易:

java 复制代码
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

遗憾的是,JavaBeans模式自身有着很严重的缺点。因为构造过程被分到了几个调用中在构造过程中 JavaBean 可能处于不一致的状态。类无法仅仅通过检验构造器参数的有效性来保证一致性。试图使用处于不一致状态的对象将会导致失败,这种失败与包含错误的代码大相径庭,因此调试起来十分困难。与此相关的另一点不足在于,JavaBeans模式使得把类做成不可变的可能性不复存在,这就需要程序员付出额外的努力来确保它的线程安全。

建造者模式

幸运的是,还有第三种替代方法,它既能保证像重叠构造器模式那样的安全性,也能保证像JavaBeans模式那么好的可读性。这就是建造者(Builder)模式的一种形式。它不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造器(或者静态工厂),得到一个builder 对象。然后客户端在 builder 对象上调用类似于 setter 的方法,来设置每个相关的可选参数。最后,客户端调用无参的build方法来生成通常是不可变的对象。这个builder通常是它构建的类的静态成员类。下面就是它的示例:

java 复制代码
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        private final int servingSize;
        private final int servings;
        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder calories(int calories) {
            this.calories = calories;
            return this;
        }

        public Builder fat(int fat) {
            this.fat = fat;
            return this;
        }

        public Builder sodium(int sodium) {
            this.sodium = sodium;
            return this;
        }

        public Builder carbohydrate(int carbohydrate) {
            this.carbohydrate = carbohydrate;
            return this;
        }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servingSize;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

注意 NutritionFacts是不可变的,所有的默认参数值都单独放在一个地方。builder的设值方法返回builder本身,以便把调用链接起来,得到一个流式的API。下面就是其客户端代码:

java 复制代码
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
        .calories(100)
        .sodium(35)
        .carbohydrate(27)
        .build();

这样的客户端代码很容易编写,更为重要的是易于阅读。Builder 模式模拟了具名的可选参数,就像 Python和Scala 编程语言中的一样。

Builder模式非常灵活,可以用一个builder创建多个对象,并在创建过程中调整参数,自动填充某些字段如序列号。然而,Builder模式的缺点是需要先创建构建器,这在注重性能的情况下可能成为问题。此外,Builder模式比重叠构造器更冗长,适用于参数较多的情况(如4个或更多)。相比之下,Builder模式使代码更易读、易写,也比JavaBeans更安全。总之,如果类需要多个参数,尤其是大多数参数可选或类型相同,Builder模式是个不错的选择。

1.3 用私有构造器或者枚举类型强化 Singleton 属性

Singleton 是指仅仅被实例化一次的类。Singleton 通常被用来代表某个无状态的对象,如函数,或者那些本质上唯一的系统组件。使类成为Singleton会使它的客户端测试变得十分困难,因为不可能给Singleton 替换模拟实现,除非实现一个充当其类型的接口。

实现Singleton有两种常见的方法。这两种方法都要保持构造器为私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。

方法一

在第一种方法中,公有静态成员是个final 域:

java 复制代码
public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
}

私有构造器仅被调用一次,用来实例化共有的静态final域。由于缺少公有的或者受保护的构造器,所以保证了Elvis的全局唯一性,一旦Elvis 类被实例化,将只会存在一个Elvis 实例,不多也不少。客户端的任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助Accessible0bject.setaccessible方法通过反射机制调用私有构造器。如果需要抵御这种攻击,可以修改构造器让它在被要求创建第二个实例的时候抛出异常。

方法二

在实现 Singleton 的第二种方法中,公有的成员是个静态工厂方法:

java 复制代码
public class Elvis {
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
    public static Elvis getInstance() {
        return INSTANCE;
    }
}

静态工厂方法的优势之一在于,它提供了灵活性:在不改变其API的前提下,我们可以改变该类是否应该为 Singleton的想法。工厂方法返回该类的唯一实例,但是,它很容易被修改,比如改成为每个调用该方法的线程返回一个唯一的实例。第二个优势是,如果应用程序需要,可以编写一个泛型 Singleton 工厂(generic singletonfactory)(详见第30条)。使用静态工厂的最后一个优势是,可以通过方法引用(methodreference)作为提供者,比如Elvis::instance 就是一个 Supplier。除非满足以上任意一种优势,否则还是优先考虑公有域(public-field)的方法。

为了将利用上述方法实现的 Singleton类变成是可序列化的(Serializable)(详见第12章),仅仅在声明中加上implements Serializable是不够的。为了维护并保证 Singleton,必须声明所有实例域都是瞬时(transient)的,并提供一个readResolve方法(详见第89条)。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例,比如,在我们的例子中,会导致"假冒的Elvis"。为了防止发生这种情况,要在E1vis 类中加入如下readResolve方法。

这种方法在功能上与公有域方法相似,但更加简洁,无偿地提供了序列化机制,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现 Singleton 的最佳方法。注意,如果 Singleton必须扩展一个超类,而不是扩展Enum的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。

1.4 通过私有构造器强化不可实例化的能力

有时需要编写只包含静态方法和静态域的类,这些类虽然有时被滥用,但也有其用途,比如java.lang.Math或java.util.Arrays,用于组织基本类型或数组相关方法。它们也可以像java.util.Collections一样组织特定接口的静态方法,包括工厂方法。Java 8后,这些方法可以放在接口中,但工具类不应被实例化,因为实例化没有意义。缺少显式构造器时,编译器会提供公有无参构造器,使类可能被无意实例化。将类做成抽象类并不能防止实例化,反而可能误导用户。确保类不可被实例化的简单方法是提供一个私有构造器。

java 复制代码
// 不可被实例化的工具类
public class UtilityClass {
    private UtilityClass() {
        throw new AssertionError();
    }
}

这种习惯用法也有副作用,它使得 个类不能被子类化 所有的构造器都必须显式或 隐式地调用超类( superclass )构造器,在这种情形下,子类就没有可访问的超类构造器可调用了。

1.5 优先考虑依赖注人来引用资源

静态工具类

java 复制代码
// Inappropriate use of static utility - inflexible & untestable!
public class SpellChecker {
    private static final Lexicon dictionary = ...;
 
    private SpellChecker() {} // Noninstantiable
 
    public static boolean isValid(String word) { ... }
    public static List<String> suggestions(String typo) { ... }
}

Singleton

java 复制代码
// Inappropriate use of singleton - inflexible & untestable!
public class SpellChecker {
    private final Lexicon dictionary = ...;
 
    private SpellChecker(...) {}
    public static INSTANCE = new SpellChecker(...);
 
    public boolean isValid(String word) { ... }
    public List<String> suggestions(String typo) { ... }
}

然而,这两种方法都不令人满意,因为它们都假设只有一本字典值得使用。实际上,每种语言都拥有自己的特殊字典,专门用于特定的词汇表。此外,使用专门的字典进行测试也是一种可行的方法。想当然地认为只需一个字典就足够了,这是一种过于乐观的假设。

可以通过将dictionary属性设置为非final,并添加一种方法来更改现有拼写检查器中的字典,从而使拼写检查器支持多个字典。然而,在并发环境中,这种方法既笨拙又容易出错,因此不切实际。对于那些需要底层资源参数化的类,静态实用类和单例模式都不合适。

所需的是能够支持类的多个实例(在我们的示例中即SpellChecker),每个实例都使用客户端所期望的资源(在我们的例子中是dictionary)。满足这一需求的简单模式是在创建新实例时将资源传递到构造方法中。这就是依赖注入(dependency injection)的一种形式:字典是拼写检查器的一个依赖,当创建拼写检查器时,将字典注入到拼写检查器中。

依赖注入

java 复制代码
// Dependency injection provides flexibility and testability
public class SpellChecker {
    private final Lexicon dictionary;
 
    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }
 
    public boolean isValid(String word) { ... }
    public List<String> suggestions(String typo) { ... }
}

依赖注入提升了灵活性和可测试性,但会使大型项目变得混乱。这种混乱可通过依赖注入框架如Dagger、Guice或Spring解决。这些框架的用法超出本书讨论范围,但手动依赖注入的API通常适用于这些框架。

总之,不要用Singleton和静态工具类实现依赖底层资源的类,也不要直接创建这些资源。应将资源或工厂传给构造器、静态工厂或构建器来创建类。使用依赖注入,能极大提升类的灵活性、重用性和可测试性。

1.6 避免创建不必要的对象

通常最好重用对象,而不是每次需要时创建新对象。重用更快且更常见。不可变对象总是可以重用。例如,以下语句每次执行时创建不必要的新字符串实例:

java 复制代码
String s = new String("bikini"); // DON'T DO THIS!

改为:

java 复制代码
String s = "bikini";

这样只创建一个字符串实例,并在同一虚拟机中重用。

对于不可变类,优先使用静态工厂方法而非构造器,避免创建不必要的对象。例如,使用Boolean.valueOf(String)而不是构造器Boolean(String),后者在Java 9中已废弃。静态工厂方法不会每次调用都创建新对象。

此外,可重用已知不会被修改的可变对象。对于创建成本高的对象,应缓存重用,避免不必要的开销。

1.7 消除过期的对象引用

如果你是从 C++ 之类的语言过渡到 Java 来的,你一定会觉得编程简单了许多,因为 Java 自带垃圾回收机制。这个过程看起来有些很神奇,而且很容易给你造成一个错觉,那就是不需要再关心内存的使用情况了。

java 复制代码
// Can you spot the "memory leak"?
public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    
    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }
    
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }
    
    /**
    * Ensure space for at least one more element, roughly
    * doubling the capacity each time the array needs to grow.
    */
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}

上面这个程序,乍看没有问题,但是当你仔细观察,就会发现,它有一个潜在的问题,那就是 pop 方法,stack pop 出一个对象后,elements 仍然持有该对象的引用。也就是说这些pop的对象不会被垃圾回收,因为stack维护了对这些对象的过期引用(obsolete references)。

垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。如果无意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。即使只有少数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。

优化:

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

程序员应警惕内存泄漏,特别是当类管理内存时。释放元素后,应清空其引用。内存泄漏常见来源包括缓存和回调。

  1. 缓存 :对象放入缓存后易被遗忘,导致内存泄漏。解决方法:
    • 使用WeakHashMap,当外部引用不存在时自动删除缓存项。
    • 定期清除过期项,可用后台线程或在添加新条目时清理,LinkedHashMapremoveEldestEntry方法适用于此。
  2. 回调 :若客户端注册回调但未取消,会积累泄漏。用弱引用(如WeakHashMap)保存回调。

内存泄漏难以察觉,通常需要代码审查或Heap剖析工具检测。预防和及时处理内存泄漏尤为重要。

1.8 避免使用终结方法和清除方法

Java语言规范不保证终结方法或清除方法会被及时或执行。程序终止时,某些无法访问的对象的终结方法可能未被执行。因此,切勿依赖终结方法或清除方法来更新重要的持久状态或释放共享资源。System.gcSystem.runFinalization增加了终结方法执行的机会,但不保证其执行System.runFinalizersOnExitRuntime.runFinalizersOnExit方法有严重缺陷,已被废弃。

使用终结方法的另一个问题是未捕获的异常会导致对象处于损坏状态 ,如果其他线程使用该对象,可能导致不确定行为。正常情况下,未捕获的异常会终止线程并打印栈轨迹,但在终结方法中发生的异常不会打印警告。清除方法没有这个问题,因为类库控制其线程。

使用终结方法和清除方法会严重影响性能 。在我的机器上,创建一个简单的AutoCloseable对象并用try-with-resources关闭,再让垃圾回收器回收,耗时约12ns。而增加一个终结方法后,耗时增至550ns,即慢了约50倍。清除方法稍快些,每个实例约500ns,但作为安全网使用时,耗时约66ns,比不使用时多花了5倍时间。

终结方法还存在严重的安全问题 :可能导致终结方法攻击。如果构造器或序列化方法(readObject和readResolve)抛出异常,恶意子类的终结方法可以在未完全构造的对象上运行,并阻止其被回收。这种攻击可以导致在异常对象上调用任何不应被调用的方法。final类不受此攻击影响,因为无法编写其子类。为防止非final类受攻击,应编写一个空的finalize方法。

总而言之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用清除方法,对于在Java9之前的发行版本,则尽量不要使用终结方法。若使用了终结方法或者清除方法,则要注意它的不确定性和性能后果。

1.9 try-with-resources 优先 try-finally

Java 类库中有许多需要通过调用 close 方法手动关闭的资源,例如 InputStreamOutputStreamjava.sql.Connection。客户端经常忽略关闭资源,导致严重的性能问题。虽然许多资源使用终结方法作为安全网,但效果不理想。

经验表明,try-finally 语句是确保资源适时关闭的最佳方法,即使发生异常或返回也一样:

java 复制代码
// try-finally - No longer the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try {
        return br.readLine();
    } finally {
        br.close();
    }
}

这种方法虽然有效,但添加第二个资源时会变得复杂和混乱。例如:

java 复制代码
static void copy(String src, String dest) throws IOException {
    InputStream in = new FileInputStream(src);
    OutputStream out = new FileOutputStream(dest);
    try {
        byte[] buf = new byte[1024];
        int n;
        while ((n = in.read(buf)) >= 0) {
            out.write(buf, 0, n);
        }
    } finally {
        try {
            in.close();
        } finally {
            out.close();
        }
    }
}

这种嵌套 try-finally 的代码显得复杂且难以维护。即便用 try-finally 语句正确地关闭资源,如前两段代码范例所示,它也存在一些不足。因为在 try 块和 finally 块中的代码都可能抛出异常。例如,在 firstLineOfFile 方法中,如果底层设备出错,调用 readLine 会抛出异常,而调用 close 也会因同样原因抛出异常。在这种情况下,第二个异常会完全掩盖第一个异常,在异常堆栈轨迹中没有第一个异常的记录,这使得调试变得非常复杂,因为通常需要看到第一个异常才能诊断问题。虽然可以通过编写代码来保留第一个异常,但实现起来非常繁琐,实际上几乎没有人会这样做。

Java 7 引入了 try-with-resources 语句,这解决了上述所有问题。要使用这个构造的资源,必须实现 AutoCloseable 接口,该接口包含一个 void close() 方法。许多 Java 类库和第三方类库中的类和接口现在都实现或扩展了 AutoCloseable 接口。如果编写了一个类,表示必须被关闭的资源,那么这个类也应该实现 AutoCloseable

以下是使用 try-with-resources 的第一个范例:

java 复制代码
// try-with-resources - the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(path))) {
        return br.readLine();
    }
}

以下是使用 try-with-resources 的第二个范例:

java 复制代码
// try-with-resources on multiple resources - short and sweet
static void copy(String src, String dst) throws IOException {
    try (InputStream in = new FileInputStream(src);
        OutputStream out = new FileOutputStream(dst)) {
        byte[] buf = new byte[1024];
        int n;
        while ((n = in.read(buf)) >= 0) {
            out.write(buf, 0, n);
        }
    }
}

try-with-resources 语句不仅简洁,还能确保所有资源被正确关闭,即使在发生异常时也不例外。

结论很明显:在处理必须关闭的资源时,始终要优先考虑用try-with-resources,而不是用try-finally。这样得到的代码将更加简洁、清晰,产生的异常也更有价值。有了try-with-resources语句,在使用必须关闭的资源时,就能更轻松地正确编写代码了。实践证明这个用try-finally是不可能做到的。

2. 对于所有对象都通用的方法

尽管 Object 是一个具体类,但它主要是为扩展设计的。它的非 final方法(equalshashCodetoStringclonefinalize)都有明确的通用约定,因为它们是要被覆盖的。任何类在覆盖这些方法时,都必须遵守这些约定,否则依赖这些约定的类(例如 HashMapHashSet)将无法正常运作。

2.1 覆盖 equals 时请遵守通用规定

覆盖 equals 方法看似简单,但不正确的实现会导致严重错误。避免这些问题的最佳方法是不要覆盖 equals 方法,在这种情况下,类的每个实例都只与自身相等。如果满足以下任一条件,可以使用 Object 提供的 equals 实现:

  1. 类的每个实例本质上都是唯一的,例如 Thread
  2. 类不需要提供"逻辑相等"的测试功能,例如 java.util.regex.Pattern
  3. 超类已经覆盖了 equals 方法,且其行为适合该类,例如 Set 实现从 AbstractSet 继承了 equals
  4. 类是私有的或包级私有的,确定 equals 方法永远不会被调用。

如果非常担心风险,可以覆盖 equals 方法来确保它不会被意外调用:

java 复制代码
@Override
public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

在覆盖 equals方法的时候,必须要遵守它的通用约定 下面是约定的内容,来自 Object的规范:
equals 方法实现了等价关系,具有以下属性:

  1. 自反性:任何非 null 的引用值 x,x.equals(x) 必须返回 true。
  2. 对称性:对于任何非 null 的引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 必须返回 true。
  3. 传递性:对于任何非 null 的引用值 x、y 和 z,如果 x.equals(y) 返回 true,且 y.equals(z) 也返回 true,那么 x.equals(z) 必须返回 true。
  4. 一致性:对于任何非 null 的引用值 x 和 y,只要对象中的信息未被修改,多次调用 x.equals(y) 会一致地返回 true 或 false。
  5. 非空性:对于任何非 null 的引用值 x,x.equals(null) 必须返回 false。

在覆盖 equals方法时,务必同时覆盖 hashCode,以遵循约定。equals 方法不应过于智能,简单地测试域值是否相等即可,过度追求复杂的等价关系容易导致问题。例如,File 类没有将符号链接视为相等对象,这是一种良好的设计。此外,equals 方法的参数类型必须为 Object,使用其他类型会导致不可预期的行为。

总而言之,不要轻易覆盖 equals 方法,除非迫不得已 因为在许多情况下,从 Object 处继 实现正是你想要 如果覆盖 equals,一定要比较这个类的所有关键域,并且查看它 是否遵守 equals 合约的所有五个条款。

2.2 覆盖 equals 时总要覆盖 hashCode

在每个覆盖了 equals 方法的类中,必须同时覆盖 hashCode 方法,以确保与基于散列的集合(如 HashMapHashSet)正常运作。根据 Object 规范:

  1. 一致性 :在对象未被修改的情况下,多次调用 hashCode 方法应返回相同的值。
  2. 相等性 :如果两个对象相等(通过 equals 方法),则它们的 hashCode 方法必须返回相同的整数。
  3. 不等性 :如果两个对象不相等,hashCode 不必返回不同的结果,但返回不同的结果可以提高散列性能。

总而言之,每当覆盖equals方法时都必须覆盖hashCode,否则程序将无法正确运行。hashCode方法必须遵守Object 规定的通用约定,并且必须完成一定的工作,将不相等的散列码分配给不相等的实例。这个很容易实现,但是如果不想那么费力,也可以使用前文建议的解决方法。如第10条所述,AutoValue框架提供了很好的替代方法,可以不必手工编写equalshashCode方法,并且现在的集成开发环境IDE也提供了类似的部分功能。

2.3 始终要覆盖 toString

尽管 Object 提供了 toString 方法的默认实现,但返回的字符串通常不符合用户的期望。它包含类名和散列码,如 PhoneNumber@163b91,虽然简洁,但缺乏信息性。toString 的通用约定建议返回一个简洁、信息丰富且易读的字符串,鼓励所有子类覆盖此方法。

虽然遵守 toString 的约定不如 equalshashCode 重要,但良好的实现可以提升类的可用性并简化调试。当对象被传递给 printlnprintf或者在错误消息中记录时,toString 方法会自动调用。因此,覆盖 toString 可以确保提供有用的信息,避免在调试或日志记录中出现毫无价值的输出。

在第10条中提到的 Google 的 AutoValue 工具可以自动生成 toString 方法,大多数集成开发环境(IDE)也提供类似功能。这些生成的方法能够显示每个域的内容,但往往缺乏特定于该类的语义。例如,对于 PhoneNumber 类,使用标准的字符串表示法更合适,而 Potion 类则适合使用自动生成的 toString方法。

总之,建议在每个可实例化的类中覆盖 ObjecttoString方法,除非超类已经实现。这样可以提升类的可用性和调试性,toString 方法应返回一个格式美观、简洁且有用的对象描述。

2.4 谨慎的覆盖 clone

Cloneable 接口的设计初衷是作为对象的一个混入接口(mixin interface),标识这些对象可以被克隆。然而,它未能有效实现这个目的。主要问题在于它缺少 clone 方法,而 Object 类的 clone 方法是受保护的。如果不借助反射,仅因为对象实现了 Cloneable,就无法调用 clone 方法。而即使使用反射调用,也可能会失败,因为无法保证对象一定具有可访问的 clone 方法。

虽然 Cloneable 接口没有定义任何方法,但它影响了 Object 中受保护的 clone 方法的行为:如果一个类实现了 CloneableObjectclone 方法将返回该对象的逐域拷贝,否则抛出 CloneNotSupportedException 异常。这是一种非典型的接口用法,不值得效仿。通常,实现接口是为了表明类可以为客户提供某些功能,但对于 Cloneable 接口,它改变了超类中受保护方法的行为。
如何实现行为良好的 clone 方法?

实现一个行为良好的 clone 方法需要遵守一定的协议,这些协议并没有在文档中明确说明,但实现 Cloneable 接口的类通常需要提供一个功能适当的公有 clone 方法。为此,类及其所有超类必须遵守复杂的协议。这种协议是语言之外的机制:它可以在不调用构造函数的情况下创建对象。
clone 方法的通用约定:
clone 方法的通用约定来自 Object 规范:

  • 创建并返回该对象的一个拷贝。
  • x.clone() != x 表达式应返回 true
  • x.clone().getClass() == x.getClass() 表达式应返回 true,但这不是绝对要求。
  • 通常情况下,x.clone().equals(x) 表达式应返回 true,但这也不是绝对要求。

根据约定,clone 方法返回的对象应该通过调用 super.clone 获得。如果类及其超类(Object 除外)遵守这一约定,那么 x.clone().getClass() == x.getClass() 应成立。

尽管 Cloneable 接口和 clone 方法存在诸多缺陷和复杂性,仍然被广泛使用。通过了解并遵循其约定,可以实现一个行为良好的 clone 方法,但也需要考虑其他替代方法,例如复制构造函数或静态工厂方法,以实现对象的拷贝。

2.5 考虑实现 Comparable 接口

与本章中讨论的其他方法不同,compareTo方法并没有在Object类中声明。相反它是Comparable接口中唯一的方法。compareTo方法不但允许进行简单的等同性比较而且允许执行顺序比较,除此之外,它与Objectequals方法具有相似的特征,它还是个泛型(generic)。类实现了Comparable接口,就表明它的实例具有内在的排序关系(naturalordering)。为实现Comparable接口的对象数组进行排序就这么简单:

java 复制代码
Arrays.sort(a);

对存储在集合中的 Comparable对象进行搜索、计算极限值以及自动维护也同样简单。例如,下面的程序依赖于实现了Comparable接口的String类,它去掉了命令行参数列表中的重复参数,并按字母顺序打印出来:

java 复制代码
public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet<>();
        Collections.addAll(s, args);
        System.out.println(s);
    }
}

**compareTo**** 方法的通用约定:**
compareTo 方法用于将当前对象与指定对象进行比较,返回一个负整数、零或正整数,分别表示当前对象小于、等于或大于指定对象。如果无法与指定对象进行比较,则抛出 ClassCastException 异常。以下是其通用约定的详细说明:

  1. 符号函数sgn(expression) 表示数学中的 signum 函数,根据表达式的值为负、零和正,分别返回 -1、0 或 1。
  2. 对称性
    • 确保对于所有的 xysgn(x.compareTo(y)) == -sgn(y.compareTo(x))
    • 这意味着当且仅当 y.compareTo(x) 抛出异常时,x.compareTo(y) 也必须抛出异常。
  3. 传递性
    • 确保比较关系是可传递的:(x.compareTo(y) > 0 && y.compareTo(z) > 0) 暗示 x.compareTo(z) > 0
  4. 一致性
    • 确保 x.compareTo(y) == 0 暗示所有的 z 都满足 sgn(x.compareTo(z)) == sgn(y.compareTo(z))
  5. 与 equals 方法的一致性
    • 强烈建议 (x.compareTo(y) == 0) == (x.equals(y)),但这并非绝对必要。
    • 如果实现 Comparable 接口的类违反了这个条件,应明确说明。推荐使用这样的说明:"注意:该类具有内在的排序功能,但是与 equals 不一致。"

3. 类和接口

3.1 使类和成员的可访问性最小化

概述

第15条强调应尽量将类和成员的访问级别设为最小化,以减少潜在的错误并提高代码的可维护性和安全性。访问控制是指通过 publicprotecteddefault(无修饰符)和 private 修饰符来限制类、方法、字段和成员变量的访问范围。

原则

  1. 将所有类和成员的可访问性设为最小化
    • 私有成员 :将类的所有成员(字段、方法、嵌套类等)设置为 private,除非确实需要更高的可见性。
    • 包级私有成员:如果需要在同一个包内访问,则可以使用包级私有(无修饰符)。
    • 受保护成员 :对于需要在子类中访问的成员,可以使用 protected
    • 公共成员 :只有在需要从所有其他类中访问时,才将成员声明为 public
  2. 公开API
    • 公共API应该是尽可能小且稳定的。
    • 避免公开不必要的内部实现细节,这样可以减少因为内部实现变化导致的兼容性问题。
  3. 不公开字段
    • 避免将类的字段声明为 public,即使是 final 的常量字段也应谨慎处理,因为公开字段会暴露实现细节,并且无法控制对它们的访问。
  4. 访问方法
    • 使用访问方法(getters 和 setters)来提供对私有字段的访问控制。通过这种方式,可以在不修改外部代码的情况下改变字段的实现。
  5. 嵌套类的访问性
    • 如果一个嵌套类(包括静态和非静态)只在其外围类中使用,应将其声明为私有。
    • 可以考虑将嵌套类移到其自己的顶层类中,并通过包级私有访问进行控制。

好处

  1. 提高模块化:最小化访问权限有助于将类和成员限制在一个模块内,从而减少模块之间的耦合,增强模块的独立性和可维护性。
  2. 提高安全性:限制访问权限可以减少攻击面,防止类被不正当地访问或修改。
  3. 增强灵活性:当实现细节不暴露时,可以在不影响外部代码的情况下更改内部实现。
  4. 简化调试:较小的访问范围使得调试和理解代码变得更容易,因为减少了潜在的交互和依赖关系。

实践建议

  1. 默认使用私有访问修饰符 :编写新类和成员时,默认使用 private 修饰符,然后根据需要逐步放宽访问权限。
  2. 避免 public :如果类不需要在包外使用,应避免将其声明为 public
  3. **慎用 **protectedprotected 修饰符允许包内和子类访问,但这意味着可能会意外地暴露给比预期更多的类。
  4. 减少包级私有的使用:包级私有访问是默认访问修饰符,虽然在包内使用方便,但应确保只有真正需要访问的类才能看到这些成员。

通过将类和成员的访问级别设为最小化,可以提高代码的安全性、可维护性和灵活性。默认使用最严格的访问修饰符,并在必要时逐步放宽,可以帮助开发者更好地控制类的访问范围和依赖关系,进而构建出更为健壮的系统。

3.2 要在共有类而非共有域中使用访问方法

概述

在设计公有类时,应避免使用公有域(public fields)。相反,应该使用私有域(private fields)并通过公有的访问方法(getters 和 setters)来控制对这些域的访问。这样可以保持类的封装性,提供更好的灵活性和安全性。

详细说明

  1. 封装性
    • 使用访问方法可以隐藏类的内部表示,只暴露必要的部分,从而增强封装性。封装性有助于维护类的不变性,防止外部代码直接修改类的状态。
  2. 控制访问
    • 通过访问方法,可以控制对字段的访问和修改。这不仅可以验证数据,还可以在需要时触发相关的业务逻辑。
    • 例如,可以在设置某个值时,检查其有效性或在获取某个值时进行懒加载。
  3. 提高灵活性
    • 如果直接使用公有域,任何对字段的访问和修改都会直接影响类的实现。使用访问方法可以在不影响外部代码的情况下修改内部实现细节。
    • 例如,可以将字段从一个类型更改为另一个类型,或改变其存储方式,而不需要修改使用该类的代码。
  4. 易于调试和维护
    • 使用访问方法可以在访问和修改字段时添加日志、断点等,有助于调试和维护代码。
    • 可以在方法中添加调试信息,以便在出现问题时更容易跟踪和诊断。

实践建议

  1. 私有域与公有访问方法
    • 将类的所有域声明为私有,并提供公有的访问方法(getters 和 setters)来访问这些域。
  2. 只读字段
    • 对于只读字段,只提供 getter 方法,不提供 setter 方法,以确保字段的不可变性。
    • 例如,不可变类通常只包含 getter 方法,确保类的实例一旦创建,其状态就不能改变。
  3. 延迟初始化
    • 在访问方法中,可以实现延迟初始化(lazy initialization),以优化性能和资源使用。
  4. 文档和注释
    • 对于每个访问方法,添加适当的文档和注释,说明方法的用途和使用注意事项,增强代码的可读性和可维护性。

代码示例

java 复制代码
public class Person {
    // Private fields
    private String name;
    private int age;
    // Public constructor
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    // Public getter for name
    public String getName() {return name;}
    // Public setter for name
    public void setName(String name) {this.name = name;}
    // Public getter for age
    public int getAge() {return age;}
    // Public setter for age
    public void setAge(int age) {this.age = age;}
}

在这个示例中,Person 类的 nameage 字段是私有的,并通过公有的访问方法来访问和修改。这种设计方式提供了封装性、灵活性和安全性。

总结

在公有类中使用私有域和公有的访问方法,而不是直接使用公有域,可以增强类的封装性、灵活性和可维护性。通过这种设计,可以更好地控制对类的访问和修改,提高代码的健壮性和安全性。

3.3 使可变性最小化

不可变类是指其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期(lifetime)内固定不变。Java平台类库中包含许多不可变的类,其中有String、基本类型的包装类、BigInteger和BigDecimal。存在不可变的类有许多理由:不可变的类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。

为了使类成为不可变,要遵循下面五条规则:

  1. 不要提供任何会修改对象状态的方法(也称为设值方法)。
  2. 保证类不会被扩展。这样可以防止粗心或者恶意的子类假装对象的状态已经改变从而破坏该类的不可变行为。为了防止子类化,一般做法是声明这个类成为final的,但是后面我们还会讨论到其他的做法。
  3. 声明所有的域都是 fina 的。通过系统的强制方式可以清楚地表明你的意图。而且,如果一个指向新创建实例的引用在缺乏同步机制的情况下,从一个线程被传递到另一个线程就必须确保正确的行为。
  4. 声明所有的域都为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final域,只要这些域包含基本类型的值或者指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法再改变内部的表示法。
  5. 确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法(accessor)中返回该对象引用。在构造器、访问方法和readObject方法中请使用保护性拷贝(defensive copy)技术。

避免为每个 get 方法编写对应的 set 方法。除非有充分的理由让类变成可变的,否则应使类不可变。不可变类有很多优点,唯一的缺点是某些情况下可能存在性能问题。小的值对象如 PhoneNumberComplex 应该是不可变的;较大的值对象如 StringBigInteger 也应考虑不可变性。只有在确认需要达到特定性能要求时,才为不可变类提供可变配套类。对于无法实现不可变性的类,应尽量限制其可变性。减少对象的可变状态可以简化其行为分析并减少出错概率。因此,除非有充分理由,否则应使每个域都是 private final

3.4 复合优先于继承

简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性(fragility)。为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。

3.5 要么设计继承并提供文档说明,要么禁止继承

设计继承并提供文档说明

如果你允许类被继承,应当确保:

明确文档说明

  • 合同明确:清楚地记录子类可以重写哪些方法以及如何重写。明确哪些方法是抽象的、需要实现的,哪些是钩子方法(即可以重写但有默认实现的)。
  • 继承行为说明:记录子类继承时的行为和期望,包括方法调用的顺序和状态变化。这有助于子类开发者理解如何正确继承并实现功能。

考虑继承影响

  • 安全性检查:确保类的内部状态在子类重写方法时不被破坏。父类的方法应当在调用子类重写方法时保持一致性。
  • 设计可扩展性:考虑未来的扩展,设计类时要灵活,使得子类能够有效地扩展父类的功能而不破坏父类的行为。

禁止继承

如果不希望类被继承,应当明确禁止继承。这可以通过以下方式实现:

  1. 使用 final 修饰符
  • 类级别 :将类声明为 final,使其无法被继承。
java 复制代码
public final class MyClass {
    // class implementation
}
  • 方法级别 :将方法声明为 final,使其无法被子类重写。
java 复制代码
public class MyClass {
    public final void myMethod() {
        // method implementation
    }
}
  1. 私有构造器
  • 单例模式:通过私有构造器和工厂方法来实现不可继承的类。
java 复制代码
public class MyClass {
    private MyClass() {
        // private constructor
    }
    
    public static MyClass getInstance() {
        return new MyClass();
    }
}

总结

"要么设计继承并提供文档说明,要么禁止继承"这一规则的目的是确保代码的健壮性和可维护性。允许继承的类需要设计得当,并有充分的文档说明,以便开发者能够正确地进行扩展。相反,如果类不打算被继承,应通过技术手段和文档明确禁止继承,避免错误使用带来的潜在问题。这一规则强调了在面向对象设计中,对继承的使用应当谨慎和明确,以确保系统的稳定性和可维护性。

3.6 接口优于抽象类

多重继承:Java中,一个类可以实现多个接口,但只能继承一个抽象类。这使得接口更灵活,因为类可以组合多个接口来获得多种行为,而不受单一继承的限制。

java 复制代码
public interface Flyable {
    void fly();
}

public interface Swimmable {
    void swim();
}

public class Duck implements Flyable, Swimmable {
    public void fly() {
        // implement flying behavior
    }

    public void swim() {
        // implement swimming behavior
    }
}

解耦设计:接口可以定义一组方法而不涉及实现细节,这促进了实现和接口的解耦。接口提供了一个抽象层,使得具体实现可以随时更换,而不影响依赖该接口的代码。

java 复制代码
public interface PaymentProcessor {
    void processPayment(double amount);
}

public class CreditCardProcessor implements PaymentProcessor {
    public void processPayment(double amount) {
        // process credit card payment
    }
}

public class PayPalProcessor implements PaymentProcessor {
    public void processPayment(double amount) {
        // process PayPal payment
    }
}

默认方法:从Java 8开始,接口可以包含默认方法(default methods),这使得接口不仅可以定义方法,还可以提供默认实现。这使得接口的可扩展性和灵活性进一步增强。

java 复制代码
public interface Vehicle {
    void move();

    default void start() {
        System.out.println("Vehicle is starting");
    }
}

public class Car implements Vehicle {
    public void move() {
        System.out.println("Car is moving");
    }
}

"接口优于抽象类"的核心在于接口提供了更大的灵活性、解耦性和扩展性。接口允许多重继承,促进设计的解耦,并且自Java 8起支持默认方法,使其不仅可以定义合同,还可以提供默认实现。相比之下,抽象类由于单一继承的限制和可能的实现细节耦合,通常不如接口灵活和简洁。因此,在设计API和类结构时,优先选择接口可以带来更好的设计效果。

3.7 为后代设计接口

"为后代设计接口"的核心在于设计接口时要考虑长期使用的稳定性、兼容性和灵活性。通过保持接口的简洁性、提供默认方法、使用接口继承、清晰的文档说明和提供示例实现,可以确保接口在未来的扩展和使用中保持健壮和易用。这一规则帮助开发者设计出高质量、可维护的API,使得接口能够更好地适应未来的变化和需求。

3.8 接口只用于定义类型

当类实现接口时,接口充当可以引用该类实例的类型,表示客户端可以对该类的实例执行某些操作。然而,为其他目的定义接口是不合适的。例如,常量接口仅包含静态final域,用于导出常量,这种做法是不推荐的。实现常量接口会将实现细节泄露到导出API中,对用户没有实际价值,反而增加混淆。更糟糕的是,如果类将来不再需要这些常量,为了保持二进制兼容性,仍需实现该接口。非final类实现常量接口还会污染其子类的命名空间。Java平台中的一些常量接口如java.io.ObjectStreamConstants应被视为反面典型,不应效仿。

3.9 类层次优于标签类

有时可能会遇到带有两种甚至更多风格的实例的类,并包含表示实例风格的标签域,例如,以下面这个类为例,它能够表示圆形或者矩形:

java 复制代码
public class Figure {
    enum Shape {RECTANGLE, CIRCLE};
    Shape shape;
    double length;
    double width;
    double radius;
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }
    Figure(double length, double width) {
        this.length = length;
        this.width = width;
    }
    double area() {
        switch (shape) {
            case CIRCLE:
                return Math.PI * radius * radius;
            case RECTANGLE:
                return length * length;
            default:
                throw new AssertionError(shape);
        }
    }
}

这种标签类(taggedclass)有许多缺点。它们中充斥着样板代码,包括举声明、标签域以及条件语句。由于多个实现乱七八糟地挤在单个类中,破坏了可读性。由于实例承担着属于其他风格的不相关的域,因此内存占用也增加了。域不能做成final的,除非构造器初始化了不相关的域,产生了更多的样板代码。构造器必须不借助编译器来设置标签域,并初始化正确的数据域:如果初始化了错误的域,程序就会在运行时失败。无法给标签类添加风格,除非可以修改它的源文件。如果一定要添加风格,就必须记得给每个条件语句都添加一个条件,否则类就会在运行时失败。最后,实例的数据类型没有提供任何关于其风格的线索。一句话,标签类过于冗长、容易出错,并且效率低下。

幸运的是,面向对象的语言(如Java)提供了其他更好的方法来定义能表示多种风格对象的单个数据类型:子类型化(subtyping)。标签类正是对类层次的一种简单的仿效。为了将标签类转变成类层次,首先要为标签类中的每个方法都定义一个包含抽象方法的抽象类,标签类的行为依赖于标签值。在Figure类中,只有一个这样的方法:area。这个抽象类是类层次的根(root)。如果还有其他的方法其行为不依赖于标签的值,就把这样的方法放在这个类中。同样地,如果所有的方法都用到了某些数据域,就应该把它们放在这个类中。在Figure类中,不存在这种类型独立的方法或者数据域。

接下来,为每种原始标签类都定义根类的具体子类。

java 复制代码
abstract class Figure {
    abstract double area();
}
class Circle extends Figure {
    final double radius;
    public Circle(double radius) {this.radius = radius;}
    @Override
    double area() {return Math.PI * radius * radius;}
}
class Rectangle extends Figure {
    final double length;
    public Rectangle(double length) {this.length = length;}
    @Override
    double area() {return length * length;}
}

这个类层次纠正了前面提到过的标签类的所有缺点。这段代码简单且清楚,不包含在原来的版本中见到的所有样板代码。每个类型的实现都配有自己的类,这些类都没有受到不相关数据域的拖累。所有的域都是final的。编译器确保每个类的构造器都初始化它的数据域,对于根类中声明的每个抽象方法都确保有一个实现。这样就杜绝了由于遗漏switch case而导致运行时失败的可能性。多名程序员可以独立地扩展层次结构,并且不用访问根类的源代码就能相互操作。每种类型都有一种相关的独立的数据类型,允许程序员指明变量的类型,限制变量,并将参数输入到特殊的类型。

类层次的另一个好处在于,它们可以用来反映类型之间本质上的层次关系,有助于增强灵活性,并有助于更好地进行编译时类型检查。

简而言之,标签类很少有适用的时候 当你想要编写 个包含显式标签域的类时,应 该考虑一下,这个标签是否可以取消,这个类是否可以用类层次来代替 你遇到 个包含 标签域的现有类时,就要考虑将它重构到 个层次结构中去。

3.10 静态成员优于非静态成员

嵌套类(nested class)是定义在另一个类内部的类,主要服务于外围类(enclosing class)。嵌套类分为四种:静态成员类(static member class)、非静态成员类(non-static member class)、匿名类(anonymous class)和局部类(local class),除了静态成员类,其他都称为内部类(inner class)。以下是各类嵌套类的使用场景和原因总结:

  1. 静态成员类(static member class)
    • 类似于顶层类,只是声明在另一个类内部。
    • 可以访问外围类的所有成员。
    • 常用于作为公有的辅助类,与外围类一起使用才有意义。
    • 如果嵌套类实例不需要外围类实例的引用,使用静态成员类更合适。
  2. 非静态成员类(non-static member class)
    • 每个实例隐含地与一个外围实例关联。
    • 可以在实例方法内部调用外围实例的方法。
    • 适合需要与外围实例相关联的场景,比如实现Adapter模式。
    • 需要注意的是,使用非静态成员类会增加时间和空间开销。
  3. 匿名类(anonymous class)
    • 没有名字,在使用的同时被声明和实例化。
    • 只能出现在表达式允许的地方,且通常较短。
    • 不能有静态成员,只能有常数变量。
    • 适用于动态创建小型函数对象或过程对象,尤其在lambda表达式引入之前常用。
  4. 局部类(local class)
    • 在任何可以声明局部变量的地方都可以声明局部类,遵守相同的作用域规则。
    • 与匿名类一样,通常比较简短。
    • 适用于方法内部使用,并且需要在方法内部重复使用或需要命名的场景。

使用建议:

总而言之,共有四种不同的嵌套类,每一种都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合放在方法内部,就应该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的;否则,就做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型可以说明这个类的特征,就要把它做成匿名类;否则,就做成局部类。

3.11 限制源文件为单个顶级类

永远不要把多个顶级类或者接口放在一个源文件中。遵循这个规则可以确保编译时一个类不会有多个定义。这么做反过来也能确保编译产生的类文件,以及程序结果的行为,都不会受到源文件被传给编译器时的顺序的影响。

4. 泛型

从Java 5开始,泛型(generic)已经成为Java编程语言的一部分。在没有泛型之前,从集合中读取的每一个对象都必须进行类型转换。如果有人不小心插入了类型错误的对象,在运行时进行类型转换时就会出错。有了泛型之后,你可以告诉编译器每个集合中接受哪些类型的对象。编译器会自动为你的插入操作进行类型检查,并在编译时 告知是否插入了类型错误的对象。这样可以使程序更加安全和清晰,但要享受这些优势(不限于集合)有一定的难度。

4.1 请不要使用原生态类型

原生态类型(Raw Types)

原生态类型是泛型类或接口在不指定类型参数的情况下使用。例如,List就是List<T>的原生态类型。当你使用原生态类型时,Java编译器无法检查类型安全性,这可能导致运行时异常(ClassCastException)。

为什么不要使用原生态类型

  1. 类型安全性:使用泛型可以在编译时进行类型检查,避免在运行时发生类型转换错误。例如:
java 复制代码
List<String> stringList = new ArrayList<>();
stringList.add("Hello");
String s = stringList.get(0); // 这是安全的

如果使用原生态类型:

java 复制代码
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0); // 需要显式类型转换,存在ClassCastException风险
  1. 自文档化 :使用泛型可以使代码更清晰、可读性更强,明确指出集合中的元素类型。例如,List<String>表示一个字符串列表,而List则不明确。
  2. 避免编译警告:使用原生态类型时,Java编译器会发出unchecked警告。消除这些警告有助于保持代码整洁、无警告。

示例

java 复制代码
// 原生态类型的使用
List rawList = new ArrayList();
rawList.add("Hello");
rawList.add(123); // 这是允许的,但可能引发问题
String s = (String) rawList.get(0); // 需要显式类型转换

// 泛型的使用
List<String> genericList = new ArrayList<>();
genericList.add("Hello");
// genericList.add(123); // 编译时会报错
String s = genericList.get(0); // 无需显式类型转换

不使用原生态类型是为了增强类型安全性、提高代码的可读性和可维护性,以及避免编译时的警告。通过使用泛型,程序员可以编写更可靠、更清晰的代码。

4.2 消除非受检的警告

什么是非受检的警告(Unchecked Warnings)?

非受检的警告是在编译期间,由Java编译器发出的警告,表示在使用泛型时可能存在类型不安全的情况。通常这些警告涉及到使用原生态类型(raw types)或在泛型和非泛型代码之间进行类型转换。

例如,下面的代码会产生非受检的警告:

java 复制代码
List rawList = new ArrayList(); // 使用了原生态类型
List<String> stringList = rawList; // 类型转换

为什么要消除非受检的警告?

  1. 类型安全性:消除非受检的警告有助于确保代码的类型安全性,防止运行时类型转换错误(ClassCastException)。
  2. 代码质量:减少警告可以使代码更加整洁和可维护,增强代码的可读性和可靠性。
  3. 避免隐藏问题:忽略警告可能掩盖潜在的类型问题,这些问题可能会在代码的某些边缘情况下引发错误。

如何消除非受检的警告?

  1. 使用泛型 :确保代码中使用的是泛型类型而不是原生态类型。例如,使用List<String>而不是List
  2. 类型安全的代码:编写类型安全的代码,避免不必要的类型转换和使用泛型类型的不安全操作。
  3. @SuppressWarnings 注解 :在确实无法避免非受检警告时,可以使用@SuppressWarnings("unchecked")注解来压制这些警告,但这应该是最后的手段,并且要非常小心使用,确保代码是类型安全的。

4.3 列表优于数组

列表优于数组"(Prefer lists to arrays),这句话的背景是基于 Java 集合框架(Collections Framework)中的 List 接口与传统数组之间的对比。这里的"列表"指的是实现 List 接口的类,比如 ArrayListLinkedList

理解"列表优于数组"

  1. 类型安全性
    • 数组 :Java 数组是协变的(covariant),即如果 SubSuper 的子类,那么 Sub[] 就是 Super[] 的子类。这导致了数组在运行时会进行类型检查,并可能抛出 ArrayStoreException。例如:
java 复制代码
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // 运行时会抛出 ArrayStoreException
  • 列表:列表使用泛型来保证类型安全,编译时会进行类型检查,防止类型错误。例如:
java 复制代码
List<Object> objectList = new ArrayList<Long>();
objectList.add("I don't fit in"); // 编译时会报错
  1. 泛型支持
    • 数组:不直接支持泛型,不能创建泛型数组。这限制了数组在处理泛型类型时的灵活性。
java 复制代码
List<String>[] stringLists = new List<String>[1]; // 编译错误
  • 列表:完全支持泛型,允许你创建和操作各种类型安全的集合。
java 复制代码
List<List<String>> listOfLists = new ArrayList<>();
  1. 功能丰富
    • 数组:提供的操作比较有限,主要是简单的索引访问和遍历操作。
    • 列表List 接口提供了丰富的方法,如 addremovecontainsindexOfsubList 等,方便进行各种集合操作。
  2. 动态大小
    • 数组:大小固定,一旦创建就不能改变大小。如果需要动态调整数组大小,通常需要创建一个新数组并复制旧数组的内容。
java 复制代码
int[] array = new int[10];
array = Arrays.copyOf(array, 20);
  • 列表:大小是动态的,可以随时添加或移除元素,自动调整内部容量。
java 复制代码
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
  1. 集合框架的统一性
    • 数组:与 Java 集合框架不一致,无法直接使用集合框架提供的丰富功能。
    • 列表 :与集合框架中的其他接口和类(如 SetMap)一致,可以使用集合框架的各种工具类和方法(如 Collections 工具类)。

总结

列表优于数组"是基于以下几点:

  1. 类型安全:列表通过泛型提供了更好的类型安全性。
  2. 功能丰富:列表提供了比数组更多的操作方法。
  3. 动态调整:列表可以动态调整大小,更加灵活。
  4. 集合框架兼容:列表与 Java 集合框架中的其他部分一致,使用更加方便。

因此,在大多数情况下,特别是当需要更复杂和灵活的集合操作时,建议优先使用 List 而不是数组。这能够提高代码的安全性、可读性和可维护性。

4.4 优先考虑泛型

"优先考虑泛型"(Favor generics),这句话的背景是基于泛型(Generics)在 Java 编程中的重要性和优越性。泛型使代码更具通用性、类型安全性和可读性。下面是对这一建议的详细解释。

理解"优先考虑泛型"

  1. 类型安全性
    • 非泛型代码 :在没有使用泛型时,集合类只能存储 Object 类型的对象,需要进行显式类型转换,这可能导致运行时错误。
java 复制代码
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0); // 可能导致 ClassCastException
  • 泛型代码:使用泛型后,类型转换在编译时进行检查,避免了运行时错误。
java 复制代码
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0); // 类型安全,无需显式转换
  1. 可读性和可维护性
    • 非泛型代码:因为类型信息丢失,读代码时不容易知道集合中存储的是什么类型的对象。
java 复制代码
List list = new ArrayList();
list.add("Hello");
list.add(123);
  • 泛型代码:类型信息显而易见,代码更容易理解和维护。
java 复制代码
List<String> list = new ArrayList<>();
list.add("Hello");
  1. 代码重用性
    • 非泛型代码:在处理不同类型的数据时,可能需要编写多份相似的代码。
java 复制代码
public void processStrings(List list) {
    for (Object obj : list) {
        String s = (String) obj;
        // 处理字符串
    }
}
public void processIntegers(List list) {
    for (Object obj : list) {
        Integer i = (Integer) obj;
        // 处理整数
    }
}
  • 泛型代码:可以编写更加通用的代码,适用于多种类型。
java 复制代码
public <T> void processElements(List<T> list) {
    for (T element : list) {
        // 处理元素
    }
}
  1. 消除非受检警告
    • 非泛型代码:在混用泛型和非泛型代码时,容易出现非受检警告。
java 复制代码
List list = new ArrayList();
list.add("Hello");
List<String> stringList = list; // 非受检的转换警告
  • 泛型代码:消除非受检警告,使代码更加干净。
java 复制代码
List<String> list = new ArrayList<>();
list.add("Hello");

总结

因为泛型提供了更高的类型安全性、可读性、可维护性和代码重用性。通过使用泛型,可以编写更加通用和健壮的代码,减少运行时错误,提高代码质量。在日常编程中,应尽可能使用泛型来定义和使用集合类、方法和接口。

4.5 优先考虑泛型方法

什么是泛型方法?

泛型方法是在方法定义中使用类型参数,使得方法能够操作泛型类型的参数。与泛型类不同,泛型方法的类型参数是在方法级别定义的,而不是在类级别定义的。

下面是一个简单的泛型方法的定义:

java 复制代码
public class Utility {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }
}

在这个例子中,<T> 是类型参数,printArray 方法可以操作任何类型的数组。

为什么要优先考虑泛型方法?

  1. 增强代码的通用性

泛型方法使代码能够处理多种类型,而无需为每种类型编写单独的方法。例如:

java 复制代码
public class Utility {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }
}

// 使用泛型方法打印不同类型的数组
Utility.printArray(new Integer[]{1, 2, 3});
Utility.printArray(new String[]{"a", "b", "c"});

同一个 printArray 方法可以处理 Integer 数组和 String 数组。

  1. 提高类型安全性

泛型方法在编译时进行类型检查,防止运行时类型转换错误。例如:

java 复制代码
public class Utility {
    public static <T> void addToList(T element, List<T> list) {
        list.add(element);
    }
}

// 类型安全的调用
List<String> stringList = new ArrayList<>();
Utility.addToList("Hello", stringList); // 类型安全

编译器会确保 addToList 方法中的类型参数 T 一致,避免类型错误。

  1. 减少代码重复

泛型方法避免了为不同类型编写重复的代码。例如,不需要分别为 StringInteger 编写两个方法:

java 复制代码
public class Utility {
    public static <T> boolean contains(T element, T[] array) {
        for (T e : array) {
            if (e.equals(element)) {
                return true;
            }
        }
        return false;
    }
}

// 检查数组是否包含某个元素
boolean result1 = Utility.contains(1, new Integer[]{1, 2, 3});
boolean result2 = Utility.contains("a", new String[]{"a", "b", "c"});

4.6 利用有限制通配符来提升API的灵活性

"利用有限通配符来提升 API 的灵活性" 是指在设计 API 时,通过使用上界通配符 <? extends T> 和下界通配符 <? super T>,可以使 API 更加通用和灵活,适应更多类型的需求,同时提高类型安全性。这样不仅能简化代码,还能避免不必要的类型转换和方法重载,提升代码的可维护性和可扩展性。

4.7 谨慎并用泛型和可变参数

"谨慎使用泛型和可变参数",因为它们的结合可能会引发类型安全问题,导致潜在的 ClassCastException 和隐藏的类型转换错误。为了安全使用泛型和可变参数,可以考虑以下策略:

  1. 使用 @SafeVarargs 注解(仅适用于 finalstatic 方法)。
  2. 避免直接操作可变参数数组。
  3. 使用集合(如 List)代替可变参数。

通过谨慎处理,可以编写更安全、可靠的泛型代码,避免运行时错误,提高代码的健壮性和可维护性。

4.8 优先考虑类型安全的异构容器

什么是异构容器?

异构容器(heterogeneous container)是指可以存储不同类型对象的容器。与传统容器(如 List<T>Map<K, V>)不同,异构容器允许在同一个容器中存储多种类型的对象。

为什么需要类型安全的异构容器?

在很多情况下,我们希望在一个容器中存储不同类型的对象,但同时又不希望丧失类型安全性。传统容器(如 List<Object>Map<Object, Object>)允许存储不同类型的对象,但会失去类型安全性,需要进行类型转换,容易导致 ClassCastException

类型安全的异构容器示例

为了实现类型安全的异构容器,可以使用 Class 对象作为键。例如,一个类型安全的异构容器可以使用 Map<Class<?>, Object> 来存储不同类型的对象。

以下是一个实现类型安全的异构容器的示例:

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class Favorites {
    private Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance) {
        if (type == null) {
            throw new NullPointerException("Type is null");
        }
        favorites.put(type, type.cast(instance));
    }

    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        
        favorites.putFavorite(String.class, "Java");
        favorites.putFavorite(Integer.class, 42);
        
        String favoriteString = favorites.getFavorite(String.class);
        Integer favoriteInteger = favorites.getFavorite(Integer.class);
        
        System.out.println("Favorite String: " + favoriteString);
        System.out.println("Favorite Integer: " + favoriteInteger);
    }
}

在这个示例中:

  1. Favorites 类使用一个 Map<Class<?>, Object> 来存储不同类型的对象。
  2. putFavorite 方法将对象存储在 Map 中,并使用 Class 对象作为键,以确保类型安全。
  3. getFavorite 方法通过键(Class 对象)从 Map 中检索对象,并使用 Class.cast 方法进行类型转换,以确保类型安全。

优点

  1. 类型安全 :使用 Class 对象作为键,可以在编译时进行类型检查,确保存储和检索的对象类型匹配。
  2. 灵活性:能够在同一个容器中存储不同类型的对象,而无需进行类型转换。
  3. 简洁性 :避免了大量的类型转换和 ClassCastException,代码更加简洁易读。

5. 枚举和注解

5.1 用 enum 代替 int 常量

在JDK1.5之前,表示枚举类型的常用模式是声明一组具名的int常量,每个类型成员一个常量:

java 复制代码
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
 
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;

缺点:

  1. 在类型安全方面,将 apple 传到想要orange的方法中,编译器并不能检测出错误;
  2. 因为 int 常量是编译时常量,被编译到使用它们的客户端中。若与枚举常量关联的 int 发生了变化,客户端需重新编译,否则它们的行为就不确定;
  3. 没有便利方法将 int 常量翻译成可打印的字符串。这里的意思应该是比如你想调用的是 ORANGE_NAVEL,debug 的时候显示的是0,但你不能确定是 APPLE_FUJI 还是 ORANGE_NAVEL。

枚举类型是int枚举常量的替代解决方案:

java 复制代码
public enum Apple {FUJI, PIPPIN, GRANNY_SMITH}
public enum Orange {NAVEL, TEMPLE, BLOOD}

Java枚举类型背后的基本想法非常简单:通过公有的静态final域为每个枚举常量导出实例的类。因为没有可以访问的构造器,枚举类型是真正的final。客户端既不能创建枚举类型的实例,也不能对它进行扩展,因此很可能没有实例,而只是声明过的枚举常量。换句话说,枚举类型是实例受控的。他们是单例(Singleton)的泛型化,本质上是单元素的枚举。枚举类型为类型安全的枚举(typesafe enum)模式。

枚举提供编译时的类型安全,如果一个参数的类型是Apple,就可以保证,被传入到该参数上的任何非null对象引用一定是FUJI,PIPPIN,GRANNY_SMITH三个之一。

包含同名常量的多个枚举类型可以共存,因为每个类型有自己的命名空间,增加或重新排列枚举类型的常量,无需重新编译客户端代码。

通过调用toString方法,可以将枚举转换成可打印的字符串。

除了完善了int枚举模式的不足之处,枚举类型还允许添加任意的方法和域,并实现任意的接口。他们提供了所有Object方法的高级实现,实现了Comparable和Serializable接口,并针对枚举类型的可任意改变性设计了序列化方式。

enum 枚举常量于数据关联

enum枚举常量可以与数据相关,然后在枚举中提供方法返回客户端需要的信息。如以太阳系为例,每个行星都拥有质量和半径,可以依据这两个属性计算行星表面物体的重量。代码如下:

java 复制代码
public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS (4.869e+24, 6.052e6),
    EARTH (5.975e+24, 6.378e6),
    MARS (6.419e+23, 3.393e6),
    JUPITER(1.899e+27, 7.149e7),
    SATURN (5.685e+26, 6.027e7),
    URANUS (8.683e+25, 2.556e7),
    NEPTUNE(1.024e+26, 2.477e7);
 
    private final double mass; // In kilograms
    private final double radius; // In meters
    private final double surfaceGravity; // In m / s^2
 
    // Universal gravitational constant in m^3 / kg s^2
    private static final double G = 6.67300E-11;
 
    // Constructor
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
    }
 
    public double mass() { return mass; }
    public double radius() { return radius; }
    public double surfaceGravity() { 
        return surfaceGravity; 
    }
 
    public double surfaceWeight(double mass) {
        return mass * surfaceGravity; // F = ma
    }
}
 
public class PlanetDemo {
    public static void main(String[] args) {
        double earthWeight = Double.parseDouble(args[0]);
        double mass = earthWeight / Planet.EARTH.surfaceGravity();

        for (Planet p : Planet.values()) {
            System.out.printf("Weight on %s is %f%n", p, p.surfaceWeight(mass));
        }
    }
}

enum 枚举常量与行为关联

有些时候将enum枚举常量与数据关联还不够,还需要将枚举常量与行为关联。如采用枚举来写加、减、乘、除的运算。可以避免增加新枚举时,遗漏相应条件。代码如下:

java 复制代码
public enum Operation {
  PLUS {
    double apply(double x, double y) {
      return x + y;
    }
  },
  MINUS {
    double apply(double x, double y) {
      return x - y;
    }
  },
  TIMES {
    double apply(double x, double y) {
      return x * y;
    }
  },
  DIVIDE {
    double apply(double x, double y) {
      return x / y;
    }
  };
  abstract double apply(double x, double y);
}

什么时候应该使用枚举?

每当需要一组固定常量的时候。当然,这包括"天然的枚举类型",例如行星、一周的天数以及棋子的数目等等。但它也包括你在编译时就知道其所有可能值的其他集合,例如菜单的选项,操作代码以及命令行标记等。枚举类型中的常量集并不一定要始终保持不变。专门设计枚举特性是考虑到枚举类型的二进制兼容演变。

总而言之,与int常量相比,枚举类型的优势是不言而喻的。枚举要易读得多,也要更安全,功能更加强大。许多枚举都不需要显式的构造器或者成员,但许多其他枚举则受益于"每个常量与属性关联"以及"提供行为受这个属性影响的方法"。只有极少数的枚举受益于将多种行为与单个方法关联。在这种相对少见的情况下,特定于常量的方法要优先于启用自有值的枚举。如果多个枚举常量同时共享相同的行为,则考虑策略枚举。

5.2 用实例域代替序数

许多枚举天生就与一个单独的 int 值相关联。所有的枚举都有一个 ordinal 方法,它返回每个枚举常量在类型中的数字位置。

你可以试着从序数中得到关联的 int 值:

java 复制代码
// Abuse of ordinal to derive an associated value - DON'T DO THIS
public enum Ensemble {
    SOLO, DUET, TRIO, QUARTET, QUINTET,
    SEXTET, SEPTET, OCTET, NONET, DECTET;
    public int numberOfMusicians(){ return ordinal() + 1; }
}

虽然这个枚举不错,但是维护起来就想一场恶梦。如果常量进行重新排序, numberOfMusicians 方法就会遭到破坏。如果要再添加一个与已经用过的 int 值关联的枚举常量,就没那么走运了。例如,给双四重奏(double quartet)添加一个常量,它就像个八重奏一样,是由8位演奏家组成,但是没有办法做到。

要是没有给所有这些 int 值添加常量,也无法给某个 int 值添加常量。

幸运的是,有一种很简单的方法可以解决这些问题。永远不要根据枚举的序数导出与它关联的值,而是要将它保存在一个实例域中:

java 复制代码
public enum Ensemble {
    SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
    SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
    NONET(9), DECTET(10), TRIPLE_QUARTET(12);
    private final int numberOfMusicians;
    Ensemble(int size) { this.numberOfMusicians = size; }
    public int numberOfMusicians(){ return numberOfMusicians; }
}

Enum 规范中谈及 ordinal 方法时写道:"大多数程序员都不需要这个方法。它是设计用于像 EnumSet 和 EnumMap 这种基于枚举的通用数据结构的。"除非你在编写的是这种数据结构,否则最好完全避免使用ordinal 方法。

5.3 使用 EnumSet 代替位域

如果一个枚举类型的元素主要用在集合中,一般使用int枚举模式,将2的不同倍数赋予每个常量:

java 复制代码
// Bit field enumeration constants - OBSOLETE
public class Test{
  public static final int STYLE_BOLD = 1<<0;//1
  public static final int STYLE_INALIC = 1<<2;//2
  public static final int STYLE_UNDERLINE = 1<<3;//4
  public static final int STYLE_STRIKETHROUGH = 1<<4;//8
  //Parameter is bitwise OR of zero or more STYLE_ constants
  public void applyStyles(int styles){...}
}

这种表示法让你用OR位运算将几个常量合并到一个集合中,称作位域:

java 复制代码
test.applyStyles(STYLE_BOLD | STYLE_INALIC);

位域表示法也允许利用位操作,有效地执行像union(并集)和intersection(交集)这样的操作集合。但位域有着int枚举常量的所有缺点,甚至更多。当位域以数字形式打印时,翻译位域比翻译简单的int枚举常量要困难的多。甚至要遍历域表示的所有元素也没有很容易的方法。

有些程序员优先使用枚举而非int常量,他们在需要传递多组常量集合时,仍然倾向于使用位域。其实没有理由这么做,因为还有更好的方法代替。Java.util包还提供了EnumSet类来有效的表示从单个枚举类型中提取的多个值的多个集合。这个类实现了Set接口,提供了丰富的功能、类安全性,以及可以从任何其他Set实现中得到的互用性。但是在内部具体实现上,每个EnumSet内容都表示为位矢量。如果底层的枚举类型有64个或者更少的元素------大多如此------整个EnumSet就是用单个long来表示,因此它的性能比得上位域的性能。批处理,如removerAll和retainAll,都是利用位算法来实现,就像手工替位域实现得那样。但是可以避免手工位操作时容易出现的错误以及不太雅观的代码,因为EnumSet替你完成了这项艰巨的工作。

下面是前一个范例改成用枚举代替位域后的代码,它更简单、更加清楚,也更加安全:

java 复制代码
// EnumSet -a modern replacement for bit fields
public class Test{
  public enum Style{BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}
  //Any Set could be passed in, but EnumSet is clearly best
  public void applyStyles(Set<Style> styles){...}
}

下面是将EnumSet实例传递给applyStyles方法的客户端代码。EnumSet提供了丰富的静态工厂来创建集合,其中一个如这个代码所示:

java 复制代码
test.applyStyles(EnumSet.of(Style.BOLD,Style.ITALIC));

总而言之,正是因为枚举类型要用在集合(Set)中,所以没有理由用位域来表示它。EnumSet类位域的简介和性能优势及枚举类型的所有的优点于一身。实际上EnumSet有个缺点,即截至Java1.6发行版,它都无法创建不可变的EnumSet,但是这一点很可能在即将出现的版本中得到修复。同时可以用Collections.unmodifiableSet将EnumSet封装起来,但是简洁性和性能会受到影响。

5.4 使用 EnumMap 代替序数索引

本条目与 EnumSet 类似,强调的是 int 类型数据在 java 中本身其实没有特殊意义,对于数组来说,下标与内容也没有严格对应关系,因此依赖数组下标也是不太好的行为。

如下,对于植物,按播种季节分类:

java 复制代码
import java.util.HashSet;
import java.util.Set;

@ToString
@AllArgsConstructor
public class Plant {
    public enum Type {
        // 春种
        SPRING,
        // 夏种
        SUMMER,
        //秋种
        AUTUMN
    }
    private final String name;
    private final Type type;
    public static void main(String[] args) {
        Plant[] garden = new Plant[]{
                new Plant("A11", Type.SPRING),
                new Plant("A12", Type.SPRING),
                new Plant("B20", Type.SUMMER),
                new Plant("C30", Type.AUTUMN),
                new Plant("C31", Type.AUTUMN),
                new Plant("A13", Type.SPRING),
                new Plant("B23", Type.SUMMER)
        };
        Set<Plant>[] types = new Set[Type.values().length];
        for (int x = 0; x < Type.values().length; x++) {
            types[x] = new HashSet<>();
        }
        for (Plant p : garden) {
            types[p.type.ordinal()].add(p);
        }
        for (int x = 0; x < types.length; x++) {
            System.out.println(Type.values()[x] + ":" + types[x]);
        }
    }
}

在上面的例子中,为了把Plant 按照播种季节区分,使用了泛型数组, Set[] types = new Set[Type.values().length];

这种方法的确可行,但是隐藏着许多问题。因为数组不能与泛型兼容。程序需要进行未受检的转换,并且不能正确无误地进行编译。因为数组不知道它的索引代表着什么,你必须手工标注这些索引的输出。但是这种方法最严重的问题在于,当你访问一个按照枚举的序数进行索引的数组时,使用正确的int值就是你的职责了;int不能提供枚举的类型安全。你如果使用了错误的值,程序就会悄然地完成错误的工作,或者幸运的话就会抛出ArrayIndexOutOfBoundException异常。

为了避免上面提到的问题,我们可以采用 EnumMap来处理这件事,EnumMap的key是枚举类型:

java 复制代码
import lombok.AllArgsConstructor;
import lombok.ToString;
import java.util.*;

@ToString
@AllArgsConstructor
public class Plant {
    public enum Type {
        // 春种
        SPRING,
        // 夏种
        SUMMER,
        //秋种
        AUTUMN
    }
    private final String name;
    private final Type type;

    public static void main(String[] args) {
        Plant[] garden = new Plant[]{
                new Plant("A11", Type.SPRING),
                new Plant("A12", Type.SPRING),
                new Plant("B20", Type.SUMMER),
                new Plant("C30", Type.AUTUMN),
                new Plant("C31", Type.AUTUMN),
                new Plant("A13", Type.SPRING),
                new Plant("B23", Type.SUMMER)
        };
        Map<Type, Set<Plant>> typeMap = new EnumMap<>(Type.class);
        for (Plant p : garden) {
            typeMap.computeIfAbsent(p.type, n -> new HashSet<>()).add(p);
        }
        typeMap.forEach((type, set) -> System.out.println(type + ":" + set));

    }
}

这段程序更简短,更清楚,也更安全,运行速度方面可以与使用序数的程序相媲美。它没有不安全的转换;不必手工标注出这些索引的输出,因为映射键知道如何将自身翻译成可打印的字符串的枚举;计算数组索引时也不可能出错。EnumMap在运行速度方面之所以能与通过序数索引的数组相媲美,是因为EnumMap在内部使用了这种数组。但是它对程序员隐藏了这种思想细节,集Map的丰富功能和类型安全与数组的快速于一身。注意EnumMap构造器采用键类型的Class对象:这是一个有限制的类型令牌(bounded type token),它提供了运行时的泛型信息。

5.5 使用接口模拟可扩展的枚举

枚举类型是不可扩展的,但是接口类型是可扩展的。使用接口,可以模拟可伸缩的枚举。

java 复制代码
//只有一个apply方法的接口Operation。
public interface Operation {
    double apply(double x, double y);
}
//实现接口的计算器
public enum BasicOperation implements Operation {
    PLUS("+") {
        public double apply(double x, double y) {return x + y;}
    },
    MINUS("-") {
        public double apply(double x, double y) {return x - y;}
    },
    TIMES("*") {
        public double apply(double x, double y) {return x - y;}
    },
    DIVIDE("/") {
        public double apply(double x, double y) {return x - y;}
    };
    private final String symbol;
    BasicOperation(String symbol) {
        this.symbol = symbol;
    }
    public String toString() {
        return symbol;
    }
    
}

你可以定义另外一个枚举类型,它实现这个接口,并用这个新类型的实例代替基本类型。例如,假设你想要定义一个上述操作类型的扩展,由求幂和求余操作组成。你所要做的就是编写一个枚举类型,让它实现Operation接口:

java 复制代码
public enum ExtendedOperation implements Operation {
    EXP("^"){
        @Override
        public double apply(double x, double y) {
            return Math.pow(x , y);
        }
    },
    REMAINDER("%"){
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
        
    };
     private final String symbol;
     ExtendedOperation(String symbol) {
            this.symbol = symbol;
     }   
}

注意,在枚举中,不必像在不可扩展的枚举中所做的那样,利用特定于实例的方法实现来声明抽象的apply方法。这是因为抽象的方法是接口的一部分。

不仅可以在任何需要"基本枚举"的地方单独传递一个"扩展枚举"的实例,而且除了那些基本类型的元素之外,还可以传递完整的扩展枚举类型,并使用它的元素。通过下面这个测试程序,体验一下上面定义过的所有扩展过的操作:

java 复制代码
public class test1 {
    public static void main(String[] args) {
        double x = 2;
        double y = 4;
        test(ExtendedOperation.class, x, y);
    }
    private static <T extends Enum<T> & Operation> void test(Class<T> opSet,
            double x, double y) {
        for (Operation op : opSet.getEnumConstants())
            System.out.printf("%f %s %f= %f%n", x, op, y, op.apply(x, y));
    }
}

用接口模拟可伸缩枚举有个小小的不足,即无法将实现从一个枚举类型继承到另一个枚举类型。在上Operation的实例中,保存和获取与某项操作相关联的符号的逻辑代码,可以复制到BasicOperation和ExtendedOperation中。在这个例子中是可以的,因为复制的代码非常少。如果共享功能比较多,则可以将它封装在一个辅助类或者静态辅助方法中,来避免代码的复制工作。

总而言之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样允许客户端编写自己的枚举来实现接口。如果API是根据接口编写的,那么在可以使用基础枚举类型的任何地方,也就可以使用这些枚举。

5.6 注解优于命名模式

在之前的做法中(Historically),一般使用命名模式(naming pattern) 表明有些程序元素需要通过某种工具或者框架进行特殊处理。例如,在第4版之前,JUnit测试框架要求其用户通过使用字符test作为测试方法名称的开头[Beck04]。这种方法可行,但它有几个很大的缺点。

命名模式的缺点

  1. 首先,文字拼写错误会导致失败,并且没有任何提示。例如,假设你不小心将测试方法命名为tsetSafetyOverride而不是testSafetyOverride。Junit 3不会出错,但是它也不会执行测试,造成错误的安全感【也就是说,那个命名错误的测试方法不会执行测试,并且不会报错,让我们误以为测试成功了的假象】。
  2. 命名模式的第二个缺点是,无法确保它们只用于相应的程序元素上。例如,假设你将某个类命名为TestSafetyMechanisms ,是希望JUnit 3会自动测试它所有的方法,而不管它们叫什么名称。Junit 3还是不会出错,但也同样不会执行测试。
  3. 命名模式的第三个缺点是它们没有提供将参数值与程序元素相关联的好方法。例如,假设您希望支持仅在抛出特定异常时才成功的测试类别。异常类型本质上是测试的参数。您可以使用一些复杂的命名模式将异常类型名称编码到测试方法的名称中,但是这样的代码会很不雅观,也很脆弱。编译器不知道要去检验准备命名异常的字符串是否真正命名成功。如果命名类不存在,或者不是一个异常,你也要试着运行测试时才会发现。

注解的优势

注解很好地解决了所有这些问题,JUnit从第4版开始采用它们。在该项中,我们将编写自己的测试框架来显示注释是如何工作的。假设想要定义一个注解类型来指定简单的测试,它们自动运行,并在抛出异常时失败。以下就是这样一个注解类型,命名为Test:

java 复制代码
// Marker annotation type declaration
import java.lang.annotation.*;
/**
 * Indicates that the annotated method is a test method.
 * Use only on parameterless static methods.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

Test注解类型的声明就是它自身通过 Retention 和 Target 注解进行了注解。注解类型声明中的这种注解被称作元注解(meta-annotation)。@Retention(RetentionPolicy.RUNTIME)元注解表明,Test注解应该在运行时保留。如果没有保留,测试工具就无法知道Test注解。@Target(ElementType.METHOD)元注解表明,Test注解只在方法声明中才是合法的;它不能运用到类声明、域声明或者其他程序元素上。

注意Test注解声明上方的注释:"Use only on parameterless static methods.(只用于无参的静态方法)。"如果编译器能够强制这一限制最好,但是它做不到。除非你写一个注释处理器这样做。有关此专题的更多信息,请参阅javax.annotation.processing的文档。在缺少这样的注释处理器的情况下,如果将Test注解放在实例方法的声明中,或者放在带有一个或者多个参数的方法中,测试程序仍然会编译,让测试工具在运行时来处理这个问题。

小结

除了 toolsmiths 之外,大多数程序员不需要定义注解类型。但是所有程序员都应该使用 Java 提供的预定义注解类型。另外,考虑使用 IDE 或静态分析工具提供的注解。这些注解可以提高这些工具提供的诊断信息的质量。但是,请注意,这些注解还没有标准化,因此,如果你切换了工具或出现了标准,那么你可能需要做一些工作。

5.7 统一使用 override 注解

Override注解只能用在方法声明中,表示被注解的方法声明覆盖了超类型中的一个声明。

使用Override注解的好处

  1. 当你想要覆盖父类的方法时,却因为人为原因将覆盖写成了重载,如果在方法上标注了Override注解,那么编译器就会在编译期间帮助你发现这个错误,而非在程序运行时出现非法的错误。【例外:如果你在编写一个没有标注为抽象的类,并且确信它覆盖了抽象的方法,在这种情况下,就不必将Override注解放在该方法上了。在没有声明为抽象的类中,如果没有覆盖抽象的超类方法,编译器就会发出一条错误消息。但是,你可能希望关注类中所有覆盖超类方法的方法,在这种情况下,也可以放心的标注这些方法。】
  2. 现代的IDE提供了坚持使用Override注解的另一种理由。这种IDE具有自动检查功能,称作代码检验(code inspection)。如果启用相应的代码检验功能,当有一个方法没有Override注解,却覆盖了超类方法时,IDE就会产生一条警告。如果坚持使用Override注解,这些警告就会提醒你警惕无意识的覆盖。这些警告补充了编译器的错误消息,提醒你警惕无意识的覆盖失败。IDE和编译器,可以确保你覆盖任何你想要覆盖的方法,无一遗漏。

总而言之,如果在你想要的每个方法声明中使用Override注解来覆盖超类声明,编译器就可以替你防止大量的错误,但有一个例外。在具体的类中,不必标注你确信覆盖了抽象方法声明的方法(虽然这么做也没有什么坏处)。

5.8 用标签接口定义类型

什么是标签接口

Java中的标记接口(Marker Interface),又称标签接口(Tag Interface),具体是不包含任何方法的接口。

首先要明确的是,标记接口并不是Java语言独有的,而是计算机科学中的一种通用的设计理念。

具体说的就是,标记接口是计算机科学中的一种设计思路,用于给那些面向对象的编程语言描述对象。因为编程语言本身并不支持为类维护元数据,而标记接口可以用作描述类的元数据,弥补了这个功能上的缺失。对于实现了标记接口的类,我们就可以在运行时通过反射机制去获取元数据。

以Serializable接口为例,如果一个类实现了这个接口,则表示这个类可以被序列化。因此,我们实际上是通过了Serializable这个接口给该类标记了【可被序列化】的元数据,打上了【可被序列化】的标签。这也是标记/标签接口名字的由来。

标签接口的目的

  1. 建立一个公共的父接口。比如EventListener接口,一个由几十个其它接口扩展的Java API,当一个接口继承了EventListener接口,Java虚拟机(JVM)就知道该接口将要被用于一个事件的代理方案。同样的,你可以使用一个标记接口来建立一组接口的父接口。
  2. 向一个类添加数据类型。这种情况是标记接口最初的目的,实现标记接口的类不需要定义任何接口方法(因为标记接口根本就没有方法),但是该类通过Java的多态性可以变成一个接口类型。

在现在Spring流行的时代,注解(Annotation)已经成为了最好的维护元数据的方式。因为注解能声明在包、类、字段、方法、局部变量、方法参数等之上,既灵活又方便地起到维护元数据的目的。

然而注解这么好的东西,只有在JDK1.5之后才能用。JDK1.5之前维护元数据的重任就落在标记接口的肩膀上了。可以看看另一个标记接口Cloneable的源码,里面的注释清晰地标注了该接口从JDK1.0的时代就有了。

java 复制代码
package java.lang;

/**
 * A class implements the {@code Cloneable} interface to
 * indicate to the {@link java.lang.Object#clone()} method that it
 * is legal for that method to make a
 * field-for-field copy of instances of that class.
 * <p>
 * Invoking Object's clone method on an instance that does not implement the
 * {@code Cloneable} interface results in the exception
 * {@code CloneNotSupportedException} being thrown.
 * <p>
 * By convention, classes that implement this interface should override
 * {@code Object.clone} (which is protected) with a public method.
 * See {@link java.lang.Object#clone()} for details on overriding this
 * method.
 * <p>
 * Note that this interface does <i>not</i> contain the {@code clone} method.
 * Therefore, it is not possible to clone an object merely by virtue of the
 * fact that it implements this interface.  Even if the clone method is invoked
 * reflectively, there is no guarantee that it will succeed.
 *
 * @see     java.lang.CloneNotSupportedException
 * @see     java.lang.Object#clone()
 * @since   1.0
 */
public interface Cloneable {
}

JDK 源码中标签接口的例子

java.io.Serializable:未实现此接口的类将无法使其任何状态序列化或反序列化。为保证serialVersionUID值跨不同Java编译器实现的一致性,序列化类必须声明一个明确的 serialVersionUID值。
java.lang.Cloneable:表明Object.clone()方法可以合法地对该类实例进行按字段复制。实现此接口的类应该使用公共方法重写Object.clone(它是受保护的)。如果在没有实现 Cloneable接口的实例上调用Object的clone()方法,则会导致抛出CloneNotSupportedException异常。
java.util.RandomAccess:用来表明其支持快速(通常是固定时间)随机访问。此接口的主要目的是允许一般的算法更改其行为,从而在将其应用到随机或连续访问列表时能提供良好的性能。
java.rmi.Remote:Remote接口用于标识其方法可以从非本地虚拟机上调用的接口。任何远程对象都必须直接或间接实现此接口。只有在远程接口(扩展java.rmi.Remote的接口)中指定的这些方法才可远程使用

6. Lambda 表达式和 Stream 流

在 Java 8 中,添加了函数式接口,lambda 表达式和方法引用,以便更容易地创建函数对象。 Stream API 随着其他语言的修改一同被添加进来,为处理数据元素序列提供类库支持。

6.1 Lambda 表达式优于匿名类

以往,使用单一抽象方法的接口(或者很少使用抽象类)被用作函数类型。 它们的实例(称为函数对象)表示函数(functions)或行动(actions)。 自从 JDK 1.1 于 1997 年发布以来,创建函数对象的主要手段就是匿名类。 下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序):

java 复制代码
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式[Gamma95]。 比较器接口表示排序的抽象策略; 上面的匿名类是排序字符串的具体策略。 然而,匿名类的冗长,使得 Java 中的函数式编程成为一种吸引人的前景。

在 Java 8 中,语言形式化了这样的概念,即使用单个抽象方法的接口是特别的,应该得到特别的对待。 这些接口现在称为函数式接口,并且该语言允许你使用 lambda 表达式或简称 lambda 来创建这些接口的实例。 Lambdas 在功能上与匿名类相似,但更为简洁。 下面的代码使用 lambdas 替换上面的匿名类。 样板不见了,行为清晰明了:

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

请注意,代码中不存在 lambdaComparator <String>),其参数(s1 和 s2,都是 String 类型)及其返回值(int)的类型。 编译器使用称为类型推断的过程从上下文中推导出这些类型。 在某些情况下,编译器将无法确定类型,必须指定它们。 类型推断的规则很复杂:他们在 JLS 中占据了整个章节。 很少有程序员详细了解这些规则,但没关系。 除非它们的存在使你的程序更清晰,否则省略所有 lambda 参数的类型。 如果编译器生成一个错误,告诉你它不能推断出 lambda 参数的类型,那么指定它。 有时你可能不得不强制转换返回值或整个 lambda 表达式,但这很少见。

你可能会认为匿名类在 lambda 时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而却不能用 **lambda **做。 Lambda 仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用 lambda 。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,lambda 不能获得对自身的引用。 在 lambda 中,this 关键字引用封闭实例,这通常是你想要的。 在匿名类中,this 关键字引用匿名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。

**Lambda **与匿名类共享无法可靠地序列化和反序列化实现的属性。因此,应该很少 (如果有的话) 序列化一个 lambda(或一个匿名类实例)。 如果有一个想要进行序列化的函数对象,比如一个 Comparator,那么使用一个私有静态嵌套类的实例。

综上所述,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。 除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。 另外,请记住,lambda 表达式使代表小函数对象变得如此简单,以至于它为功能性编程技术打开了一扇门,这些技术在 Java 中以前并不实用。

6.2 方法引用优于 Lambda 表达式

lambda 优于匿名类的主要优点是它更简洁。Java 提供了一种生成函数对象的方法,比 lambda 还要简洁,那就是:方法引用(method references)。

方法的参数越多,你可以通过方法引用消除更多的样板。 然而,在一些 lambda 中,选择的参数名称提供了有用的文档,使得 lambda 比方法引用更具可读性和可维护性,即使 lambda 看起来更长。

只要方法引用能做的事情,就没有 lambda 不能完成的(只有一种情况例外 - 如果你好奇的话,参见 JLS,9.9-2)。 也就是说,使用方法引用通常会得到更短,更清晰的代码。 如果 lambda 变得太长或太复杂,它们也会给你一个结果:你可以从 lambda 中提取代码到一个新的方法中,并用对该方法的引用代替 lambda。 你可以给这个方法一个好名字,并把它文档记录下来。

如果你使用 IDE 编程,它将提供替换 lambda 的方法,并在任何地方使用方法引用。通常情况下,你应该接受这个提议。偶尔,lambda 会比方法引用更简洁。

使用方法引用的代码段并不比使用 lambda 的代码片段更短也不清晰,所以优先选择后者。 在类似的代码行中,Function 接口提供了一个通用的静态工厂方法来返回标识函数 Function.identity()。 不使用这种方法,而是使用等效的 lambda 内联代码:x -> x,通常更短,更简洁。

总之,方法引用通常为 lambda 提供一个更简洁的选择。 如果方法引用看起来更简短更清晰,请使用它们;否则,还是坚持 lambda。

6.3 坚持使用标准的函数接口

java.util.Function 中有 43 个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。Operator 接口表示方法的结果和参数类型相同。Predicate 接口表示其方法接受一个参数并返回一个布尔值。Function 接口表示方法其参数和返回类型不同。Supplier 接口表示一个不接受参数和返回值 (或「供应」) 的方法。最后,Consumer 表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下:

接口 方法 示例
UnaryOperator<T> T apply(T t) String::toLowerCase
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

现在 Java 已经有了 lambda 表达式,因此必须考虑 lambda 表达式来设计你的 API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用 java.util.function.Function 中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。

6.4 谨慎使用Stream

Stream API 是在 Java 8 引入的,提供了一种简洁且富有表达力的方法来处理集合操作。虽然 Stream 有很多优点,但 Bloch 也指出了一些需要谨慎使用的地方。以下是书中关于谨慎使用 Stream 的几个要点:

  1. 可读性和维护性:Stream 的链式调用可以使代码看起来简洁且连贯,但如果链过长或逻辑过于复杂,会导致代码变得难以阅读和理解。过度使用 lambdas 和方法引用可能会使代码的意图不够清晰,尤其对于不熟悉 Stream API 的开发者来说。
  2. 性能问题:虽然 Stream 在某些情况下能够提升性能,但并非总是如此。对于简单的集合操作,传统的迭代可能比使用 Stream 更高效。特别是并行流(Parallel Stream),尽管可以利用多核处理器的优势提升性能,但在某些情况下,开启并行流的开销可能会超过其带来的性能提升。
  3. 调试困难:链式调用使得调试变得复杂。传统的 for 循环可以轻松地设置断点并检查每一步的状态,而 Stream 的链式调用在这一点上显得不够灵活。
  4. 错误处理:在 Stream 中处理异常并不直观,特别是检查型异常。使用 Stream 时,需要格外注意异常的处理,避免导致意外的错误和程序崩溃。

传统迭代:

java 复制代码
List<String> list = Arrays.asList("a", "b", "c", "d");
for (String s : list) {
    System.out.println(s.toUpperCase());
}

Stream API:

java 复制代码
List<String> list = Arrays.asList("a", "b", "c", "d");
list.stream()
    .map(String::toUpperCase)
    .forEach(System.out::println);

虽然两者在这个简单示例中都很清晰,但在更复杂的场景下,Stream 的链式调用可能会导致代码变得难以理解。

总结来说,Stream 是一个强大的工具,但在使用时需要注意其潜在的缺点和适用场景,保持代码的可读性和可维护性是首要任务。Joshua Bloch 在《Effective Java》中建议谨慎使用 Stream,正是为了避免因过度依赖这种工具而导致代码质量下降。

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

纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。

java 复制代码
// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
	words.forEach(word -> {
		freq.merge(word.toLowerCase(), 1L, Long::sum);
	});
}

forEach 操作应仅用于报告流计算的结果,而不是用于执行计算

Collectors有三个这样的收集器: toList() 、toSet() 和toCollection(collectionFactory) 。它们分别返回集合、列表和程序员指定的集合类型。

java 复制代码
// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
						.sorted(comparing(freq::get).reversed())
						.limit(10)
						.collect(toList());

toMap(keyMapper、valueMapper)最简单的映射收集器 ,它接受两个函数,一个将流元素映射到键,另一个映射到值。

java 复制代码
// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum =
	Stream.of(values()).collect(toMap(Object::toString, e -> e));

toMap 的三个参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行 last-write-wins 策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的:

java 复制代码
// Collector to impose last-write-wins policy
final Map<String, Apple> singleMap = 
	list.stream().collect(Collectors.toMap(Apple::getColor, it -> it, (oldVal, newVal) -> newVal));

toMap 的第三个也是最后一个版本采用第四个参数,它是一个 map 工厂,用于指定特定的 map 实现,例如EnumMap 或 TreeMap。
groupingBy 方法,该方法返回收集器以生成基于分类器函数(classifier function) 将元素分组到类别中的 map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的 map 的键。

java 复制代码
Map<String, Long> freq = 
	words.collect(groupingBy(String::toLowerCase, counting()));

join ,它仅对 CharSequence 实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。

java 复制代码
List<String> items =
        Arrays.asList("apple", "apple", "banana");
String str = 
		items.stream().collect(Collectors.joining(",", "[", "]"));

6.6 Stream 要优先用 Collection 作为返回类型

许多方法都返回元素的序列。在Java8之前,这类方法明显的返回类型是集合接口Collection、Set和List;Iterable;以及数组类型。一般来说,很容易确定要返回这其中哪一种类型。标准是一个集合接口。如果某个方法只为for-each循环或者返回序列而存在,无法用它来实现一些Collection方法(一般是contains(Objetc)),那么就用Iterable接口吧。如果返回的元素是基本类型值,或者有严格的性能要求,就是用数组。在Java8中增加了Strema,本质上导致给序列化返回的方法选择适当返回类型的任务变得更复杂了。

在编写返回一系列元素方法时,要记住有些用户可能想要当作Stream处理,而其他用户可能想要使用迭代。要尽量两边兼顾。如果可以返回集合,就返回集合。如果集合中已经有元素,或者序列中的元素数量很少,足以创建一个新的集合,那么就返回一个标准的集合,如ArrayList。否则就要考虑实现一个定制的集合。如幂集范例中所示。如果无法返回集合,就返回Stream或者Iterable,感觉哪一种更自然即可。如果在未来的Java发行版本中,Stream接口声明被修改成扩展了Iterable接口,就可以放心的返回Stream了,因为他们允许进行Stream处理和迭代。

6.7 谨慎使用 Stream 并行

在主流的编程语言中,Java一直走在简化并发编程任务的最前沿。1996年Java发布时,就通过同步和wait/notify内置了对线程的支持。Java5引入了java.util.concurrent类库,提供了并行集合(concurrent collection)和执行者框架(executor framework)。Java7引入fork-join包,这是一个处理并行分解的高性能框架。Java8引入Stream,只需要调用一次parallel方法就可以实现并行处理。在Java中编写并发程序变得越来越容易,但是要编写出正确又快速的并发程序,则一向没那么简单。安全性和活性失败是并发编程中需要面对的问题,Stream pipeline并行也不例外。

请看摘自第45条的这段程序:

java 复制代码
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
  primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) 
    .filter(mersenne -> mersenne.isProbablePrime(50))
    .limit(20)
    .forEach(System.out::println);
}
static Stream<BigInteger> primes() {
  return Stream.iterate(TWO, BigInteger::nextProbablePrime); 
}

在我的机器上,这段程序会立即开始打印素数,完成运行花了12.5秒。假设我天真的想通过在Stream pipeline上添加一个parallel()调用来提速。你认为这样会对其性能产生什么样的影响?运行速度会稍微块一点吗?还是会慢一点?遗憾的是,其结果是根本不打印任何内容,CPU的使用率却定在90%一动不动了(活性失败)。程序最后可能会终止,但是我们不想一探究竟,半个小时后就强行把它终止了。

这是怎么回事呢?简单的说,Stream类库不知道如何并行这个pipeline,以及如何探索失败。即便在最佳环境下,**如果源头是来自Stream.iterate,或者使用了中间操作的limit,那么并行pipeline也不可能提升性能**。这个pipeline必须同时满足这两个条件。更糟糕的是,默认的并行策略在处理limit的不可预知性时,是假设额外多处理几个元素,并放弃任何不需要的结果,这些都不会影响性能。在这种情况下,他查找每个梅森素数时,所花费的时间大概是查找之前元素的两倍。因而,额外多计算一个元素的成本,大概相当于计算所有之前元素总和的时间,这个貌似无伤大雅的pipeline,却使得自动并行算法濒临崩溃。这个故事的寓意很简单:**千万不要任意的并行Stream pipeline**。它造成的性能后果有可能是灾难性的。

总之,**在Stream上通过并行获得性能,最好是通过ArrayList、HashMap、HashSet和ConcurrentHashMap实例,数组,int范围和long范围等**。这些数据结构的共性是,都可以被精确、轻松的分成任意大小的子范围,使并行线程的分工变得更加轻松。Stream类库用来执行这个任务的抽象是分割迭代器,它是由Stream和Iterable中的spliterator方法返回的。

这些数据结构共有的另一项重要特性是,在进行顺序处理时,它们提供了优异的引用局部性:序列化的元素引用一起保存在内存中。被那些引用访问到的对象在内存中可能不是一个紧挨着一个,这降低了引用的局部性。事实证明,引用局部性 对于并发批处理来说至关重要:没有它,线程就会出现闲置,需要等待数据从内存转移到处理器的缓存 。具有最佳引用局部性的数据结构是基本类型数组,因为数据本身是相邻的保存在内存中的。

Stream pipeline的终止操作本质上也影响了并发执行的效率。如果大量的工作在终止操作中完成,而不是全部工作在pipeline中完成,并且这个操作是固有的顺序,那么并行pipeline的效率就会受到限制。并行的最佳终止操作是做减法(reduction),用一个Stream的reduce方法,将所有从pipeline产生的元素都合并在一起,或者预先打包像min、max、count和sum这类方法。短路操作anyMatch、allMatch和noneMatch也都可以并行。由Stream的collect方法执行的操作,都是可变的减法,不是并行的最好选择,因为合并集合的成本非常高。

如果是自己编写Stream、Iterable或者Collection实现,并且想要得到适当的并行性能,就必须覆盖spliterator方法,并广泛的测试结果Stream的并行性能。编写高质量的分割迭代器很困难,并且超出了本书的讨论范畴。
**并行Stream不仅可能降低性能,包括活性失败,还可以导致结果出错,以及难以预计的行为**(如安全性失败)。安全性失败可能是因为并行的pipeline使用了映射、过滤器或者程序员自己编写的其他函数对象,并且没有遵守它们的规范。Stream规范对于这些函数对象有着严格的要求条件。例如,传到Stream的reduce操作的收集器函数和组合器函数,必须是有关联、互不干扰,并且是无状态的。如果不满足这些条件(在第46条提到了一些),但是按序列运行pipeline,可能会得到正确的结果;如果并发运行,则可能会突发性失败。

以上值得注意的是,并行的梅森素数程序虽然运行完成了,但是并没有按正确的顺序(升序)打印出素数。为了保存序列化版本程序显示的顺序,必须用forEachOrdered代替终止操作的forEach,它可以确保按enconuter顺序遍历并行的Stream。

假如在使用的是一个可以有效分割的源Stream,一个可行的或者简单的终止操作,以及互不干扰的函数对象,那么将无法获得通过并行实现的提速,除非pipeline完成了足够的实际工作,抵消了与并行相关的成本。据不完全估计,Stream中的元素数量,是每个元素所执行的代码行数的很多倍,至少是十万倍。

切记:并行Stream是一项严格的性能优化。对于任何优化都必须在改变前后对性能进行测试,以确保值得这么做(详见第67条)。最理想的是在实现的系统设置中进行测试。一般来说,程序中所有的并行Stream pipeline都是在一个通用的fork-join池中运行的。只要有一个pipeline运行异常,都会损害到系统中其他不相关部分的性能。

听起来貌似在并行Stream pipeline时怪事连连,其实正是如此。我有一个朋友,他发现在大量使用Stream的几百万行代码中,只有少数几个并行Stream是有效的。这并不意味着应该避免使用并行Stream。**在适当的条件下,给Stream pipeline添加parallel调用,确实可以在多处理器核的情况下实现近乎线性的倍增**。某些域如机器学习和数据处理,尤其适用于这样的提速。

简单举一个并行Stream pipeline有效的例子。假设下面这个函数是用来计算π(n),素数的数量少于或者等于n:

java 复制代码
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
  return LongStream.rangeClosed(2, n)
    .mapToObj(BigInteger::valueOf)
    .filter(i -> i.isProbablePrime(50))
    .count();
}

在我的机器上,这个函数花31秒完成了计算π(108)。只要添加一个parallel()调用,就把调用时间减到了9.2秒:

java 复制代码
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
  return LongStream.rangeClosed(2, n)
    .parallel()
    .mapToObj(BigInteger::valueOf) 
    .filter(i -> i.isProbablePrime(50)) 
    .count();
}

换句话说,并行计算在我的四核机器上添加了parallel()调用后,速度加快了3.7倍。值得注意的是,这并不是在实践计算n值很大时的π(n)的方法。还有更加高效的算法,如著名的Lehmer公式。

如果要并行一个随机数的Stream,应该从SplittableRandom实例开始,而不是从ThreadLocalRandom(或实际上已经过时的Random)开始。SplittableRandom正是专门为此设计的,还有线性提速的可能。ThreadLocalRandom则只用于单线程,它将自身当作一个并行的Stream源运用到函数中,但是没有SplittableRandom那么快。Random在每个操作上都进行同步,因此会导致滥用,扼杀了并行的优势。

总而言之,尽量不要并行Stream pipelien,除非有足够的理由相信它能保证计算的正确性,并且能加快程序的运行速度。如果对Stream进行不恰当的并行操作,可能导致程序运行失败,或者造成性能灾难。如果确信并行是可行的,并行运行时一定要确保代码正确,并在真实环境下认真的进行性能测量。如果代码正确,这些实验也证明它有助于提升性能,只有这时候,才可以在编写代码时并行Stream。

7. 方法

本章要讨论方法设计的几个方面:如何处理参数和返回值,如何设计方法签名,如何为方法编写文档。本章大部分内容既适用于构造器,也适用于普通的方法。本章的焦点也集中在可用性、健壮性和灵活性上。

7.1 检查参数的有效性

绝大多数方法和构造器对于传递给他们的参数值都会有某些限制。例如索引值必须是非负数,对象引用不能为null,等等。应该在文档中清楚地指明所有这些限制,并且在方法体的开头出检查参数,以强制施加这些限制。这是"应该在发生错误之后尽快检测出错误"这一普遍原则的一个具体情形。

抛出异常

如果传递无效的参数值给方法,这个方法在执行之前先对参数进行了检查,那么它很快就会失败,并且清楚的抛出适当的异常。如果没有检查它的参数就有可能发生几种情况。该方法可能在处理过程中失败,并且产生令人费解的异常。更糟糕的是,该方法可以正常返回,但是返回的结果可能是一个错误的结果。最糟糕的情况是,该方法可以正常返回,但是却使得某个对象处于被破坏的状态,将来在某个不确定的时候,在某个不相关的点上会引发错误。

对于公有的方法,要用Javadoc的@throws标签 在文档中说明违反参数值限制时会抛出的异常。通常这样的异常为IllegalArgumentExceptionIndexOutOfBoundsException。一旦在文档中说明了异常,那么强加这些类型的异常检测就会是比较容易的事情,例子如下:

java 复制代码
/**
 * Returns a BigInteger whose value is {@code (this mod m}).  This method
 * differs from {@code remainder} in that it always returns a
 * <i>non-negative</i> BigInteger.
 *
 * @param  m the modulus.
 * @return {@code this mod m}
 * @throws ArithmeticException {@code m} ≤ 0
 * @see    #remainder
 */
public BigInteger mod(BigInteger m) {
    if (m.signum <= 0)
        throw new ArithmeticException("BigInteger: modulus not positive");

    BigInteger result = this.remainder(m);
    return (result.signum >= 0 ? result : result.add(m));
}

断言

对于未被导出的方法,也就是该方法不会被客户端代码调用的方法,作为包的创建者,你可以控制这个方法将在哪些情况下被调用,因此你可以确保只将有效的参数值传递进来。因此非公有的方法应该使用断言(Assertion)来检查参数。具体做法如下:

java 复制代码
private static void sort(long a[], int offset, int length) {
	assert a != null;
	assert offset >= 0 && offset <= a.length;
	assert length >= 0 && length <= a.length - offset;
}

小结

总之,每当编写方法或者构造器的时候,应该考虑它的参数有哪些限制。应该把这些限制写在方法上面的注释文档中,并且考虑实施这些有效性检查的开销,权衡利弊,最后进行显示的来实施这些有效性的检查。养成这样的习惯非常重要。

7.2 必要时进行保护性拷贝

JAVA 是一门安全的语言。这就意味着,它对缓冲区溢出、数组越界、非法指针以及其它的内存破坏错误都自动免疫,而这些错误却困扰着诸如 C 和 C++ 这样的不安全的语言。在一门安全的语言中,可以确切的知道,无论系统的其他部分发生什么事,这些类的约束都可以保持为真。对于那些"把内存当做一个巨大数组看待"的语言来说,这是不可能的。

假设类的客户端会尽其所能来破坏这个类的约束条件,因此你必须保护性的设计程序。考虑下列类,它声称可以表示一段不可变的时间周期:

java 复制代码
public final class Period {
    private final Date start;
    private final Date end;
    public Period(Date start, Date end) {
        if (start.compareTo(end) > 0) {
            throw new IllegalArgumentException(start + " after " + end);
        }
        this.start = start;
        this.end = end;
    }
    public Date start() { return start; }
    public Date end() { return end; }
}

这个类看上去没有什么问题,时间是不可改变的。然而 Date 类本身是可变的,因此很容易违反这个约束条件。

java 复制代码
// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period period = new Period(start, end);
end.setYear(78);
System.out.println(period.end());

构造方法保护性拷贝

为了保护 Period 实例的内部信息避免受到修改,导致问题,对于构造器的每个可变参数进行保护性拷贝(defensive copy)是必要的:

java 复制代码
public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
    if (this.start.compareTo(this.end) > 0) {
        throw new IllegalArgumentException(this.start + " after " + this.end);
    }
}

用了新构造器之后,上面的攻击对于 Period 实例不再有效。保护性拷贝是在检查参数合法性之前进行的,而且参数的合法性的检查是针对保护性拷贝之后的对象,而不是针对原始对象。这样做是为了避免危险阶段来自另外一个线程的修改参数。危险阶段指的是检查参数开始,直至保护性拷贝参数之间的时间。

对于参数类型可以被不可信任方子类化的参数,请不要使用 clone 方法进行保护性拷贝。

虽然替换构造器就可以成功地避免上述的攻击,但是改变 Period 实例仍然是有可能的,因为它的访问方法提供了对其可变内部成员的访问能力:

java 复制代码
// Second attack on the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period period = new Period(start, end);
end.setYear(78);
System.out.println(period.end());

getter 方法保护性拷贝

为了防止二次攻击,可以让 end() 返回拷贝对象:

java 复制代码
public Date end() {
    return new Date(end.getTime());
}

采用了新的构造器和新的访问方法之后,Period 真正是不可变的了。

参数的保护性拷贝不仅仅针对不可变类。每当编写方法和构造器时,如果他要允许客户提供的对象进入到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的,我是否能够容忍这种可变性。特别是你用到 List、Map 之类连接元素时。

小结

如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性的拷贝这些组件。如果拷贝的成本受到限制,并且类信任他的客户端不会进行修改,或者恰当的修改,那么就需要在文档中指明客户端调用者的责任(不的修改或者如何有效修改)。特别是当你的可变组件的生命周期很长,或者会多层传递时,隐藏的问题往往暴漏出来就很可怕。
当我们准备实现一个具有特殊约束条件类的时候,假设类的客户端会尽其所能的破坏这个类的约束条件,因此我们必须保护性设计程序。

7.3 谨慎设计方法签名

本条目是若干API设计技巧的总结,它们都还不足以单独开设一个条目。综合来说这些设计技巧将有助于使你的API更易于学习和使用,并且比较不容易出错。

谨慎选择方法名称

方法的名称应该始终遵循标准的命名习惯。首要目标应该是选择易于理解的,并且与同一个包中的其他名称风格一致的名称。第二个目标应该是选择广泛认可的名称(如果存在的话)相一致的名称。避免使用长的方法名称。如果还有疑问,请参考Java库的API。虽然它们存在许多不一致的地方,考虑到这些库的规模和范围,这是不可避免的,但它还是得到了相当程度的认可。

不要过于追求提供便利的方法

每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口而言,这无疑是正确的,方法太多会使接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到的时候,才考虑为它提供快捷方式(shorthand)。如果不能确定,还是不要提供快捷方式为好。

避免过长的参数列表

目标是四个参数,或者更少。大多数程序猿都无法记住更长的参数列表。如果你编写的许多方法都超过了这个限制,你的 API 就不太便于使用,除非用户不停地参考它的文档。现代的 IDE 会有所帮助,但最好还是使用简短的参数列表。相同类型的长参数序列格外有害。 用户不仅无法记住参数的顺序,而且,当他们不小心弄错了参数顺序时,他们的程序仍然可以编译和运行,只不过这些程序不会按照作者的意图进行工作。

从对象构建到方法调用建议采用 Builder 模式。如果方法带有多个参数,尤其是当它们中有些是可选的时候,最好定义一个对象来表示所有参数,并允许客户端在这个对象上进行多次"setter"调用,每次调用都设置一个参数,或者设置一个较小的相关的集合。一旦设置了需要的参数,客户端就调用对象的"执行(execute)"方法,它对参数进行最终的有效性检查,并执行实际的计算。

对于参数类型,要优先使用接口而不是类

只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如,没有理由在编写方法时使用 HashMap 类来作为输入,相反,应当使用 Map 接口作为参数。这使你可以传入一个 HashMap、TreeMap、 ConcurrentHashMap、TreeMap 的子映射列表(submap),或者尚未编写的任何 Map 实现。如果使用的是类而不是接口,则限制了客户端只能输入的特定实现,如果碰巧输入的数据是以其他的形式存在,就会导致不必要的、可能非常昂贵的拷贝操作。

7.4 慎用重载

应该避免胡乱的使用重载机制。

**安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数(varargs),保守的策略是根本不要重载他。**如果你遵守这些限制,程序员永远也不会陷入到"对于任何一组实际的参数,哪个重载方法是适用的"这样的疑问中。这项限制并不麻烦,因为你始终可以给方法起不同的名称,而不使用重载机制。

例如,考虑 ObjectOutputStream类。对于每个基本类型,以及几种引用类型,他的write方法都有一种变形。这些变形方法并不是重载write方法,而是具有诸如writeBoolean(boolean)writeInt(int)writeLong(long)这样的签名。与重载方案比较,这种命名模式带来的好处是,有可能提供相应名称的读方法,比如readBoolean()readInt()readLong()。实际上,ObjectInputStream类正是提供了这样的读方法。

对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器。对于构造,还不用担心重载和覆盖的相互影响,因为构造器不可能被覆盖。或许你有可能导出多个具有相同参数数目的构造器,所以有必要了解一下如何安全的做到这一点。

如果对于"任何一组给定的实际参数将应用于哪个重载方法上"始终非常清楚,那么,导出多个具有相同参数数目的重载方法就不可能使程序员感到混淆。如果对于每一对重载方法,至少有一个对应的参数在两个重载方法中具有"根本不同"的类型,就属于这种情形。如果显然不可能把一种类型的实例转换为另一种类型,这两种类型就是根本不同的。在这种情况下,一组给定的实际参数应用于哪个重载方法上就完全由参数的运行时类型来决定,不可能受到其编译时类型的影响,所以主要的混淆根源就消除了。

简而言之,"能够重载方法"并不意味着就"应该重载方法"。一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法。如果不能避免这种情形,例如,因为正在改造一个现有的类以实现新的接口,就应该保证:当传递同样的参数时,所有重载方法的行为必须一致。如果不能做到这一点,程序员就很难有效地使用被重载的方法或者构造器,他们就不能理解他为什么不能正常的工作。

7.5 慎用可变参数

JDK 1.5 发行版本中增加了可变参数方法。可变参数方法接受0个或者多个指定类型的参数。

可变参数机制通过先创建一个数组,数组的大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。

这样由于可变参数的方法,可变参数时借助数组实现的的,所有调用可变参数的方法时,我们可以传入若干个参数,也可以传入保存

有若干个参数的数组。

对于可变参数使用,比较典型的一个方法是:Arrays.asList(T. . . args)

我们调用这个方法时可以这样: List list = Arrays.asList(1,2,3);

也可以这样: Integer[] i = new Integer[]{1,2,3} ; List list = Arrays.asList(i);

需要一点需要注意 当我们给传入一个引用类型数组时,会将数组中的对象分别当成一个对象,存入集合中。

当我们传入一个基本类型的数组时,会将这个数组当成一个对象存入集合中。

int[] i = new int[]{1,2,3};

List<int[]> list = Arrays.asList(i);

分析Arrays.asList(T. . . args)的源码可知,是创建了一个ArrayList,而泛型集合不能保存基本类型,所以程序只能讲基本类型数组当成一个

对象,存进集合,因为不管是基本类型数组还是引用类型的数组,都是引用类型。

简而言之,在定义参数数目不定的方法时,可变参数是一个很方便的方式,但是它们不应该被过度滥用,如果使用不当,会产生混乱的结果。

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

很多方法需要返回一个数组或集合

比如常见的 service 方法中,我们需要查询满足条件的集合

java 复制代码
public List<T> getByNameLike(String name){
    return mapper.selectByNameLike(name);
} 

在非常多的返回集合的场景中,null 与 0长度集合都表示没有符合条件的结果,也就是多数时候,他们是等价的,而本条目讨论的就是这种情况。

至于说 null 和空集合意义不同的场景,那就无需讨论,该返回什么就返回什么。

返回 null 有一个比较直接的优势,性能优势,避免了分配这一点点内存,当然也就节省了后续的一点点 gc

永远返回 0 长度的集合则在后续的直接操作中非常方便,可以直接循环或者 调用相应方法

与之相对的,当然就是空集合或数组的分配内存开销,不过,这种开销是非常微弱的,对性能的影响微乎其微,除非是这个方法在程序中执行占比非常之大。大多数时候,实际测试,你会发现,这个方法循环几百万遍甚至几千万遍。也就是外部交互与内存中操作的性能差异在好几个数量级。

即便这个方法真的是非常常用的,比如工具类一样的函数,依然有办法去优化,去尽量缩小这样的性能差异。

java 复制代码
public List<T> getByNameLike(String name){
    List<T> list =  mapper.selectByNameLike(name);
    if( list == null) {
        return Collections.emptyList();
    }
    return list;
} 

简而言之,永远不要返回 null,而不是返回一个零长度的数组或集合。如果返回 null,那样会使 API 更难以使用,也更容易出错,而且没有任何性能优势。

7.7 谨慎返回 Optional

在Java 8之前,方法无法返回值时,开发者通常选择抛出异常或返回null。但这两种方式都有缺点:抛出异常开销高,而返回null可能导致NullPointerException。Java 8引入了Optional类,提供了一种新的方式来处理无法返回值的情况。Optional可以包含一个非null的值或为空,迫使调用者处理可能没有返回值的情况。 使用Optional有以下优点:

  • 比抛出异常更灵活、更易用。
  • 比返回null更不容易出错。

选择返回Optional、null或抛出异常的依据是:

  • 如果方法可能无法返回值,并且调用者需要特殊处理这种情况,应返回Optional。
  • 返回Optional有性能开销,不适用于注重性能的方法。
  • 对于基本类型,应使用OptionalInt、OptionalLong和OptionalDouble,避免返回基本包装类型的Optional。

Optional的几种常见用法包括:

  • orElse:提供默认值。
  • orElseGet:在需要时计算默认值。
  • orElseThrow:抛出异常。
  • map、flatMap、filter:处理Optional中的值。

不应该用Optional作为容器类型的元素或映射的值,也不适合用作键。在某些情况下,将Optional用作对象实例的域是合理的。

总之,如果方法可能无法返回值,并且调用者需要处理这种情况,应考虑返回Optional。但需要注意性能影响,并且避免将Optional用于除返回值以外的其他用途。

7.8 为所有导出的 API 元素编写文档注释

编写API文档是确保其可用的必要条件。Java提供了Javadoc工具,可以自动生成API文档,这减轻了手工编写文档的负担。文档注释不是Java语言正式的一部分,但已经成为程序员必须掌握的标准。正确的文档注释应该包含在每个导出的类、接口、构造器、方法和域之前。对于可序列化的类,也应为其序列化形式编写文档。

方法的文档注释应简洁描述其与客户端之间的约定,包括前提条件、后置条件和副作用。每个参数应有一个@param标签,返回值(除非是void)应有一个@return标签,抛出的每个异常(受检或未受检)应有一个@throws标签。文档注释中可以使用HTML标签,但要注意处理HTML元字符。

Java 8引入了@implSpec标签,用于描述方法与其子类之间的约定。Java 9引入了@index标签,用于索引API文档中的重要条件。文档注释应特别注意泛型、枚举和注解的描述。

包级私有的文档注释应放在package-info.java文件中。API的线程安全性和可序列化性应在文档中说明。Javadoc可以继承超类或接口的文档注释,减少重复工作。

为API编写文档时,除了文档注释,还可能需要额外的外部文档来描述API的整体结构。Javadoc提供了一些自动检测功能,有助于遵循文档注释的规范。生成的文档应使用HTML有效性检查器进行验证。

总的来说,为所有导出的API元素编写文档注释是强制性的,应采用一致的风格并遵循标准约定。阅读由Javadoc生成的网页是检验文档质量的最好方式。

8. 通用编程

本章主要讨论Java语言的细枝末节,包含局部变量的处理、控制结构、类库的用法各种数据类型的用法,以及两种不是由语言本身提供的机制(反射机制和本地方法)的用法。最后讨论了优化和命名惯例。

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

  1. 局部变量的作用域应尽可能小,以提高代码的可读性、可维护性,并减少出错的可能性。
  2. 应在局部变量第一次使用的地方声明它,避免提前声明导致作用域不必要地扩大。
  3. 局部变量声明时最好包含初始化表达式,除非是try-catch语句中的特定情况。
  4. 在循环中使用for循环而非while循环,因为for循环可以更好地限定循环变量的作用域,减少错误发生的概率,并提高代码的可读性。
  5. 在循环中,如果循环测试涉及方法调用,应将结果保存在循环变量中,避免在每次迭代中重复计算。
  6. 为了最小化局部变量的作用域,应编写小而集中的方法,将不同操作分解到不同的方法中。

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

传统循环方式的缺点

  • 遍历集合时,迭代器在每个循环中出现三次,增加了出错的可能。
  • 遍历数组时,索引变量在每个循环中出现四次,同样增加了出错的可能。
  • 这两种循环方式在更改容器类型时带来额外的复杂性。

for-each循环的优势

  • 简洁性: for-each循环隐藏了迭代器和索引变量,避免了混乱和错误。
  • 一致性: 无论是集合还是数组,for-each循环的语法一致,简化了代码转换。
  • 性能: for-each循环不会有性能损失,生成的代码与手工编写的传统for循环代码本质上相同。

for-each循环的应用场景

  • 遍历集合和数组:
java 复制代码
for (Element e : elements) {
  ... // 对e进行操作
}

for-each循环在嵌套迭代中的优势

  • 避免错误: 在嵌套迭代中,传统循环容易出错,for-each循环则能有效避免这些错误。
java 复制代码
for (Suit suit : suits)
  for (Rank rank : ranks) 
    deck.add(new Card(suit, rank));

for-each循环的局限性

有三种情况无法使用for-each循环:

  1. 解构过滤: 需要删除选定元素时,必须使用显式迭代器的remove方法。
  2. 转换: 需要替换列表或数组的部分或全部值时,必须使用列表迭代器或数组索引。
  3. 平行迭代: 需要并行遍历多个集合时,必须显式控制迭代器或索引变量。

结论

for-each循环在简洁性、灵活性以及预防错误方面相对于传统for循环具有显著优势,并且没有性能惩罚。因此,在可以选择的情况下,应优先使用for-each循环。

8.3 了解和使用类库

本文详细阐述了使用标准库而非自定义实现的一些常见任务的优点,以随机数生成为例,说明了自定义方法的缺点和潜在问题,并强调了使用标准库的诸多好处。以下是主要内容的总结:

自定义随机数生成方法

java 复制代码
// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
  return Math.abs(rnd.nextInt()) % n;
}

缺点:

  • 周期性重复: 当n是小的2的幂时,随机数序列会很快重复。
  • 不均匀分布: 当n不是2的幂时,有些数字比其他数字出现频率更高。
  • 潜在的灾难性失败: Math.abs可能会返回负数,导致取模操作返回负数。

标准库生成随机数的优点

  • 可靠性和正确性: Random.nextInt(int)方法已经经过详细设计、实现和测试,保证了其正确性。
  • 性能优化: 标准库的方法往往经过优化,性能优于自定义实现。Java7开始推荐使用ThreadLocalRandom,比Random更快、更高效。
  • 功能更新: 标准库随着时间推移不断改进,增加新功能,提高性能。
  • 代码质量和维护性: 使用标准库的代码更易读、易维护,并且更容易被其他开发者重用。

标准库的使用案例

例如使用Java9引入的InputStream.transferTo方法简化读取URL内容的操作:

java 复制代码
public static void main(String[] args) throws IOException {
  try (InputStream in = new URL(args[0]).openStream()) { 
    in.transferTo(System.out);
  } 
}

选择第三方类库

如果标准库不能满足需求,考虑使用高级第三方类库,如Google的Guava。如果仍不能满足需求,再考虑自定义实现。

总结

不要重复发明轮子。利用现成的、可靠的标准库代码,不仅可以提高代码质量和效率,还可以随时间推移享受库的改进和优化。标准库的代码通常比自己实现的更好,更能经受住时间和广泛使用的考验。

8.4 如果需要精确的答案,请避免使用 float 和 double

浮点类型的缺陷

  1. 精度问题: float和double不能精确表示0.1或10的负次方。例如,1.03 - 0.42输出0.610000000001,而不是预期的0.61。
  2. 舍入误差: 在进行多次运算后,结果可能会出现明显的误差。例如,1.00 - 9 * 0.10输出0.09999999999999998,而不是0.1。
  3. 累积误差: 连续的小数计算会导致误差积累,影响最终结果。例如,计算买糖果的数量时会出错。

解决方案

使用BigDecimal
  • 精确度高: 适用于需要精确计算的场合。
  • 使用方式: 通过String构造器创建BigDecimal对象,避免精度问题。

示例代码:

java 复制代码
public static void main(String[] args) {
  final BigDecimal TEN_CENTS = new BigDecimal(".10"); 
  int itemsBought = 0;
  BigDecimal funds = new BigDecimal("1.00");
  for (BigDecimal price = TEN_CENTS; funds.compareTo(price) >= 0; price = price.add(TEN_CENTS)) { 
    funds = funds.subtract(price); 
    itemsBought++;
  }
  System.out.println(itemsBought + " items bought."); 
  System.out.println("Money left over: $" + funds);
}
使用整数类型(int或long)
  • 性能高: 适用于性能敏感的场合。
  • 处理方法: 使用整数表示货币值,自己管理小数点位置。

示例代码:

java 复制代码
public static void main(String[] args) {
  int itemsBought = 0;
  int funds = 100; // 表示1.00美元
  for (int price = 10; funds >= price; price += 10) {
    funds -= price;
    itemsBought++; 
  }
  System.out.println(itemsBought + " items bought.");
  System.out.println("Cash left over: " + funds + " cents");
}

总结

  1. 避免使用浮点类型: 对于任何需要精确答案的计算任务,不要使用float或double。
  2. 使用BigDecimal: 适用于需要高精度和控制舍入的场合。
  3. 使用整数类型: 适用于性能关键且不介意自己处理小数点的场合。
  4. 选择依据: 根据需要的精度、性能要求和数值范围选择合适的类型。如果数值范围较小,使用int或long;否则使用BigDecimal。

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

Java 的类型系统包括两部分:基本类型(primitive type)和引用类型(reference type)。基本类型包括 intdoubleboolean 等,而引用类型包括 StringList。每个基本类型都有对应的装箱类型(boxed type),如 Integer 对应 intDouble 对应 doubleBoolean 对应 boolean

基本类型与装箱类型的区别

  1. 同一性和值的区别:
    • 基本类型只有值,没有同一性。
    • 装箱类型有值和同一性。两个装箱类型可能有相同的值,但不同的同一性。
  2. 非函数值:
    • 基本类型只有函数值。
    • 装箱类型除了函数值外,还有 null 作为非函数值。
  3. 性能:
    • 基本类型通常比装箱类型更高效,节省时间和空间。

问题示例与解决方案

  1. 错误的比较器示例:
java 复制代码
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
java 复制代码
Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> { 
  int i = iBoxed, j = jBoxed;
  return i < j ? -1 : (i == j ? 0 : 1);
};
  • 解决方法:在基本类型上进行比较操作。
  1. 空指针异常示例:
java 复制代码
public class Unbelievable { 
  static Integer i;
  public static void main(String[] args) { 
    if (i == 42)
      System.out.println("Unbelievable"); 
  }
}
  • 解决方法:将 i 声明为基本类型 int 而不是 Integer
  1. 性能问题示例:
java 复制代码
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
  Long sum = 0L;
  for (long i = 0; i < Integer.MAX_VALUE; i++) { 
    sum += i;
  }
  System.out.println(sum); 
}
  • 解决方法:将 sum 声明为基本类型 long 而不是 Long

使用装箱类型的注意事项

  • 集合元素、键和值: 基本类型不能直接用作集合的元素、键或值,必须使用装箱类型。
  • 参数化类型: 参数化类型和方法(如泛型)必须使用装箱类型,因为 Java 不允许使用基本类型作为类型参数。
  • 反射调用: 反射方法调用必须使用装箱类型。

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

避免用字符串来表示对象,如果可以使用更加适合的数据类型或编写适当的数据类型。字符串替代其他类型会更笨拙、不灵活、速度更慢且容易出错。常见错误替代的类型包括基本类型、枚举类型和聚合类型。

了解字符串连接的性能

字符串连接操作符(+)是把多个字符串合并为一个字符串的便利途径。要想产生单独一行的输出,或者构造一个字符串来表示一个较小的、大小固定的对象,使用连接操作符是非常合适的,但是它不适合运用在大规模的场景中。**为连接n个字符串而重复的使用字符串连接操作符,需要n的平方级的时间**。这是由于字符串不可变而导致的不幸结果。当两个字符串被连接在一起时,它们的内容都会被拷贝

例如,下面的方法通过重复的为每个项目连接一行,构造出一个代表该账单声明的字符串:

java 复制代码
// Inappropriate use of string concatenation - Performs poorly!
public String statement() {
  String result = "";
  for (int i = 0; i < numItems(); i++)
    result += lineForItem(i); // String concatenation 
  return result;
}

如果项目的数量巨大,这个方法的执行时间就难以估算。**为了获得可以接受的性能,请用StringBuilder代替String**,来存储构造过程中的账单声明:

java 复制代码
public String statement() {
  StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
  for (int i = 0; i < numItems(); i++)
    b.append(lineForItem(i)); 
  return b.toString();
}

从Java6以来,已经做了大量的工作使字符串连接变得更加快速,但是上述两种做法的性能差别还是很大:如果numItems返回100,并且lineForItem返回一个固定长度为80个字符的字符串,在我的机器上,第二种做法比第一种做法要块6.5倍。因为第一种做法的开销随项目数量而呈平方级增加,项目的数量越大,性能的差别就会越明显。注意,第二种做法预先分配了一个StringBuilder,使它大到足以容纳整个结果字符串,因此不需要自动扩展。即使使用默认大小的StringBuilder,它也仍然比第一种做法快5.5倍。

原因很简单:**不要使用字符串连接操作符来合并多个字符串**,除非性能无关紧要。否则,应该使用StringBuilder的append方法。另一种做法是使用字符数组,或者每次只处理一个字符串,而不是将它们组合起来。

8.7 通过接口引用对象

使用接口而不是类作为参数类型的建议

总原则

优先使用接口而不是类来引用对象。对于参数、返回值、变量和域,应尽可能使用接口类型进行声明。只有在创建对象时,才需要引用对象的类。

示例
java 复制代码
// Good - uses interface as type
Set<Son> sonSet = new LinkedHashSet<>();

// Bad - uses class as type!
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

使用接口类型声明变量,可以让程序更灵活。例如,后续可以轻松地更换实现而无需修改周围代码:

java 复制代码
Set<Son> sonSet = new HashSet<>();
注意事项
  1. 特殊功能依赖 :如果代码依赖于某个实现类的特殊功能(非接口通用约定),则更换实现时需确保新实现提供相同功能。例如,依赖 LinkedHashSet 的同步策略时,不能随意更换为 HashSet
  2. 性能优化和功能扩展 :更换实现类型可能出于性能优化或功能需求。例如,将 HashMap 更换为 EnumMapLinkedHashMap 可以提供更好的性能和可预估的迭代顺序。
  3. 保持代码编译性:使用接口声明变量,可以避免因为实现类型变化导致的编译错误。修改实现时,只需更改构造器中的类名。
使用类作为引用类型的例外
  1. 缺少合适的接口 :对于没有多个实现的值类(如 StringBigInteger),以及框架的基本类型为类(如 java.io.OutputStream),应直接使用类引用对象。
  2. 额外方法依赖 :如果类实现了接口但提供了额外的方法(如 PriorityQueuecomparator 方法),且程序依赖于这些方法,才应使用类引用对象。这种情况较少见。

8.8 接口优先于反射机制

概述

Java的反射机制(java.lang.reflect包)提供了通过程序访问任意类的能力。通过反射,可以获取类的构造器、方法和字段实例,并使用这些实例操作底层类的成员。

优点
  1. 动态访问:允许在编译时未知的类进行动态访问和操作。
  2. 灵活性:允许一个类在运行时使用另一个类,即使当前者被编译时后者还不存在。
缺点
  1. 损失编译时类型检查:包括异常检查。如果调用不存在或不可访问的方法,程序在运行时会失败。
  2. 代码冗长且难读:反射访问所需代码笨拙且冗长,编写和阅读都不方便。
  3. 性能损失:反射方法调用比普通方法调用慢很多,具体慢多少取决于多种因素。
使用场景

反射机制通常在代码分析工具和依赖注入框架等复杂应用程序中使用,但这些工具逐渐减少对反射的依赖,因为其缺点日益显著。

限制使用反射的建议
  1. 有限使用:尽量少量使用反射,仅在必要时使用。例如,通过反射创建实例,然后使用编译时已知的接口或超类访问这些实例。
  2. 实例化示例
java 复制代码
// 反射实例化与接口访问
public static void main(String[] args) {
    Class<? extends Set<String>> cl = null;
    try {
        cl = (Class<? extends Set<String>>) Class.forName(args[0]);
    } catch (ClassNotFoundException e) { 
        fatalError("Class not found.");
    }

    Constructor<? extends Set<String>> cons = null; 
    try {
        cons = cl.getDeclaredConstructor(); 
    } catch (NoSuchMethodException e) {
        fatalError("No parameterless constructor"); 
    }

    Set<String> s = null; 
    try {
        s = cons.newInstance();
    } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
        fatalError("Instantiation error"); 
    } catch (ClassCastException e) {
        fatalError("Class doesn't implement Set"); 
    }

    s.addAll(Arrays.asList(args).subList(1, args.length)); 
    System.out.println(s);
}

private static void fatalError(String msg) {
    System.err.println(msg);
    System.exit(1); 
}

该示例展示了如何通过反射机制创建一个Set<String>实例,并操作其成员。虽然反射使代码冗长且复杂,但一旦对象被实例化,它与普通实例无异。

  1. 避免运行时错误:反射会导致一些运行时错误,如类未找到、方法未找到等,这些在非反射方式下会变成编译时错误。
  2. 使用反射的合理情形
    • 依赖于多个版本的包:在最小支持环境下编译,通过反射访问新的类或方法。
    • 动态加载和使用类:在运行时动态加载并使用类时,通过反射实例化对象,再通过接口或超类操作。
总结

反射机制功能强大,适用于特定复杂系统编程任务,但应谨慎使用。优先使用反射实例化对象,随后通过已知接口或超类操作对象,以最大限度减少反射带来的缺点。

8.9 谨慎使用本地方法

Java Native Interface (JNI) 允许Java应用程序调用用本地编程语言(如C或C++)编写的方法。这些本地方法可以访问特定于平台的机制、遗留代码库,并可以编写性能关键的代码以提高系统性能。

使用场景

  1. 访问特定于平台的机制:合法但通常不必要。Java平台不断成熟,提供了许多以前只有宿主平台才有的特性。
  2. 访问遗留代码库:当Java中没有相应的库时,可以通过本地方法使用遗留代码库。
  3. 性能优化:不推荐使用本地方法来优化性能。JVM性能已经大幅提升,用Java编写的代码通常能达到良好的性能。

优缺点

优点

  • 平台特定功能访问:可访问Java平台未提供的特定功能。
  • 遗留系统集成:可以利用现有的遗留代码库。
  • 性能优化:在极少数情况下,使用本地语言优化性能关键的部分。

缺点

  • 安全性:本地语言不安全,可能引发内存损坏错误。
  • 移植性:使用本地方法的应用程序不再是可自由移植的。
  • 调试困难:本地方法的调试比纯Java代码困难。
  • 性能影响:可能降低性能,垃圾回收机制对本地内存的管理存在问题,进入和退出本地代码的开销较大。
  • 编写复杂:本地方法需要编写胶合代码,单调乏味且难以阅读。

具体示例

Java中的BigInteger最初用C实现以提高性能,但后来完全用Java重写,并进行了性能调优,结果比本地实现更快。然而,随着时间的推移,其他高性能的多精度运算库(如GMP)继续发展,对于需要高性能高精度运算的场合,使用本地方法调用GMP库也是合理的。

总结

在使用JNI前需慎重考虑。尽量避免使用本地方法进行性能优化,只有在需要访问底层资源或遗留代码库时才使用本地方法。即便如此,也应尽量减少本地代码的使用,并对其进行全面测试,以确保整个应用程序的稳定性。

8.10 谨慎的进行优化

三条格言

  1. 性能问题常被误认为效率问题:很多计算错误被归因于效率问题,而非其他原因。
  2. 不成熟的优化是根本问题:不要为小的效率得失斤斤计较,97%的情况下,不成熟的优化才是问题根源。
  3. 优化规则
    • 规则一:不要进行优化。
    • 规则二(仅针对专家):在没有清晰未优化方案前,不要优化。

合理的设计

  • 优先编写好的程序:好的程序如果不够快,其良好的结构使其更容易优化。
  • 信息隐藏原则:把设计决策集中在单个模块中,以便改变决策不影响其他部分。

设计中的性能考量

在设计过程中考虑性能问题,避免设计决策对性能的限制。

  • API设计的性能后果:API设计会影响性能,应该慎重考虑。
  • 模块交互和数据格式:难以更改且影响性能,需要在设计时充分考虑。

优化的时机与方法

  1. 先编写结构良好的程序:如果性能不满意再考虑优化。
  2. 测量性能:每次优化前后都要测量性能,避免盲目优化。
  3. 使用性能剖析工具:确定优化重点和警告算法问题,找出程序中性能瓶颈。

8.11 遵守普遍接受的命名惯例

标准命名惯例应视为一种内在机制。字面惯例直接明确,语法惯例更复杂松散。应遵循公认的命名惯例,不盲目遵从不符合长期习惯的命名规则。

字面命名惯例

  1. 包和模块名称
    • 层次结构,用句号分隔,每部分小写字母,极少数情况下有数字。
    • 包名应以组织的互联网域名开头,如edu.cmu、com.google、org.eff。
    • 组成部分简短(不超过8个字符),可以使用有意义的缩写,如util、awt。
  2. 类和接口名称
    • 每个单词首字母大写,如List、FutureTask。
    • 避免缩写,除非是通用缩写如max、min。
    • 首字母缩写形式(如HTTPURL vs HttpUrl)建议只首字母大写。
  3. 方法和域名称
    • 与类和接口命名一致,但第一个字母小写,如remove、ensureCapacity。
    • 常量域(static final)使用大写字母和下划线分隔,如VALUES、NEGATIVE_INFINITY。
  4. 局部变量名称
    • 类似成员名称,但允许缩写和单字符,如i、denom、houseNum。
  5. 类型参数名称
    • 通常为单个字母,如T、E、K、V、X、R,表示不同类型。

语法命名惯例

  1. 类和接口
    • 类(包括枚举)和接口用名词或名词短语命名,如Thread、PriorityQueue。
    • 工具类常用复数名词,如Collectors、Collections。
    • 接口可以用形容词结尾,如Runnable、Iterable。
  2. 方法
    • 执行动作的方法用动词或动词短语命名,如append、drawImage。
    • 返回boolean值的方法常以is或has开头,如isDigit、hasSiblings。
    • 返回对象属性的方法用名词、名词短语或以get开头的动词短语,如size、getTime。
    • 特殊方法命名,如toType(转换对象类型)、asType(返回视图)、typeValue(返回基本类型同值对象)、静态工厂方法(如from、of、valueOf)。
    • 没有严格的语法惯例。
    • boolean类型域与访问方法类似,但省去is,如initialized。
    • 其他类型域用名词或名词短语命名,如height、digits。

9. 异常

充分发挥异常的优点,可以提高程序的可读性、可靠性和可维护性。如果使用不当,他们也会带来负面的影响。

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

不当使用异常处理

  • 示例代码
java 复制代码
// Horrible abuse of exceptions. Don't ever do this!
try {
  int i = 0;
  while(true) range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) { 
}
  • 作用 :尝试遍历数组range,在访问数组越界时抛出ArrayIndexOutOfBoundsException来终止循环。
  • 标准模式
java 复制代码
for (Mountain m : range) 
  m.climb();
  • 问题
    1. 异常机制设计用于处理错误,不适合优化,JVM不针对异常进行优化。
    2. try-catch块阻碍JVM优化。
    3. 标准循环不会产生冗余检查,现代JVM会优化掉这些检查。
    4. 异常模式比标准模式慢,容易掩盖不相关的Bug,增加调试复杂性。

结论

  • 异常设计用于异常情况,不应用于普通控制流或迫使API用户这么做。
  • 标准模式代码优于过度优化的复杂模式,保持代码简洁、易读、易维护。

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

三种可抛出结构

  1. 受检异常(Checked Exceptions):用于期望调用者能够适当恢复的情况。调用者必须处理这些异常或将其传播。强制API用户应对可能的异常条件。
  2. 运行时异常(Runtime Exceptions):用于编程错误(如前提违例)。不需要被捕获或处理,程序继续执行可能有害。表示API客户未遵守API规范。
  3. 错误(Errors):JVM保留,用于资源不足、约束失败等无法继续执行的情况。不建议用户定义新的Error子类或抛出AssertionError。

选择异常类型的原则

  1. 受检异常:当异常条件是可恢复的。强制调用者处理异常,确保程序有恢复机制。
  2. 运行时异常:当异常条件是不可恢复的编程错误。API客户违反了API规范的约定。
  3. 错误:通常由JVM抛出,用于表示严重错误。不应该被程序员定义或使用。
  4. 模糊情况:资源枯竭等情况可能是临时性问题,也可能是编程错误。如果不确定是否可恢复,建议使用未受检异常。

定义异常的建议

  1. 不要定义既不是Exception也不是RuntimeException的抛出类型

这些类型与普通受检异常相比没有任何益处,只会困扰API用户。

  1. 异常对象应提供辅助方法

为捕获异常的代码提供额外信息,特别是关于引发异常条件的信息。

例如,用户资金不足导致购买失败时,异常对象应提供缺少的资金金额。

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

受检异常强制程序员处理异常条件,增强代码可靠性,适用于无法避免且可有用处理的异常情况。然而,过度使用受检异常会增加编程复杂度,并在Java 8的Stream中使用时增加复杂性。对于无法恢复的情况或常见处理不当的情况,未受检异常更合适。减少受检异常的方法包括使用Optional返回值和状态检测方法,这些方法虽然减少了编程负担,但在某些情况下可能不适用。

9.4 优先使用标准的异常

专家级程序员与缺乏经验的程序员的一个重要区别在于专家追求并能够实现高度的代码重用,异常处理也不例外。Java平台类库提供了一组标准的未受检异常,重用这些异常有助于API的易用性和可读性,并减少内存占用。最常重用的异常包括:

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

不应直接重用Exception、RuntimeException、Throwable 或 Error,因为它们是方法可能抛出的其他异常的超类。适当重用标准异常能够提高代码质量和一致性,必要时也可以子类化标准异常以添加更多信息。选择重用哪种异常可能不总是明确的,应根据具体情况进行合理判断。

9.5 抛出与抽象对应的异常

如果方法抛出的异常与其任务没有明显联系,会使人困惑,并污染更高层的API。为避免此问题,更高层实现应捕获底层异常并抛出适合高层抽象的异常,这称为异常转译。异常链是异常转译的一种形式,它将底层异常传递到高层异常中,并提供访问方法以获取低层异常。
示例代码

  • 异常转译
java 复制代码
try {
    // Use lower-level abstraction to do our bidding
} catch (LowerLevelException e) {
    throw new HigherLevelException(...); 
}
  • 异常链
java 复制代码
try {
    // Use lower-level abstraction to do our bidding
} catch (LowerLevelException cause) { 
    throw new HigherLevelException(cause);
}

下面的异常转译例子取自于AbstractSequentialList类型,该类是List接口的一个骨架实现,详见第20条。在这个例子中,按照List接口中的get方法的规范要求,异常转译是必需的:

java 复制代码
/**
 * 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); 
    }
}

尽管异常转译较传递底层异常有改进,但应避免滥用。最好在调用底层方法前确保其成功执行,避免异常抛出。若无法阻止低层异常,次好的做法是高层悄悄处理异常,将异常记录下来,以帮助管理员调查问题,同时隔离客户端代码和最终用户。

总之,若不阻止或处理低层异常,通常使用异常转译。只有在低层方法规范保证所有异常对高层也合适时,才可将异常从低层传播到高层。异常链提供了最佳功能,允许抛出适当高层异常,并捕获低层原因进行失败分析。

9.6 每个方法抛出的所有异常都要建立文档

描述方法所抛出的异常是方法文档的重要组成部分,应详细记录每个方法抛出的异常及其条件。单独声明受检异常,并使用Javadoc的@throws标签准确记录抛出每个异常的条件。避免使用泛泛的异常类如ExceptionThrowable

对于未受检异常,尽管Java语言不强制要求,但仍应详细记录。未受检异常通常代表编程错误,记录这些异常有助于避免重复错误。接口中的方法尤其需要记录未受检异常,以指定所有实现需遵循的通用行为。使用@throws标签记录未受检异常,而不要在方法声明中包含它们。

在类文档注释中记录出于同样原因抛出同一异常的方法是一种可接受的做法,例如NullPointerException。总之,为每个方法可能抛出的每个异常建立文档,包括未受检和受检异常、抽象和具体方法,使用@throws标签记录,且仅在throws子句中声明受检异常。这样可以使其他程序员更有效地使用你的类和接口。

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

为了便于分析和调试,异常的细节消息应包含尽可能多的失败捕获信息。这些信息有助于程序员在软件出现未捕获异常时更快地诊断问题。异常的字符串表示应详细记录所有与异常相关的参数和域的值,但要避免包含敏感信息。

  • 异常捕获信息 :细节消息应包含所有相关参数和域的值,如IndexOutOfBoundsException应包含下界、上界和实际索引值。
  • 安全考虑:细节消息不应包含密码、密钥等敏感信息。
  • 简洁性:异常消息应包含足够的信息,而不应冗长;异常的详细信息通常通过源代码分析获得。
  • 区分用户消息:异常消息主要供程序员使用,关注信息内容而非可读性,不应与用户层次的错误消息混淆。

构造异常时应在构造器中引入相关信息,并在生成细节消息时使用这些信息。例如:

java 复制代码
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
    super(String.format("Lower bound: %d, Upper bound: %d, Index: %d", lowerBound, upperBound, index));
    this.lowerBound = lowerBound;
    this.upperBound = upperBound;
    this.index = index;
}

从Java 9开始,IndexOutOfBoundsException引入了包含index参数的构造器,但未包含lowerBoundupperBound参数。尽管Java标准库中并未广泛使用这种方式,但推荐这种方法,以确保异常信息完整、易于分析。

为异常的失败捕获信息提供访问方法是明智的,尤其对于受检异常,这有助于从失败中恢复。虽然未受检异常较少需要程序访问其细节,但仍应作为一般原则提供这些访问方法。

9.8 努力使失败保持原子性

当对象在操作过程中抛出异常后,我们期望它仍保持在一种可用状态中,特别是在受检异常的情况下。具有这种属性的方法被称为具有失败原子性。以下是实现失败原子性的方法:

  1. 不可变对象:不可变对象天然具有失败原子性,因为其状态在创建后不会改变,操作失败也不会影响已有对象的状态。
  2. 参数有效性检查 :在修改对象状态之前检查参数的有效性,以确保在对象状态改变之前抛出异常。例如,Stack.pop方法在弹出元素前检查栈是否为空。
  3. 调整计算顺序 :在修改对象状态之前,先完成可能失败的计算。例如,TreeMap在添加元素之前检查元素类型是否正确。
  4. 临时拷贝:在对象的临时拷贝上执行操作,操作完成后再用结果替换对象内容。例如,某些排序函数在排序前备份输入列表以确保操作失败时列表保持原样。
  5. 恢复代码:编写恢复代码,在操作失败时将对象回滚到操作开始前的状态,这种方法主要用于基于磁盘的永久性数据结构。

虽然实现失败原子性是理想的,但并非总能做到。例如,并发修改同一对象可能导致不一致状态,捕获ConcurrentModificationException后不应假设对象可用。此外,有时实现失败原子性会显著增加开销或复杂性。
总结:

  • 方法应确保异常不会改变对象在调用前的状态,这是方法规范的一部分。
  • 若无法实现,API文档应明确说明对象在异常后的状态。
  • 大量现有API文档未能明确说明异常后的对象状态。

通过了解并实现这些方法,可以有效提高代码的健壮性和可维护性。

9.9 不要忽略异常

  1. 不可忽略的异常:API设计者声明方法抛出异常是为了告知你需要处理潜在的问题。因此,忽略异常是不负责任的做法。空的catch块使得异常信息丢失,无法警示和解决潜在的问题。
  2. 正确的异常处理:遇到空的catch块时应警惕,避免这种忽略异常的处理方式。在必要时可以忽略一些异常,但应包含注释解释原因,并记录异常,以便后续调查。

忽略异常的示例:

java 复制代码
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
}
  1. 适用于所有异常 :无论是受检异常还是未受检异常,空的catch块都是不正确的处理方式。未处理的异常会导致程序在遇到错误时继续执行,可能在将来的某个点因无法容忍错误而失败。正确处理异常至少可以确保程序在遇到问题时迅速失败,保留有助于调试的信息。

10. 并发

线程机制允许同时进行多个活动。并发程序设计比单线程程序设计要困难得多,因为有更多的东西可能出错,也很难重现失败。但是你无法避免并发,因为我们所做的大部分事情都需要并发,而且并发也是能否从多核的处理器中获得好的性能的一个条件,这些现在都是很平常的事了。

10.1 同步访问共享的可变数据

同步和内存模型

同步的作用synchronized 关键字确保一次只有一个线程可以执行被同步的方法或代码块。这不仅仅是为了防止线程看到对象处于不一致状态,还保证了线程之间对共享数据的正确可见性。
内存模型 :Java 内存模型规定了线程如何看到其他线程对共享变量的修改。没有同步,线程可能看不到其他线程的更改,即使变量是原子的(如 boolean 类型)。

线程停止的示例:

java 复制代码
// Properly synchronized cooperative thread termination
private static volatile boolean stopRequested;

public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> {
        int i = 0;
        while (!stopRequested)
            i++;
    });
    backgroundThread.start();
    TimeUnit.SECONDS.sleep(1);
    stopRequested = true;
}

示例代码演示了如果没有同步,后台线程可能看不到主线程对停止标志的更改,导致程序无法终止。正确的做法是使用 synchronizedvolatile 关键字来确保线程看到最新的标志值。

volatile 关键字和 Atomic 类

volatile关键字:volatile 修饰符确保线程能看到最新写入的值,适用于只需要可见性而非互斥的情况。但它不提供互斥功能。volatile 适用于简单的标志位,但复杂操作如递增操作仍需 synchronizedAtomic 类。
Atomic 类:

java 复制代码
private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber() {
    return nextSerialNum.getAndIncrement();
}

使用 java.util.concurrent.atomic 包中的 AtomicLong 类可以实现线程安全的无锁操作。例如,AtomicLong 提供了对单个变量的原子操作。
避免共享可变数据:最好的方法是尽量避免共享可变数据。通过将数据限制在单个线程内或使用不可变对象,可以避免复杂的同步问题。使用安全发布方法来确保对象在多线程环境中的正确共享和不可变性。

小结

多线程程序中,确保每个线程对共享数据的访问都经过同步,以保证数据的可见性和一致性。volatile 修饰符用于可见性,而 synchronizedAtomic 类用于提供互斥和原子操作。避免共享可变数据是避免同步复杂性和错误的最佳策略。

10.2 避免过度的同步

  1. 同步区域内外来方法的风险:在同步方法或代码块内调用外来方法(如客户端提供的方法)可能导致性能问题、死锁或不确定行为。外来方法是指不受当前类控制的方法,例如客户端提供的回调方法。
  2. 潜在问题
    • 异常 :例如,迭代同步区域内的列表并在回调方法中修改该列表,可能导致 ConcurrentModificationException
    • 死锁:如果外来方法在同步区域内执行了耗时操作(如使用线程池),可能导致主线程与后台线程互相等待,从而引发死锁。
    • 安全性失败:如果外来方法导致同步区域内的数据不一致,可能破坏类的线程安全性。
  3. 解决方法
    • 开放调用:将外来方法调用移出同步块,将同步块的工作最小化。可以在同步块内创建数据的"快照",然后在同步块外部进行操作,以避免同步区域内的不必要阻塞。
    • 使用并发集合 :如 CopyOnWriteArrayList,它可以避免同步块内的并发问题,通过在修改集合时复制底层数组来避免同步。
  4. 性能考虑:避免过度同步,以避免性能下降和限制并行化机会。过度同步可能导致虚拟机优化代码执行的能力受限。
  5. 设计建议:对于可变类,应该选择合适的同步策略:内部同步(线程安全)还是由客户端外部同步。内外同步各有利弊,选择时需考虑并发需求和性能影响。文档化同步决策,确保客户端理解类的线程安全性和使用限制。

总结来说,在同步区域内调用外来方法需要谨慎,以避免可能的异常、死锁或安全性问题。通过将外来方法调用移出同步块或使用合适的并发集合,可以有效减少这些风险。同时,合理的同步策略有助于提高程序的性能和可靠性。

10.3 Executor、Task 和 Stream 优先于线程

  1. Executor Framework提供了多种方法来管理任务和线程:
    • get 方法等待特定任务完成。
    • invokeAnyinvokeAll 方法等待任务集合完成。
    • awaitTermination 方法等待执行器服务终止。
    • ExecutorCompletionService 用于逐个检索任务结果。
    • ScheduledThreadPoolExecutor 支持任务调度和定期执行。
  2. 线程池的选择 :对于轻负载的应用,Executors.newCachedThreadPool 通常足够,因为它会创建新线程来处理任务,但对于高负载的生产环境,应使用 Executors.newFixedThreadPoolThreadPoolExecutor 来提供固定数量的线程,以避免创建过多线程的问题。
  3. 线程与任务的分离
    • 使用 Executor Framework 时,工作单元(任务)和执行机制(线程)是分开的。任务通过 RunnableCallable 接口定义,执行机制由 ExecutorService 提供。
    • 这样可以灵活地选择执行策略,并在需求变化时调整策略。
  4. Fork-Join Framework
    • 在 Java 7 中,Executor Framework 扩展支持 fork-join 任务,使用 ForkJoinPool 处理可以拆分为子任务的任务,提供更高的任务利用率和吞吐量。
    • ForkJoinTask 任务可以被拆分和并行处理,通过任务窃取机制保持所有线程忙碌。
    • 并行流(Parallel streams)建立在 fork-join 池之上,简化了并行处理的使用。

总结而言,Executor Framework 提供了比传统的工作队列和线程管理更简洁和强大的并发处理方式。使用现代的并发工具可以有效地管理线程、处理任务和提高性能,同时避免传统方法中的复杂性和潜在问题。

10.4 并发工具优于 wait 和 notify

Java并发编程中传统waitnotify机制与现代java.util.concurrent包中高级并发工具的对比。自Java 5起,高级并发工具如Executor Framework、并发集合、同步器等提供了更简单、安全和高效的并发控制方式,使得传统waitnotify的使用变得不那么必要。并发集合通过内部同步管理提供了高性能的线程安全数据结构,如ConcurrentHashMap,它利用原子操作简化了线程安全编程。同步器如CountDownLatch提供了协调线程执行的高级抽象,使得复杂的并发模式实现起来更为简洁。尽管在某些特殊情况下仍可能需要使用waitnotify,但一般推荐使用java.util.concurrent包中的工具,因为它们提供了更高级的并发控制手段。此外,在使用waitnotify时应使用循环来安全地检查条件,以及在可能的情况下优先使用notifyAll以避免潜在的唤醒问题。

10.5 文档应包含线程安全属性

在Java并发编程中,类的设计和文档应明确记录其线程安全属性,以避免客户端做出错误假设,导致程序出现同步不足或过度同步的问题。类应根据其线程安全级别(如不可变、无条件线程安全、有条件的线程安全、非线程安全、线程对立)进行分类,并在文档中详细说明。synchronized修饰符不应作为线程安全的唯一标识,因为它是实现细节而非API的一部分。有条件的线程安全类需要特别小心地在文档中指出哪些方法调用序列需要外部同步,并明确指出需要获取哪些锁。类的设计应考虑使用私有锁对象来代替同步方法,以防止客户端和子类干扰同步,同时提供更大的灵活性。最终,每个类都应该通过文档注释或线程安全注解来清晰地记录其线程安全属性,确保客户端能够正确地理解和使用这些类。

其中:

  • 不可变的 --- 这个类的实例看起来是常量。不需要外部同步。示例包括 StringLongBigInteger(详见第 17 条)。
  • 无条件线程安全 --- 该类的实例是可变的,但是该类具有足够的内部同步,因此无需任何外部同步即可并发地使用该类的实例。例如 AtomicLongConcurrentHashMap
  • 有条件的线程安全 --- 与无条件线程安全类似,只是有些方法需要外部同步才能安全并发使用。示例包括 Collections.synchronized 包装器返回的集合,其迭代器需要外部同步。
  • 非线程安全 --- 该类的实例是可变的。要并发地使用它们,客户端必须使用外部同步来包围每个方法调用(或调用序列)。这样的例子包括通用的集合实现,例如 ArrayListHashMap
  • 线程对立 --- 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修复或弃用。

10.6 明智审慎的使用延迟初始化

延迟初始化是一种优化手段,它推迟字段的初始化直到实际需要它的值,适用于静态和实例字段,可以减少初始化成本,但可能增加访问成本。在多线程环境下,需要同步机制来保证线程安全。文中推荐了几种延迟初始化模式:对于静态字段,推荐使用懒汉式持有者类模式(lazy initialization holder class idiom),它利用类加载机制保证线程安全且访问成本低;对于实例字段,推荐使用双重检查锁定模式(double-check idiom),它通过两次检查和volatile关键字减少锁定成本;还提到了单检查模式(single-check idiom),适用于可以容忍重复初始化的场景。最后,作者建议大多数字段应正常初始化,只在性能优化或解决初始化循环时考虑延迟初始化,并使用适当的技术确保线程安全和性能。

懒汉式持有者模式(用于静态字段)

java 复制代码
public class LazyInitializationHolderClass {
    // 私有静态内部类,确保只有在第一次访问时才会被加载
    private static class FieldHolder {
        static final FieldType field = computeFieldValue();
    }

    // 公共静态方法,返回初始化的字段值
    public static FieldType getField() {
        return FieldHolder.field;
    }

    // 模拟字段的计算成本
    private static FieldType computeFieldValue() {
        // 模拟高成本的初始化过程
        System.out.println("Initializing field...");
        return new FieldType();
    }

    // 定义FieldType,仅为示例
    public static class FieldType {
        // 字段类型的内容
    }
}

双重检查锁定模式(用于实例字段)

java 复制代码
public class LazyInitializationDoubleCheck {
    private volatile FieldType field;
    
    // 公共方法,返回初始化的字段值
    public FieldType getField() {
        FieldType result = field;
        if (result == null) { // 第一次检查
            synchronized(this) {
                if (field == null) { // 第二次检查
                    field = result = computeFieldValue();
                }
            }
        }
        return result;
    }

    // 模拟字段的计算成本
    private FieldType computeFieldValue() {
        // 模拟高成本的初始化过程
        System.out.println("Initializing field...");
        return new FieldType();
    }

    // 定义FieldType,仅为示例
    public static class FieldType {
        // 字段类型的内容
    }
}

10.7 不要依赖线程调度器

当许多线程可以运行时,线程调度器决定哪些线程可以运行以及运行多长时间。任何合理的操作系统都会尝试公平地做出这个决定,但是策略可能会有所不同。因此,编写良好的程序不应该依赖于此策略的细节。任何依赖线程调度器来保证正确性或性能的程序都可能是不可移植的。

编写健壮、响应快、可移植程序的最佳方法是确保可运行线程的平均数量不显著大于处理器的数量。这使得线程调度器几乎没有选择:它只运行可运行线程,直到它们不再可运行为止。即使在完全不同的线程调度策略下,程序的行为也没有太大的变化。注意,可运行线程的数量与线程总数不相同,后者可能更高。正在等待的线程不可运行。

保持可运行线程数量低的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。如果线程没有做有用的工作,它们就不应该运行。 对于 Executor 框架(详见第 80 条),这意味着适当调整线程池的大小 [Goetz06, 8.2],并保持任务短小(但不要太短),否则分派的开销依然会损害性能。

线程不应该处于忙等待状态(busy-wait),而应该反复检查一个共享对象,等待它的状态发生变化。除了使程序容易受到线程调度器变化无常的影响之外,繁忙等待还大大增加了处理器的负载,还影响其他人完成工作。作为反面的极端例子,考虑一下 CountDownLatch 的不正确的重构实现:

java 复制代码
// Awful CountDownLatch implementation - busy-waits incessantly!
public class SlowCountDownLatch {

    private int count;

    public SlowCountDownLatch(int count) {
        if (count < 0)
            throw new IllegalArgumentException(count + " < 0");
        this.count = count;
    }

    public void await() {
        while (true) {
            synchronized(this) {
                if (count == 0)
                    return;
            }
        }
    }

    public synchronized void countDown() {
        if (count != 0)
            count--;
    }
}

在我的机器上,当 1000 个线程等待一个锁存器时,SlowCountDownLatch 的速度大约是 Java 的 CountDownLatch 的 10 倍。虽然这个例子看起来有点牵强,但是具有一个或多个不必要运行的线程的系统并不少见。性能和可移植性可能会受到影响。

当面对一个几乎不能工作的程序时,而原因是由于某些线程相对于其他线程没有获得足够的 CPU 时间,那么 通过调用 **Thread.yield** 来「修复」程序 你也许能勉强让程序运行起来,但它是不可移植的。在一个 JVM 实现上提高性能的相同的 yield 调用,在第二个 JVM 实现上可能会使性能变差,而在第三个 JVM 实现上可能没有任何影响。Thread.yield没有可测试的语义。更好的做法是重构应用程序,以减少并发运行线程的数量。

一个相关的技术是调整线程优先级,类似的警告也适用于此技术,即,线程优先级是 Java 中最不可移植的特性之一。通过调整线程优先级来调优应用程序的响应性并非不合理,但很少情况下是必要的,而且不可移植。试图通过调整线程优先级来解决严重的活性问题是不合理的。在找到并修复潜在原因之前,问题很可能会再次出现。

总之,不要依赖线程调度器来判断程序的正确性。生成的程序既不健壮也不可移植。因此,不要依赖 Thread.yield 或线程优先级。这些工具只是对调度器的提示。线程优先级可以少量地用于提高已经工作的程序的服务质量,但绝不应该用于「修复」几乎不能工作的程序。

11. 序列化

11.1 优先选择 Java 序列化的替代方案

自1997年引入Java以来,序列化虽然为分布式对象提供了便利,但也带来了诸多问题,如不可见的构造函数、API与实现界限模糊、正确性、性能、安全性和维护问题。历史证明,这些风险大于其收益,尤其是反序列化过程中可能引发的安全漏洞,如2016年旧金山大都会运输署的勒索软件攻击。序列化的问题在于其攻击面广,难以保护,且随着时间推移问题不断增多。反序列化可以执行几乎任何类型的代码,使得所有实现Serializable接口的类都可能成为攻击目标。攻击者通过寻找可序列化类型中的潜在危险方法(称为gadget)来构建攻击链,甚至可以执行任意的本机代码。此外,即使是无害的序列化数据,也可以通过构造反序列化炸弹(如深度嵌套的HashSet)来发起拒绝服务攻击。因此,最佳防御策略是避免反序列化不可信的数据,尽可能不使用Java序列化。如果必须使用,建议使用跨平台的结构化数据表示,如JSONProtocol Buffers,这些技术比Java序列化简单且安全。对于无法避免使用序列化的遗留系统,建议使用Java 9引入的对象反序列化筛选,通过白名单或黑名单来控制可反序列化的类。总之,序列化是危险的,应尽量避免,如果必须使用,应采取严格的安全措施。

11.2 非常谨慎的实现 Serializable 接口

  1. 序列化的长期成本
    • 一旦类实现Serializable,其序列化形式就成为其导出API的一部分,必须长期支持。
    • 默认的序列化形式将类的私有和包级私有实例属性暴露出来,限制了类的修改灵活性,并破坏了信息隐藏的原则。
  2. 版本兼容性问题
    • 更改类的内部表示会导致序列化形式的不兼容,从而导致反序列化失败。
    • 系统通过类结构自动生成的序列版本UID(serialVersionUID)如果没有显式声明,会随着类的变化而变化,导致版本不兼容的问题。
  3. 增加错误和安全漏洞的风险
    • 反序列化相当于一个隐藏的构造方法,可能会破坏对象的不变性或导致安全漏洞。
    • 默认的反序列化机制容易让对象处于非法状态或暴露内部信息。
  4. 增加测试负担
    • 修改可序列化类时,需要确保新版本的实例可以在旧版本中反序列化,反之亦然。
    • 需要大量的测试来确保序列化和反序列化过程的成功。
  5. 序列化的适用性
    • 序列化对某些类是重要的,如需要参与基于Java序列化的框架的类。
    • 通常值类(如BigInteger和Instant)和集合类实现Serializable,而表示活动实体(如线程池)的类通常不实现Serializable。
  6. 继承和序列化
    • 为继承而设计的类和接口通常不应该实现Serializable,除非有特殊需求。
    • 可序列化的父类需要注意防止子类破坏不变性和安全性,如防止终结器攻击。
  7. 内部类和序列化
    • 内部类不应实现Serializable,因为其默认序列化形式是不明确的。
    • 静态成员类可以实现Serializable。

总之,除非在受保护的环境中使用,否则实现Serializable应非常谨慎。如果类允许继承,则更需格外小心。

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

本文讨论了在Java中设计自定义序列化形式的重要性及其优势。尽管接受默认的序列化形式看似简单,但从长远来看,它可能会带来许多问题。以下是主要总结:

  1. 默认序列化形式的限制:默认序列化形式将类的物理表示(如内部数据结构)作为其导出API的一部分,限制了类的灵活性。序列化形式与类的内部表示绑定,难以在未来版本中修改。可能导致空间和时间上的效率低下,甚至会引发堆栈溢出等问题。
  2. 自定义序列化形式的优势:自定义序列化形式可以专注于类的逻辑数据,避免不必要的实现细节。更有效的空间利用和更快的序列化与反序列化速度。避免堆栈溢出问题,提高程序的健壮性。
  3. 设计自定义序列化形式的步骤:考虑类的逻辑数据,而非其物理实现。例如,字符串列表类(StringList)应序列化字符串内容,而不是链表结构。使用transient关键字标记不需要序列化的属性,确保序列化形式只包含必要的逻辑数据。提供writeObject和readObject方法来控制序列化和反序列化过程。
  4. 确保不变性和安全性:无论使用哪种序列化形式,通常都需要提供readObject方法以确保类的不变性和安全性。在设计序列化形式时,要注意同步以防止并发问题。

总之,在设计可序列化类时,必须仔细考虑序列化形式,确保它能够合理地描述对象的逻辑状态。错误的序列化形式选择会对类的灵活性、性能和长期维护产生负面影响。

11.4 防御性的编写 readObject 方法

在条目50中,定义了一个不可变的日期范围类Period,该类包含可变的私有Date属性。该类通过构造方法和访问器对Date对象进行防御性拷贝,确保其不变性。以下是该类的代码:

java 复制代码
// Immutable class that uses defensive copying
public final class Period {

    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }
    // Remainder omitted
}

假设要使该类可序列化。由于Period对象的物理表示准确反映了其逻辑数据内容,因此使用默认的序列化形式是合理的。但是,如果仅添加implements Serializable,该类不再能保证其关键不变性。
问题在于:

readObject方法实际上是一个公共构造方法,需要像其他构造方法一样谨慎地进行参数验证和防御性拷贝。否则,攻击者可以通过构造特定的字节流,创建一个违反类不变性的对象。例如,下面的程序展示了通过构造恶意字节流,创建一个无效的Period实例:

java 复制代码
public class BogusPeriod {
  private static final byte[] serializedForm = { /* byte array */ };

  public static void main(String[] args) {
    Period p = (Period) deserialize(serializedForm);
    System.out.println(p);
  }

  static Object deserialize(byte[] sf) {
    try {
      return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
    } catch (IOException | ClassNotFoundException e) {
      throw new IllegalArgumentException(e);
    }
  }
}

解决方案:

提供一个readObject方法,该方法调用defaultReadObject,然后检查反序列化对象的有效性。如果检查失败,抛出InvalidObjectException:

java 复制代码
// readObject method with validity checking - insufficient!
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + " after " + end);
}

这种方法虽然防止了无效Period实例的创建,但仍存在潜在的问题。例如,可以通过构造以有效Period实例开头的字节流,创建可变Period实例,并附加对私有Date属性的引用。攻击者可以通过以下类,修改Period实例的内部组件:

java 复制代码
public class MutablePeriod {
    public final Period period;
    public final Date start;
    public final Date end;

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);
            out.writeObject(new Period(new Date(), new Date()));
            byte[] ref = { 0x71, 0, 0x7e, 0, 5 };  // Ref #5
            bos.write(ref); // The start field
            ref[4] = 4;     // Ref # 4
            bos.write(ref); // The end field

            ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            start = (Date) in.readObject();
            end = (Date) in.readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new AssertionError(e);
        }
    }
}

运行以下程序,展示了攻击的效果:

java 复制代码
public static void main(String[] args) {
    MutablePeriod mp = new MutablePeriod();
    Period p = mp.period;
    Date pEnd = mp.end;

    // Let's turn back the clock
    pEnd.setYear(78);
    System.out.println(p);

    // Bring back the 60s!
    pEnd.setYear(69);
    System.out.println(p);
}

解决上述问题的最终方法:

在readObject方法中进行防御性拷贝和有效性检查:

java 复制代码
// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Defensively copy our mutable components
    start = new Date(start.getTime());
    end = new Date(end.getTime());

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + " after " + end);
}

总结:

  1. 在readObject方法中,对包含客户端不能拥有的对象引用的属性进行防御性拷贝。
  2. 检查不变性,检查失败则抛出InvalidObjectException。
  3. 避免在readObject方法中直接或间接调用可重写的方法。
  4. 使用readObject方法确保反序列化后的对象是有效的,或者使用序列化代理模式。

11.5 对于实例的控制,枚举类型优于 readReslove

  1. 单例类的基本实现
    • 创建一个只有一个实例的类,构造方法为私有,实例通过公共静态字段提供。
    • 示例:public static final Elvis INSTANCE = new Elvis();

但是如果单例类实现了Serializable接口,序列化会创建新的实例,破坏单例。

  1. 使用 readResolve方法
    • readResolve可以在反序列化时替换新创建的实例,确保单例。
    • 示例:private Object readResolve() { return INSTANCE; }
    • 需要将所有非基本类型的实例属性声明为transient,否则可能被攻击。
  2. 潜在攻击
    • transient实例属性可能被利用,通过"stealer"类和循环引用进行攻击。
    • 攻击流程:
      • 反序列化过程中stealer类的readResolve方法先运行,保存对未解析单例的引用。
      • 生成两个不同的单例实例。
  3. 解决方案 :将所有非基本类型实例属性声明为transient。更好的方法是使用枚举类型。
  4. 枚举类型的优势
    • 枚举类型保证除了声明的常量外不会有其他实例,避免序列化攻击。
    • 示例:public enum Elvis { INSTANCE; ... }
  5. 结论 :尽量使用枚举类型实现单例,确保实例控制不变性。如果无法使用枚举且需要序列化,提供readResolve方法,并确保所有实例属性为基本类型或transient

11.6 考虑序列化代理替代序列化实例

实现Serializable接口增加了bug和安全问题的风险,常规序列化可能导致对象不变性被破坏。
序列化代理模式

  • 目的:减少序列化引入的风险。
  • 方法:使用私有静态嵌套类作为外围类的序列化代理。

步骤与实现

  1. 创建序列化代理
java 复制代码
private static class SerializationProxy implements Serializable {
    private final Date start;
    private final Date end;
    SerializationProxy(Period p) {
        this.start = p.start;
        this.end = p.end;
    }
    private static final long serialVersionUID = 234098243823485285L;
}

设计一个私有静态嵌套类表示外围类的逻辑状态。

  1. writeReplace方法
java 复制代码
private Object writeReplace() {
    return new SerializationProxy(this);
}

在外围类中添加writeReplace方法,将实例转换为序列化代理。

  1. 防御性 readObject方法
java 复制代码
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
    throw new InvalidObjectException("Proxy required");
}

确保攻击者不能通过伪造字节流创建外围类实例。

  1. readResolve方法
java 复制代码
private Object readResolve() {
    return new Period(start, end);
}

在序列化代理中添加readResolve方法,将代理转换回外围类实例。
优点

  • 保持对象的不变性。
  • 防止伪造字节流攻击和内部属性盗用攻击。
  • 允许final属性,实现真正的不可变对象。

实际应用
EnumSet类:通过序列化代理模式实现,不同大小的枚举集可以在反序列化时转换为不同的子类(RegularEnumSet或JumboEnumSet)。

java 复制代码
private static class SerializationProxy<E extends Enum<E>> implements Serializable {
    private final Class<E> elementType;
    private final Enum<?>[] elements;
    SerializationProxy(EnumSet<E> set) {
        elementType = set.elementType;
        elements = set.toArray(new Enum<?>[0]);
    }
    private Object readResolve() {
        EnumSet<E> result = EnumSet.noneOf(elementType);
        for (Enum<?> e : elements) result.add((E)e);
        return result;
    }
    private static final long serialVersionUID = 362491234563181265L;
}

限制 :不适用于用户可扩展的类,不适用于包含循环引用的对象图。
性能成本 :比使用防御性拷贝方法多出14%的开销。
总结 :在需要编写readObjectwriteObject方法且类不能被客户端扩展时,考虑使用序列化代理模式。该模式可以是最简单且最健壮的方法来维护对象的不变性。

相关推荐
阿伟*rui2 小时前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj4 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck4 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei4 小时前
java的类加载机制的学习
java·学习
Yaml46 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~6 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616886 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7896 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
记录成长java7 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
睡觉谁叫~~~7 小时前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust