读书-Effective Java系列(二)
一
1. 以静态工厂方法代替构造函数
客户端获取类实例的方法:
- 构造器
- 静态工厂方法
推荐使用静态工厂方法,而不是构造器方法。
静态工厂优点:
-
静态工厂方法有确切的名称,构造器只能是类名
-
静态工厂方法可以通过不需要在每次调用时创建新的对象。类似于享元模式
-
静态工厂方法可以获取返回类型的子类对象
-
静态工厂方法返回对象的类可以随调用的不同而变化,作为输入参数的函数
比如:
构造方法:Map<String, List<String>> m = new HasMap<String, List<String>>();
静态工厂方法:
public static <K, V> HashMap<K, V> newInstance() {
return new HashMap<K, V>();
}
静态工厂缺点:
-
没有公共或受保护构造函数的类不能被子类化
-
和其他静态方法实际上没有任何区别
为了加以区别,静态工厂使用一些惯用名称
- valueOf:该方法返回的实例和参数的数值相同。实际上是一种类型转换方法
- of:valueOf的简洁替代
- getInstance:返回的实例通过参数来描述。对于单例模式,该方法没有参数并返回唯一实例
- newInstance:多例模式,每次返回新值
- getType:和getInstance类似,但是在工厂方法处于不同的类中使用
- newType:和newInstance类似,但是在工厂方法处于不同的类中使用
2. 遇到多个构造器参数时考虑用构建器
静态工厂和构造器有一个共同的缺陷,他们都不能很好的扩展到大量的可选参数。如果一个类新建有大量的可选参数,通常采用重叠构造器模式,多个构造器。
1 | // Telescoping constructor pattern - does not scale well! |
当创建实例的时候,就需要利于利用参数列表最短的构造器,但是这个列表包含了要设置的所有参数。
重叠构造器模式可行,但是当有许多参数的时候,客户端代码会很难编写,并且仍然难以阅读。如果客户端颠倒参数的位置,有可能编译器也不会报错,但是程序运行时错误。
替代方法二:JavaBeans模式。
1 | // JavaBeans Pattern - allows inconsistency, mandates mutability |
先调用一个无参构造器创建对象,然后调用setter方法设置每个必要的参数。
JavaBeans模式自身有很严重的缺点
- 因为构造过程被分到了几个调用中,在构造的过程中JavaBeans可能处于不一致的状态。
- JavaBeans模式阻止了把类做成不可变的可能,需要额外的处理保证线程的安全。
最佳替代:构建器(Builder模式)
1 | // Builder Pattern |
不直接生成对象,而是让客户端利用所有必要的参数调动构造器(静态工厂),得到一个builder对象,然后在builder对象上调用类似setter的方法,最后调用无参的builder()方法生成不可变对象。builder类是它创建类的静态成员类。
优点:builder模式十分灵活,可以利用单个builder创建多个对象,可以自动填充某些属性。
缺点:性能开销
3. 用私有构造器或者枚举类型强化Singleton属性
Singleton单例是一个只实例化一次的类。
实现单例有两种常见的方法。两者都基于保持构造函数私有和导出公共静态成员以提供对唯一实例的访问。
- 在第一种方法中,成员是一个 final 字段:
1 | // Singleton with public final field |
私有构造函数只调用一次,用于初始化 public static final 修饰的 Elvis 类型字段 INSTANCE。不使用 public 或 protected 的构造函数保证了「独一无二」的空间:一旦初始化了 Elvis 类,就只会存在一个 Elvis 实例,不多也不少。客户端所做的任何事情都不能改变这一点,但有一点需要注意:拥有特殊权限的客户端可以借助 AccessibleObject.setAccessible 方法利用反射调用私有构造函数。如果需要防范这种攻击,请修改构造函数,使其在请求创建第二个实例时抛出异常。
使用 AccessibleObject.setAccessible
方法调用私有构造函数示例:
1 | Constructor<?>[] constructors = Elvis.class.getDeclaredConstructors(); |
- 在实现单例的第二种方法中,公共成员是一种静态工厂方法
1 | // Singleton with static factory |
所有对 getInstance()
方法的调用都返回相同的对象引用,并且不会创建其他 Elvis 实例(与前面提到的警告相同)。
静态工厂方法的一个优点是,它可以在不更改 API 的情况下决定类是否是单例。工厂方法返回唯一的实例,但是可以对其进行修改,为调用它的每个线程返回一个单独的实例。第二个优点是,如果应用程序需要的话,可以编写泛型的单例工厂。使用静态工厂的最后一个优点是方法引用能够作为一个提供者,例如 Elvis::getInstance
是 Supplier<Elvis>
的提供者。除非能够与这些优点沾边,否则使用 public 字段的方式更可取。
但是要使单例类使用这两种方法中的任何一种实现可序列化,仅仅在其声明中添加实现 serializable 是不够的。
要维护单例保证,应声明所有实例字段为 transient,并提供 readResolve 方法。否则,每次反序列化实例时,都会创建一个新实例,在我们的示例中,这会导致出现虚假的 Elvis。为了防止这种情况发生,将这个 readResolve 方法添加到 Elvis 类中:
1 | // readResolve method to preserve singleton property |
- 从1.5开始,实现Singleton还可以编写一个包含单个元素的枚举类型
1 | // Enum singleton - the preferred approach |
这种方法类似于 public 字段方法,但是它更简洁,默认提供了序列化机制,提供了对多个实例化的严格保证,即使面对复杂的序列化或反射攻击也是如此。这种方法可能有点不自然,但是单元素枚举类型通常是实现单例的最佳方法。
注意:如果你的单例必须扩展一个超类而不是 Enum(尽管你可以声明一个 Enum 来实现接口),你就不能使用这种方法。
4. 通过私有构造器强化不可实例化的能力
对于某些类比如java.lang.Math、java.util.Arrays
等工具类,不希望被实例化,因为实例化没有任何意义。然而如果在缺少显示构造器的情况下,编译器会自动补全一个公有、无参的缺省(default)构造器。
同时,虽然将类声明成抽象类后就不能实例化,不能将不希望被实例化的类做成抽象类。因为该类可以被子类化,子类可以被实例化。这种做法会误导用户,以为这种类是专门为了继承而设计的。
我们可以将类声明一个私有的构造器,他就不能被实例化。
1 | // Noninstantiable utility class |
因为显式构造函数是私有的,所以在类之外是不可访问的。AssertionError 不是严格要求的,但是它提供了保障,以防构造函数意外地被调用。它保证类在任何情况下都不会被实例化。这个习惯用法有点违反常规,因为构造函数是明确提供的,但不能调用它。因此,最好在代码中增加注释。
缺点:使一个类不能被子类化。
因为所有的构造器都必须显示或者隐式的调用超类(super)构造器,这种情况下,子类就没有可访问的超类构造器可调用。
5. 优先考虑依赖注入来引入资源
有许多类会依赖一个或多个底层的资源。不要用Singleton和静态工具类来实现依赖一个或多个底层资源的类;也不要直接用这个类来创建这些资源。应该将这些资源或者工厂传给构造器(或者静态工厂,或者构建器),通过他们来创建类。这种方式也叫作依赖注入
。
以拼写检查程序为例,拼写检查依赖字典。常见的做法是将该类作为静态实用工具类
1 | // Inappropriate use of static utility - inflexible & untestable! |
或者是其单例实现
1 | // Inappropriate use of singleton - inflexible & untestable! |
缺点:他们都假设只使用了一个字典,但是在实际应用中,每种语言都有自己的字典,特殊的字典用于特殊的词汇表。
可以尝试让SpellChecker 支持多个字典:
首先,取消 dictionary 字段的 final 修饰,并在现有的拼写检查器中添加更改 dictionary 的方法。但是在并发环境
中这种做法是笨拙的、容易出错的和不可行的。静态实用工具类和单例不适用于由底层资源参数化的类。
其次,需要的是能够支持类的多个实例(拼写检查器),每一个实例都是用客户端指定的资源(字典)。满足该需求最简单的模式就是,**当创建一个新的实例时,就将该资源传到构造器中。这种形式也是依赖注入
的一种。**词典是拼写检查器的一个依赖,在创建拼写检查器的时候就将词典注入其中。
1 | // Dependency injection provides flexibility and testability |
**依赖注入同样适用于构造器、静态工厂和构建器。**这种模式另一种变体就是将资源工厂传递给构造器,工厂是可以被重复调用来创建类型实例的一个对象。这类工厂具体表现为工厂方法。
在Java 8中增加的接口
Supplier<T>
,最适合用于表示工厂。带有Supplier<T>
的方法通常应该使用有界通配符类型来约束工厂的类型参数,以允许客户端传入创建指定类型的任何子类型的工厂。
比如,下面一个生产马赛克的方法,利用客户端提供的工厂来生产每一篇马赛克。
1 | Mosaic create(Supplier<? extends Tile> tileFactory) { ... } |
尽管依赖注入极大地提高了灵活性和可测试性,但它可能会使大型项目变得混乱,这些项目通常包含数千个依赖项。通过使用依赖注入框架(如 Dagger、Guice 或 Spring),几乎可以消除这种混乱。这些框架的使用超出了本书的范围,但是请注意,设计成手动依赖注入的API,一般都适用于这些框架。
6. 避免创建不必要的对象
一般来说,最好能重用对象而不是在每次需要的时候就创建一个功能相同的新对象。
1 | String s = new String("bikini"); // DON'T DO THIS!‘ |
第一条语句每次执行时都会创建一个新的 String 实例,而这些对象创建都不是必需的。String 构造函数的参数 ("bikini")
本身就是一个 String 实例,在功能上与构造函数创建的所有对象相同。如果这种用法发生在循环或频繁调用的方法中,创建大量 String 实例是不必要的。
第二条语句使用单个 String 实例,而不是每次执行时都创建一个新的实例。此外,可以保证在同一虚拟机中运行的其他代码都可以复用该对象,只要恰好包含相同的字符串字面量
对于同时提供静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象
因为构造器每一次在被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做。
有些对象创建的成本比其他对象要高得多,如果重复使用这类“昂贵的对象”,建议将它缓存下来重用。
比如,使用一个正则表达式:
1 | // Performance can be greatly improved! |
这个问题在于String#matches方法。这个方法每次调用都会在内部为正则表达式创建一个Pattern实例(需要将正则表达式编译成一个有限状态机),但是只使用了一次就回收,成本很高。
为了提升性能,需要显示地将正则表达式编译成一个Pattern实例,让它成为类初始化的一部分,并缓存起来、
1 | // Reusing expensive object for improved performance |
如果RomanNumerals类被初始化但是isRomanNumeral方法没有被使用过,则ROMAN对象理论上将不需要初始化。可以使用懒加载ROMAN对象。但是,不建议懒加载ROMAN,会使方法实现更加复杂。
类加载通常指的是类的生命周期中加载、连接、初始化三个阶段。当方法没有在类加载过程中被使用时,可以不初始化与之相关的字段
当一个对象是不可变的,很明显,它可以安全地复用,但在其他情况下,它远不那么明显,甚至违反直觉。考虑适配器的情况,也称为视图。适配器是委托给支持对象的对象,提供了一个替代接口。因为适配器的状态不超过其支持对象的状态,所以不需要为给定对象创建一个给定适配器的多个实例。
例如,Map 接口的 keySet 方法返回 Map 对象的 Set 视图,其中包含 Map 中的所有键。天真的是,对 keySet 的每次调用都必须创建一个新的 Set 实例,但是对给定 Map 对象上的 keySet 的每次调用都可能返回相同的 Set 实例。虽然返回的 Set 实例通常是可变的,但所有返回的对象在功能上都是相同的:当返回的对象之一发生更改时,所有其他对象也会发生更改,因为它们都由相同的 Map 实例支持。虽然创建 keySet 视图对象的多个实例基本上是无害的,但这是不必要的,也没有好处。
1 | // keySet源码 |
另一种创建不必要对象的方法是自动装箱,它允许程序员混合基本类型和包装类型,根据需要自动装箱和拆箱。自动装箱模糊了基本类型和包装类型之间的区别, 两者有细微的语义差别和不明显的性能差别。考虑下面的方法,它计算所有正整数的和。为了做到这一点,程序必须使用 long,因为 int 值不够大,不足以容纳所有正整数值的和:
1 | // Hideously slow! Can you spot the object creation? |
教训很清楚:基本类型优于包装类,还应提防意外的自动装箱。
本条目不应该被曲解为是在暗示创建对象是成本昂贵的,应该避免。相反,创建和回收这些小对象的构造函数成本是很低廉的,尤其是在现代 JVM 实现上。创建额外的对象来增强程序的清晰性、简单性或功能通常是件好事。
相反,通过维护自己的对象池来避免创建对象不是一个好主意,除非池中的对象非常重量级。证明对象池是合理的对象的典型例子是数据库连接。建立连接的成本非常高,因此复用这些对象是有意义的。然而,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代 JVM 实现具有高度优化的垃圾收集器,在轻量级对象上很容易胜过这样的对象池。
当前项的描述是:「在应该复用现有对象时不要创建新对象」,而 Item 50 的描述则是:「在应该创建新对象时不要复用现有对象」。请注意,当需要进行防御性复制时,复用对象所受到的惩罚远远大于不必要地创建重复对象所受到的惩罚。在需要时不制作防御性副本可能导致潜在的 bug 和安全漏洞;而不必要地创建对象只会影响样式和性能。
7. 消除过期的对象引用
即使Java有内存管理机制,对于开发者来说仍需要考虑内存管理的问题。
1 | import java.util.Arrays; |
上面这段代码没有明显的错误。但是程序中隐藏着一个问题,可能会存在“内存泄露”。如果一个栈先是增长(push),然后收缩(pop),被栈弹出的对象不会被当做垃圾回收。(因为这个弹出只是逻辑上的弹出,弹出的对象仍然在数组中)。
所以存在内存泄露的风险。这种引用也被称为过期引用
过期引用:本文的过期引用指逻辑上应该被清空,但是物理上还存在的引用。
这类问题的修复很简单,一旦对象引用过期,只需要手动清空这些引用即可。
1 | public Object pop() { |
清空过期引用有另一个好处:如果这些过期引用又被错误的使用,程序就会立刻抛出NullPointerException异常,而不是悄悄的错误运行下去。
但是也没有必要过分小心:对于每一个对象引用,一旦程序不再使用,就把它清空
。因为这样会把代码弄得很乱,后期也不容易维护。**清空对象引用应该是一种例外,而不是一种规范的行为。**消除过期引用最好的方法就是让包含该引用的变量结束其生命周期。
但为何上述Stack存在内存泄露的风险?
简单地说,它管理自己的内存。存储池包含元素数组的元素(element, 对象引用单元,而不是对象本身)。数组的活动部分(size)中的元素被分配,而数组其余部分中的元素是空闲的。垃圾收集器没有办法知道这一点;对于垃圾收集器,元素数组中的所有对象引用都同样有效。只有程序员知道数组的非活动部分不重要。只要数组元素成为非活动部分的一部分,程序员就可以通过手动清空数组元素,有效地将这个事实传递给垃圾收集器。
注意事项:
所以,**只要类是自己管理内存,程序员就应该警惕内存泄露问题。**一旦元素被释放掉,则该元素中包含的任何对象引用都应该被清空。
同时,**也要注意缓存,因为内存泄露的另一个常见来源是缓存。**一旦将对象引用放入缓存中,就很容易忘记它就在那里,并且后续不在使用的时候仍在留在缓冲中一段时间。
避免方式:
可以在缓存中使用弱引用,保证缓存的自动清除。当缓存对象只有缓存的弱引用时,就会在下一个垃圾回收时回收。
增加一个后台线程(比如ScheduledThreadPoolExecutor),或者在缓存添加新条目的时候顺带清除掉没用的对象。
还有,**缓存泄露的第三个常见来源是监听器和其他回调。**比如,实现了一个API,客户端在这个API中注册了回调,但是没有显示的取消注册,那么除非采取某些动作,否则他们就会不断的堆积起来。
避免方式:
确保回调立即被当做垃圾回收的最佳方式就是只保存它们的弱引用,比如只将它们保存成WeakHashMap中的键。
最后,可以考虑借助Heap剖析工具(Heap profiler)发现内存泄露问题。
8. 避免使用终结方法和清除方法
本小节内所表述的使用终结方法、清除方法是指像普通方法一样使用,不作为安全网时使用。
终结方法通常是不可预测的,一般情况下不建议使用。及时在Java 9中用清除方法代替了终结方法,但是仍是不可预测、运行缓慢的。
类的终结方法、清除方法的缺点:
-
不能保证会被及时执行。从一个对象变得不可到达开始,到他的终结方法被执行,所花费的这段时间是任意长的。注重时间的任务不应该由终结方法或者清除方法来完成。比如文件类型。
Java语言规范不仅不保证终结方法或者清除方法会被及时的执行,甚至根本就不保证他们会被执行。有一种可能:当程序终止的时候,某些对象的终结方法还没有被执行。永远不应该依赖终结方法或者清除方法来更新重要的持久状态。
即使Java底层有System.gc和System.runFinalization这两个方法,这也只是增加了终结方法和清除方法被执行的机会,并不能保证终结方法或者清除方法一定被执行。
-
使用终结方法,无法捕获该方法中的异常,甚至连警告都不会打印出来。(清除方法没有这个问题:因为使用清除方法的一个类库在控制它的线程)。
-
使用终结方法和清除方法会有非常严重的性能损失。(但是如果把终结方法、清除方法作为安全网,则效率不会性能很多)
-
**终结方法有一个严重的安全问题,为终结方法攻击提供了可能。**即如果对象在执行构造方法的时候抛出异常,终结方法也会运行,即使这个类的构造方法没有执行完。总之:从构造器抛出的异常,应该足以防止对象继续存在;但有了终结方法,这点就做不到了。
如果类对象中封装了资源(比如文件或者线程),确实终止。只需让类实现AutoCloseable,并重写close方法,客户端在实例不需要的时候调用close方法即可。
值得提及的一个细节是,该实例必须记录下自己是否已经被关闭了:close方法必须在一个私有域中记录下“该对象已经不再有效”。 如果这些方法是在对象已经终止之后被调用,其他的方法就必须检查这个域,并抛出 IllegalStateException 异常。
类的终结方法、清除方法的优点:
- 当资源的所有者忘记调用close方法时,终结方法或者清除方法可以充当“安全网”。虽然不能保证方法被及时的运行。
- 方便关闭对象的本地对等体。本地对等体就是
一个本地(非Java的)对象
,是普通对象(Java对等体)通过本地方法委托给一个本地对象。因为本地对象不会被Java的垃圾回收器回收,所以可以在Java对等体被回收的时候,使用终结方法、清除方法关闭本地对等体。
清除方法的使用有一定的技巧,下面以简单的Room类为例,假设房间在回收之前必须被清除。Room类实现了AutoCloseable,并利用清除方法自动清除。
1 | import sun.misc.Cleaner; |
内嵌的静态类State保存清除方法清除房间所需的资源;numJunkPiles域,表示房间的杂乱度。State实现了Runnable接口,他的run方法最多被Cleanable调用一次。有两种情况会触发run方法的调用:1. 调用Room的close方法,本质是调用cleanable.clean()。2. 如果Room实例应该被垃圾回收时,客户端没有调用close方法,清除方法就会调用State的run方法。
State实例没有引用它的Room实例。 如果它引用了就会造成循环,阻止Room实例被垃圾回收(以及防止被自动清除)。因此State必须是一个静态的嵌套类,因为非静态的嵌套类包含了对其外围实例的引用。同样地,也不建议使用lambda, 因为它们很容易捕捉到对外围对象的引用。
就像之前说的,Room类的清除器只是用作安全网。如果客户端将所有Room实例包围在带有资源的try块中,则永远不需要自动清理。
1 | public class Adult { |
但如果是下面这种场景,则不会打印出"Cleaning room"。
1 | public class Teenager { |
因为Cleaner规范:清除方法在System.exit期间的行为和实现有关,但是不确保清除方法是否会被调用。
在sout之前,添加System.gc(),就可以让其在退出之前打印出Cleaning room。
总而言之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用清除方法,对于在Java 9之前的发行版本,则尽量不要使用终结方法。若使用了终结方法或者清除方法,则要注意它的不确定性和性能后果。
9. try-with-resource优先于try-finally
Java库包含许多必须通过调用close方法手动关闭的资源。常见的有InputStream、OutputStream 和 java.sql.Connection。客户端经常会忽略资源的关闭,而造成严重的性能后果。虽然许多资源用终结方法作为安全网,但是这种方法也不是很理想。
根据经验,try-finally 语句是确保正确关闭资源的最佳方法,即使在出现异常或返回时也是如此:
1 | // try-finally - No longer the best way to close resources! |
这可能看起来好像也不坏,但添加第二个资源时,情况会变得更糟:
1 | // try-finally is ugly when used with more than one resource! |
即使try-finally语句正确地关闭了资源,但是也存在一些不足:
以第一个firstLineOfFile为例,如果运行时物理设备异常,则调用readLine方法时就会抛出异常,但是由于同样的原因,close方法也会异常。这种场景下,第二个异常完全抹除了第一个异常,在异常堆栈里找不到第一个异常。这时候有可能会使调试变得非常复杂。
当 Java 7 引入 try-with-resources 语句时,所有这些问题都一次性解决了。要使用这个结构,资源必须实现 AutoCloseable 接口,它包含了单个返回void的 close 方法组成。Java 库和第三方库中的许多类和接口现在都实现或扩展了 AutoCloseable。如果你编写的类存在必须关闭的资源,那么也应该实现 AutoCloseable。
下面是使用 try-with-resources 的第一个示例:
1 | // try-with-resources - the the best way to close resources! |
下面是使用 try-with-resources 的第二个示例:
1 | // try-with-resources on multiple resources - short and sweet |
和使用 try-finally 的最开始的两个代码相比,try-with-resources 为开发者提供了更好的诊断方式。如果使用 try-with-resources ,这时候运行 firstLineOfFile 方法,如果异常是由 readLine 调用和不可见的 close 抛出的,第二个异常就会被禁止,第一个异常就会被保留(如果有多个异常,也只会保留第一个)。这些被禁止的异常并不是被简单的抛弃,而是被打印在堆栈轨迹中,并注明是被禁止的异常。通过编程调用getSuppressed方法还可以访问到它们。【getSuppressed方法也已经添加在java 7的Throwable中了】。
try-with-resources 中也可以使用 catch 子句,比如:
1 | // try-with-resources with a catch clause |
所以:在处理必须关闭的资源时,始终要优先考虑用 try-with-resources,而不是try-finally。