第二章:对象的产生和销毁
其围绕的主要问题是:何时,如何产生对象;何时,如何避免产生对象;如何确认对象及时销毁;以及如何管理销毁前的各种清理动作。
条目一:考虑使用静态工厂方法来代替构造函数
这里的静态工厂方法并不是指设计模式中的某一设计模式,而是类中提供一个静态方法用以生成该类的对象,比如
String.valueOf(long value) -> String
String.valueOf(float value) -> String
Boolean.valueOf(boolean value) -> Boolean
在传统的使用里,我们会提供一个类的构造函数来让用户去生成对象。静态工厂方法与构造函数相比,有以下几个明显的优点:
- 方法有名字,好的方法名可以让用户清楚的知道在调用什么。
- 可以不每次调用都产生一个新的对象。这里可以使用已经构建好的,缓存的对象。
- 可以返回不同的子类。这提供了类型上的多样性。
- 返回的对象的类可以在定义方法时并不存在。比如在 service provider framework 中由服务实现者提供。
静态工厂方法也有缺点:
- 只有静态工厂方法没有 public 或 protected 构造函数时,该类无法被继承。
- 静态工厂方法也是静态方法,无法让客户一眼看出它负责生成对象。
插播一个知识点:服务提供者框架 Service Provider Framework
多个服务提供者实现一个服务,系统为客户端提供多个实现,并把他们从多个实现中解耦出来。
服务提供者框架的组件:
- Service Interface: 服务接口,通过抽象统一声明,由服务提供者实现。
- Provider Registration API: 服务提供者注册 API,用于系统注册服务提供者。
- Service Access API: 服务访问 API,客户端由此获得相应的服务(实现)
- Service Provider Interface: 服务提供者接口(可选),负责创建起服务实现的实例。
服务提供者接口可选,JDBC 中没有这一项,而是直接通过 class.forName,反射生成 service 实现类实例进行注册。
这里是我想的一个小例子,比如系统需要提供输入法给用户使用,但市场上可以有多家输入法提供商,上面的服务提供者框架就可以这么使用。
// 服务接口,约定提供商们必须提供的实现
public interface InputMethodService{
void show();
void hide();
}
// 服务提供者接口
public interface InputMethodProvider{
InputMethodService getService();
}
// 服务者注册 API, 访问 API
publc class InputMethodServiceManager{
private static final Map<String, InputMethodProvider> providers = new ConcurrentHashMap<String, InputMethodProvider>();
public static void registerProvider(String name, InputMethodProvider p){
providers.put(name, p);
}
public static InputMethodService getService(String name){
InputMethodProvider p = providers.get(name);
if(p == null){
throw new IllegalArgumentException("No provider with name: " + name);
}
return p.getService();
}
}
// A家输入法服务提供商就可以根据这个框架的约定来实现
public class InputMethodServiceImpl implements InputMethodService{
...
}
public class InputMethodProviderImpl implements InputMethodProvider{
static{
InputMethodServiceManager.registerProvider("A", new InputMethodProviderImpl());
}
public InputMethodService getService(){
return new InputMethodServiceImpl();
}
}
条目二:当构造函数参数过多时考虑使用 builder
但构造函数的参数过多时(有些必要,有些可选),如果使用重叠构造法,客户端使用起来会比较困难,也比较难阅读。
另外一种选择通过JavaBeans模式 setter 将参数设置进去,构造过程被分离到几个调用中,在构造过程中可能处于不一致的状态,也不能创建一个不可修改的对象。
而如果使用 builder 模式,则既能像重叠构造器模式那样的安全性,也能保证像 JavaBeans 模式这样的可读性。
builder 模式的不足之处:为了创建对象,必须先创建它的builder构造器,builder 模式比重叠构造器模式更加冗长。
条目三:使用私有构造器或枚举强化单例属性
单例模式的写法及相关优缺点留待设计模式时再详细整理。
私有构造器也不是万无一失的,有特权的客户端也可以通过反射修改访问级别,从而在外面调用。如果需要抵御这种攻击,可以再构造器第二次调用时抛出异常。
单例可序列化的支持:如果单例类要可序列化,并不是仅仅实现 Serializable 接口就行的。必须将所有的实例域都声明为瞬时的,并提供一个 readResolve 方法,防止创建一个新的实例。
// readResolve method to perserve singleton property
private Object readResolve(){
// INSTANCE 为该类的单例
return INSTANCE;
}
而如果使用枚举来实现单例,枚举保证序列化机制和反射攻击时都不会创建多个实例。
插播一则序列化的知识
在默认的序列化实现中,Java 对象的非静态和非瞬时域都会被包括起来,与域的可见性声明没有关系。如果对象的某些域不想被序列化,可以选择将其声明为瞬时的(transient),或者添加 serialPersistentField 域来声明序列化时要包含的域。
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("firstName", String.class)
};
条目四:通过私有构造器强化不可实例化的能力
有些工具类只包含静态方法和静态域,并不希望被实例化。在没有显示构造器的情况下,编译器会自动提供一个公有的、无参的缺省构造器。客户端可能会在无意中使用了构造器,这背离了该类的初衷。
通过给类添加私有构造器,这样客户端就无法使用构造器了。而且该类无法被子类化了,因为所有的构造器都必须显示或隐式地调用超类的构造器。
条目五:使用依赖注入来代替资源硬编码
有时候一些单例或者静态工具类会需要使用到资源,资源会影响该类的行为。
比如一个 SpellChecker 类,它需要一个 dictionay,但是不同的语音的拼写检查需要依赖到不同的字典,或者测试的时候需要特殊的字典。
这种时候不要在类中直接创建获取资源。相反地,将资源依赖,或者创建资源的工厂类传递进 类的构造器(或者静态工厂方法,builder),依赖注入能够提高类的灵活性和复用性。
条目六:避免创建不必要的对象
如果功能相同,那么能重用对象就尽量重用,成本低,而不是每次需要都创建一个新的对象。如果对象是不可变的,那么这个对象就始终可以被重用。
静态工厂方法也是一种避免创建非必要对象的办法之一。
适配器或者视图,也是一个可复用的对象:它把功能委托给后备对象,提供接口,但并无后背对象的相关状态信息,所以没有必要创建多个适配器。
基本数据类型的自动装箱和自动拆箱也会创建不必要的对象,可避免。
当对象的创建成本比较高,这时候维护自己的对象池来避免创建过多的对象就是一种不错的做法。
本条目并不是鼓励一切都想尽办法重用对象,而是“但你应该重用现有对象的时候,请不要创建新的对象”,而还有一条目是说“但你应该创建新的对象的时候,请不要重用现有的对象”,所以创建新的对象,和重用现有对象应该在使用场景中做判断。
条目七:消除过时对象的引用
虽然 Java 并不像 C 或 C++子类的需要手动释放内存的语言,Java 自带了垃圾收集器,但要及时消除过时的,不再使用的对象的引用,避免内存泄露。
比如一个栈的 pop方法
public Object pop(){
if(size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // eliminate obsolete reference
return result;
}
当一个类自己管理内存时,就应该警惕内存泄露问题。如 Stack 自己管理 elements,只有程序员知道内存中哪些是不重要,需要手工清空。
清空对象引用应该是一种例外,而不是一种规范行为。 消除过期引用最好的方法是让包含该引用的变量结束其生命周期
内存泄露的另一个常见来源是缓存。 对象放在缓存后,什么时候失效,什么时候删除。
当只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以使用 WeakHashMap 代表缓存;当缓存中的项过期之后,它们就会自动被删除。
也可以由一个后台线程(可能是 Timer 或者 ScheduledThreadPoolExecutor) 来完成,或者在添加缓存项时顺便清理,可以使用 LinkedHashMap 类的 removeEldestEntry方法。如果是更加复杂的缓存,必须直接使用 java.lang.ref
内存泄露的第三个常见来源是监听器和其他回调。比如注册之后没有反注册。
条目八:避免 finalizers 和 cleaners
Java 9 中使用 clearners 来代替 finalizers,但两者都是不可预计的,危险的。不能保证一定会执行。所以如果一个类需要释放资源,请提供一个显示方法来释放,不可依赖系统去调用 finalizer, cleaner。
条目九:使用 try-with-resources 代替 try-finally
当一些资源需要客户端手动关闭时,曾经使用 try-finally 在 finally 语句中进行资源的关闭是最好的使用方式,这种方式在有超过一个资源时就会显得不优雅,如下:
void copy(String src, String dst) throws IOException{
InputStream in = new FileInputStream(src);
try{
OutputStream out = new FileOutputStream(dst);
try{
byte[] buf = new byte[BUFFER_SIZE];
int n;
while((n = in.read(buf)) >= 0)
out.write(buf,0,n);
} finally {
out.close();
}
} finally {
in.close();
}
}
在 Java 7中,使用 try-with-resources 可以避免上面的问题,所使用的资源必须实现 AutoCloseable 接口,示例如下:
void copy(String src, String dst){
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while((n = in.read(buf)) >= 0)
out.write(buf,0,n);
} catch(IOException e){
...
}
}
try-with-resouces 更具备可读性,资源无需手动关闭,异常处理也更加有用(如果是 try-finally 里 finally 里抛的异常可能会消除更上面抛的异常,debug 时错失信息)。
第三章:对所有对象都通用的方法
Object 类的 equals, hashCode, toString, clone, finalize 有明确的通用约定,在覆盖这些方法时,有责任遵守这些通用约定;如果不能做到这一点,其他依赖于这些约定的类(例如 HashMap, HashSet)就无法和该类一起正常工作。
finalize 方法在第二章已经说明过,尽量不使用。
条目10:遵守 equals 的通用准则
覆盖 equals 看似简单,实际上非常容易犯错。最简单的方法就是不去覆盖它,这样的后果就是该类的每一个实例都只能和它自己等同(equals)。
什么时候一定要去覆盖 equals 呢?如果类有自己特有的"逻辑相等"概念,而且超类还没有覆盖 equals 以实现期望的行为。
这通常是属于"值类"的情形。值类仅仅表示一个值,程序员利用 equals 来比较值对象的引用时,是希望知道它们所代表的值是否相等,而不是了解是否指向同一个对象。
当实例受控,确保"每一个值至多只存在一个对象",枚举,对于这种类,逻辑相同,对象等同都是一回事,此时不覆盖 equals 也是可以的。
高质量 equals 方法示例:
@Override
public boolean equals(Object obj){
if(obj == this)
return true;
if (!(o instanceof A)) // instanceof 可以接受 o 为 null,返回 false
return false;
A a = (A)obj;
// 步骤四,
return (field == null ? a.field == null : field.equals(a.field));
}
步骤四是检测该类中所有关键域,如果都相等则这表示这两个对象从逻辑意义上是相等的。
对于非 float, double 类型的基本类型域,可以使用 == 操作符进行比较;对于对象引用域,可以递归使用 equals方法;对于 float, double,可以使用 Float.compare, Double.compare;对于数组域,数组内每个元素都得比较,可以使用 Arrays.equals。
覆盖 equals 方法后应该满足对称性,传递性,一致性。
另外覆盖 equals 时一定要覆盖 hashCode,请见下条。
条目11:覆盖 equals 时也一定要覆盖 hashCode
相同的对象必须有相同的散列码,不相同的对象必须有不同的散列码。如果没有覆盖 hashCode,那么那些基于散列的集合类(HashMap, HashSet, Hashtable) 就无法正常工作。
覆盖 hashCode 时,一个常见的做法就是计算所有域的 hashCode, 然后加起来。因为对象的域不同,那么计算得到的 hashCode 就会不同。
条目12:永远记得覆盖 toString
java.lang.Object 提供了 toString 方法的一个实现,不过它返回的字符串通常并不是类用户所期望看到的,没有带出对象的信息。所以,覆盖 toString ,返回对象中包含的所有值得关注的信息,这有助于类用户开发调试。
现在的大部分 IDE 可以帮忙为用户产生 toString方法。
条目13:谨慎覆盖 clone
当类实现了 Cloneable 接口的时候,我们就需要覆盖 clone(),反而言之,不实现该接口则不用覆盖。
Cloneable 接口本想设计成让实现它的类可以允许克隆,不幸的是,这个接口并没有设计好,它缺乏 c lone(),而 Object 的 clone() 的访问域是 protected 级别的,除非通过反射,否则无法仅仅因为一个对象实现了 Cloneable 就可以调用 clone()。因为设计的问题,克隆方法实现和使用起来比较繁琐和容易出错。
clone 方法就是另一个构造器,必须确保它不会伤害到原对象,并确保正确地创建被克隆对象中的约束条件。
实现 Cloneable 接口的类都需要用一个公有方法覆盖 clone,此方法首先调用 super.clone(),然后修正任何需要修正的域。
当我们调用一个对象的 clone 方法时,一般会希望得到一个深拷贝的对象(浅拷贝对象在使用过程可能会对原对象造成影响),而当对象中的 filed 有容器时,深拷贝就会比较麻烦(不同的容器对克隆的支持不一样)。
有时候如果仅仅是为了复制一个对象,可以采用其他的实现方式,而不是覆盖 clone,反而简单不易出错。比如说可以提供一个拷贝构造器,或者拷贝工厂方法,它们的唯一参数类型就是包含该构造器的类。
条目14:考虑实现 Comparable 接口
Comparable 接口不是必须要实现的接口。如果类实现了 Comparable 接口,它就可以跟许多泛型算法以及依赖于该接口的集合实现进行协作。Java 平台类库中的所有值类都实现了此接口。如果你正在编写一个值类,它具有非常明显的内在排序关系,比如按字母顺序、按数值顺序或按年代顺序,那么就应该坚决考虑实现这个接口。
依赖于比较关系的类有包括有序集合类 TreeSet 和 TreeMap,以及工具类 Collections 和 Arrays,它们内部含有搜索和排序算法。
compareTo方法中域的比较是顺序的比较,而不是等同性的比较,可以通过递归调用 compareTo方法来实现,如果一个域没有实现 comparable 接口,或者你需要使用一个非标准的排序关系,那么可以使用一个显示的 Comparator 来代替。
compareTo方法的约定并没有指定返回值的大小,只是要求了返回值的符号。所以直接返回两个值的差,但是要非常小心,除非你确信相关的域不会为负值,或者更一般的情况:最小和最大的可能域值之差小于或等于 Integer.MAX_VALUE。
public int compareTo(PhoneNumber pn){
// compare area codes
int areaCodeDiff = areaCode - pn.areaCode;
if(areaCodeDiff != 0)
return areaCodeDiff;
}