返回首页

Effective Java (创建和销毁对象) - Stephen_Liu

时间:2012-02-09 23:07来源:CTO51 作者:admin 点击:
  

一、考虑用静态工厂方法代替构造器:
    
      构造器是创建一个对象实例最基本也最通用的方法,大部分开发者在使用某个class的时候,首先需要考虑的就是如何构造和初始化一个对象示例,而构造的方式首先考虑到的就是通过构造函数来完成,因此在看javadoc中的文档时首先关注的函数也是构造器。然而在有些时候构造器并非我们唯一的选择,通过反射也是可以轻松达到的。我们这里主要提到的方式是通过静态类工厂的方式来创建class的实例,如:

1     public static Boolean valueOf(boolean b) {
2 return b ? Boolean.TRUE : Boolean.FALSE;
3 }

      静态工厂方法和构造器不同有以下主要优势:
      1.    有意义的名称。
      在框架设计中,针对某些工具类通常会考虑dummy对象或者空对象以辨别该对象是否已经被初始化,如我曾在我的C++基础库中实现了String类型,见如下代码:

 1     void showExample() {
2 String strEmpty = String::empty();
3 String strEmpty2 = "";
4 String strData = String::prellocate(1024);
5 if (strEmpty.isEmpty()) {
6 //TODO: do something
7 }
8 }
9 static String String::emptyString;
10 String& String::empty() {
11 return emptyString;
12 }
13
14 bool String::isEmpty() {
15 if (this->_internal == &emptyString->_internal)
16 return true;
17 //TODO: do other justice to verify whether it is empty.
18 }

      在上面的代码中,提供了两个静态工厂方法empty和preallocate用于分别创建一个空对象和一个带有指定分配空间的String对象。从使用方式来看,这些静态方法确实提供了有意义的名称,使用者很容易就可以判断出它们的作用和应用场景,而不必在一组重载的构造器中去搜寻每一个构造函数及其参数列表,以找出适合当前场景的构造函数。从效率方面来讲,由于提供了唯一的静态空对象,当判读对象实例是否为空时(isEmpty),直接使用预制静态空对象(emptyString)的地址与当前对象进行比较,如果是同一地址,即可确认当前实例为空对象了。对于preallocate函数,顾名思义,该函数预分配了指定大小的内存空间,后面在使用该String实例时,不必担心赋值或追加的字符过多而导致频繁的realloc等操作。   
      2.    不必在每次调用它们的时候创建一个新的对象。
      还是基于上面的代码实例,由于所有的空对象都共享同一个静态空对象,这样也节省了更多的内存开销,如果是strEmpty2方式构造出的空对象,在执行比较等操作时会带来更多的效率开销。事实上,Java在String对象的实现中,使用了常量资源池也是基于了同样的优化策略。该优势同样适用于单实例模式。 
      3.    可以返回原返回类型的任何子类型。
      在Java Collections Framework的集合接口中,提供了大量的静态方法返回集合接口类型的实现类型,如Collections.subList()、Collections.unmodifiableList()等。返回的接口是明确的,然而针对具体的实现类,函数的使用者并不也无需知晓。这样不仅极大的减少了导出类的数量,而且在今后如果发现某个子类的实现效率较低或者发现更好的数据结构和算法来替换当前实现子类时,对于集合接口的使用者来说,不会带来任何的影响。本书在例子中提到EnumSet是通过静态工厂方法返回对象实例的,没有提供任何构造函数,其内部在返回实现类时做了一个优化,即如果枚举的数量小于64,该工厂方法将返回一个经过特殊优化的实现类实例(RegularEnumSet),其内部使用long(64bits在Java中) 中的不同位来表示不同的枚举值。如果枚举的数量大于64,将使用long的数组作为底层支撑。然而这些内部实现类的优化对于使用者来说是透明的。
      4.    在创建参数化类型实例的时候,它们使代码变得更加简洁。
      Map<String,String> m = new HashMap<String,String>();
      由于Java在构造函数的调用中无法进行类型的推演,因此也就无法通过构造器的参数类型来实例化指定类型参数的实例化对象。然而通过静态工厂方法则可以利用参数类型推演的优势,避免了类型参数在一次声明中被多次重写所带来的烦忧,见如下代码:
      public static <K,V> HashMap<K,V> newInstance() {
          return new HashMap<K,V>();
      }
      Map<String,String> m = MyHashMap.newInstance();
   
二、遇到多个构造参数时要考虑用构建器(Builder模式):
    
      如果一个class在构造初始化的时候存在非常多的参数,将会导致构造函数或者静态工厂函数带有大量的、类型相同的函数参数,特别是当一部分参数只是可选参数的时候,class的使用者不得不为这些可选参数也传入缺省值,有的时候会发现使用者传入的缺省值可能是有意义的,而并非class内部实现所认可的缺省值,比如某个整型可选参数,通常使用者会传入0,然后class内部的实现恰恰认为0是一种重要的状态,而该状态并不是该调用者关心的,但是该状态却间接导致其他状态的改变,因而带来了一些潜在的状态不一致问题。与此同时,过多的函数参数也给使用者的学习和使用带来很多不必要的麻烦,我相信任何使用者都希望看到class的接口是简单易用、函数功能清晰可见的。在Effective C++中针对接口的设计有这样的一句话:"接口要完满而最小化"。针对该类问题通常会考虑的方法是将所有的参数归结到一个JavaBean对象中,实例化这个Bean对象,然后再将实例化的结果传给这个class的构造函数,这种方法仍然没有避免缺省值的问题。该条目推荐了Builder模式来创建这个带有很多可选参数的实例对象。

 1     class NutritionFacts {
2 private final int servingSize;
3 private final int servings;
4 private final int calories;
5 private final int fat;
6 private final int sodium;
7 private final int carbohydrate;
8 public static class Builder {
9 //对象的必选参数
10 private final int servingSize;
11 private final int servings;
12 //对象的可选参数的缺省值初始化
13 private int calories = 0;
14 private int fat = 0;
15 private int carbohydrate = 0;
16 private int sodium = 0;
17 //只用少数的必选参数作为构造器的函数参数
18 public Builder(int servingSize,int servings) {
19 this.servingSize = servingSize;
20 this.servings = servings;
21 }
22 public Builder calories(int val) {
23 calories = val;
24 return this;
25 }
26 public Builder fat(int val) {
27 fat = val;
28 return this;
29 }
30 public Builder carbohydrate(int val) {
31 carbohydrate = val;
32 return this;
33 }
34 public Builder sodium(int val) {
35 sodium = val;
36 return this;
37 }
38 public NutritionFacts build() {
39 return new NutritionFacts(this);
40 }
41 }
42 private NutritionFacts(Builder builder) {
43 servingSize = builder.servingSize;
44 servings = builder.servings;
45 calories = builder.calories;
46 fat = builder.fat;
47 sodium = builder.sodium;
48 carbohydrate = builder.carbohydrate;
49 }
50 }
51 //使用方式
52 public static void main(String[] args) {
53 NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100)
54 .sodium(35).carbohydrate(27).build();
55 System.out.println(cocaCola);
56 }

      对于Builder方式,可选参数的缺省值问题也将不再困扰着所有的使用者。这种方式还带来了一个间接的好处是,不可变对象的初始化以及参数合法性的验证等工作在构造函数中原子性的完成了。

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

      对于单实例模式,相信很多开发者并不陌生,然而如何更好更安全的创建单实例对象还是需要一些推敲和斟酌的,在Java中主要的创建方式有以下三种,我们分别作出解释和适当的比较。
      1.    将构造函数私有化,直接通过静态公有的final域字段获取单实例对象:

1     public class Elvis {
2 public static final Elvis INSTANCE = new Elvis();
3 private Elivs() { ... }
4 public void leaveTheBuilding() { ... }
5 }

      这样的方式主要优势在于简洁高效,使用者很快就能判定当前类为单实例类,在调用时直接操作Elivs.INSTANCE即可,由于没有函数的调用,因此效率也非常高效。然而事物是具有一定的双面性的,这种设计方式在一个方向上走的过于极端了,因此他的缺点也会是非常明显的。如果今后Elvis的使用代码被迁移到多线程的应用环境下了,系统希望能够做到每个线程使用同一个Elvis实例,不同线程之间则使用不同的对象实例。那么这种创建方式将无法实现该需求,因此需要修改接口以及接口的调用者代码,这样就带来了更高的修改成本。
      2.    通过公有域成员的方式返回单实例对象:

1     public class Elvis {
2 public static final Elvis INSTANCE = new Elvis();
3 private Elivs() { ... }
4 public static Elvis getInstance() { return INSTANCE; }
5 public void leaveTheBuilding() { ... }
6 }

      这种方法很好的弥补了第一种方式的缺陷,如果今后需要适应多线程环境的对象创建逻辑,仅需要修改Elvis的getInstance()方法内部即可,对用调用者而言则是不变的,这样便极大的缩小了影响的范围。至于效率问题,现今的JVM针对该种函数都做了很好的内联优化,因此不会产生因函数频繁调用而带来的开销。
      3.    使用枚举的方式(Java SE5):

1     public enum Elvis {
2 INSTANCE;
3 public void leaveTheBuilding() { ... }
4 }

      就目前而言,这种方法在功能上和公有域方式相近,但是他更加简洁更加清晰,扩展性更强也更加安全。
      我在设计自己的表达式解析器时,曾将所有的操作符设计为enum中不同的枚举元素,同时提供了带有参数的构造函数,传入他们的优先级、操作符名称等信息。

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

      对于有些工具类如java.lang.Math、java.util.Arrays等,其中只是包含了静态方法和静态域字段,因此对这样的class实例化就显得没有任何意义了。然而在实际的使用中,如果不加任何特殊的处理,这样的classes是可以像其他classes一样被实例化的。这里介绍了一种方式,既将缺省构造函数设置为private,这样类的外部将无法实例化该类,与此同时,在这个私有的构造函数的实现中直接抛出异常,从而也避免了类的内部方法调用该构造函数。

1     public class UtilityClass {
2 //Suppress default constructor for noninstantiability.
3 private UtilityClass() {
4 throw new AssertionError();
5 }
6 }

      这样定义之后,该类将不会再被外部实例化了,否则会产生编译错误。然而这样的定义带来的最直接的负面影响是该类将不能再被子类化。
    
五、避免创建不必要的对象:

      试比较以下两行代码在被多次反复执行时的效率差异:
      String s = new String("stringette");
      String s = "stringette";
      由于String被实现为不可变对象,JVM底层将其实现为常量池,既所有值等于"stringette" 的String对象实例共享同一对象地址,而且还可以保证,对于所有在同一JVM中运行的代码,只要他们包含相同的字符串字面常量,该对象就会被重用。
    
      我们继续比较下面的例子,并测试他们在运行时的效率差异:
      Boolean b = Boolean.valueOf("true");
      Boolean b = new Boolean("true");
      前者通过静态工厂方法保证了每次返回的对象,如果他们都是true或false,那么他们将返回相同的对象。换句话说,valueOf将只会返回Boolean.TRUE或Boolean.FALSE两个静态域字段之一。而后面的Boolean构造方式,每次都会构造出一个新的Boolean实例对象。这样在多次调用后,第一种静态工厂方法将会避免大量不必要的Boolean对象被创建,从而提高了程序的运行效率,也降低了垃圾回收的负担。 
      继续比较下面的代码:

 1     public class Person {
2 private final Date birthDate;
3 //判断该婴儿是否是在生育高峰期出生的。
4 public boolean isBabyBoomer {
5 Calender c = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
6 c.set(1946,Calendar.JANUARY,1,0,0,0);
7 Date dstart = c.getTime();
8 c.set(1965,Calendar.JANUARY,1,0,0,0);
9 Date dend = c.getTime();
10 return birthDate.compareTo(dstart) >= 0 && birthDate.compareTo(dend) < 0;
11 }
12 }
13
14 public class Person {
15 private static final Date BOOM_START;
16 private static final Date BOOM_END;
17
18 static {
19 Calender c = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
20 c.set(1946,Calendar.JANUARY,1,0,0,0);
21 BOOM_START = c.getTime();
22 c.set(1965,Calendar.JANUARY,1,0,0,0);
23 BOOM_END = c.getTime();
24 }
25 public boolean isBabyBoomer() {
26 return birthDate.compareTo(BOOM_START) >= 0 && birthDate.compareTo(BOOM_END) < 0;
27 }
28 }

      改进后的Person类只是在初始化的时候创建Calender、TimeZone和Date实例一次,而不是在每次调用isBabyBoomer方法时都创建一次他们。如果该方法会被频繁调用,效率的提升将会极为显著。
      集合框架中的Map接口提供keySet方法,该方法每次都将返回底层原始Map对象键数据的视图,而并不会为该操作创建一个Set对象并填充底层Map所有键的对象拷贝。因此当多次调用该方法并返回不同的Set对象实例时,事实上他们底层指向的将是同一段数据的引用。
      在该条目中还提到了自动装箱行为给程序运行带来的性能冲击,如果可以通过原始类型完成的操作应该尽量避免使用装箱类型以及他们之间的交互使用。见下例:

1     public static void main(String[] args) {
2 Long sum = 0L;
3 for (long i = 0; i < Integer.MAX_VALUE; ++i) {
4 sum += i;
5 }
6 System.out.println(sum);
7 }

      本例中由于错把long sum定义成Long sum,其效率降低了近10倍,这其中的主要原因便是该错误导致了2的31次方个临时Long对象被创建了。

六、消除过期的对象引用:

      尽管Java不像C/C++那样需要手工管理内存资源,而是通过更为方便、更为智能的垃圾回收机制来帮助开发者清理过期的资源。即便如此,内存泄露问题仍然会发生在你的程序中,只是和C/C++相比,Java中内存泄露更加隐匿,更加难以发现,见如下代码:

 1     public class Stack {
2 private Object[] elements;
3 private int size = 0;
4 private static final int DEFAULT_INITIAL_CAPACITY = 16;
5 public Stack() {
6 elements = new Object[DEFAULT_INITIAL_CAPACITY];
7 }
8 public void push(Object e) {
9 ensureCapacity();
10 elements[size++] = e;
11 }
12 public Object pop() {
13 if (size == 0)
14 throw new EmptyStackException();
15 return elements[--size];
16 }
17 private void ensureCapacity() {
18 if (elements.length == size)
19 elements = Arrays.copys(elements,2*size+1);
20 }
21 }

      以上示例代码,在正常的使用中不会产生任何逻辑问题,然而随着程序运行时间不断加长,内存泄露造成的副作用将会慢慢的显现出来,如磁盘页交换、OutOfMemoryError等。那么内存泄露隐藏在程序中的什么地方呢?当我们调用pop方法是,该方法将返回当前栈顶的elements,同时将该栈的活动区间(size)减一,然而此时被弹出的Object仍然保持至少两处引用,一个是返回的对象,另一个则是该返回对象在elements数组中原有栈顶位置的引用。这样即便外部对象在使用之后不再引用该Object,那么它仍然不会被垃圾收集器释放,久而久之导致了更多类似对象的内存泄露。修改方式如下:

1     public Object pop() {
2 if (size == 0)
3 throw new EmptyStackException();
4 Object result = elements[--size];
5 elements[size] = null; //手工将数组中的该对象置空
6 return result;
7 }

      由于现有的Java垃圾收集器已经足够只能和强大,因此没有必要对所有不在需要的对象执行obj = null的显示置空操作,这样反而会给程序代码的阅读带来不必要的麻烦,该条目只是推荐在以下3中情形下需要考虑资源手工处理问题:
      1)    类是自己管理内存,如例子中的Stack类。
      2)    使用对象缓存机制时,需要考虑被从缓存中换出的对象,或是长期不会被访问到的对象。
      3)    事件监听器和相关回调。用户经常会在需要时显示的注册,然而却经常会忘记在不用的时候注销这些回调接口实现类。
    
七、避免使用终结方法:

      任何事情都存在其一定的双面性或者多面性,对于C++的开发者,内存资源是需要手工分配和释放的,而对于Java和C#这种资源托管的开发语言,更多的工作可以交给虚拟机的垃圾回收器来完成,由此C++程序得到了运行效率,却失去了安全。在Java的实际开发中,并非所有的资源都是可以被垃圾回收器自动释放的,如FileInputStream、Graphic2D等class中使用的底层操作系统资源句柄,并不会随着对象实例被GC回收而被释放,然而这些资源对于整个操作系统而言,都是非常重要的稀缺资源,更多的资源句柄泄露将会导致整个操作系统及其运行的各种服务程序的运行效率直线下降。那么如何保证系统资源不会被泄露了?在C++中,由于其资源完全交由开发者自行管理,因此在决定资源何时释放的问题上有着很优雅的支持,C++中的析构函数可以说是完成这一工作的天然候选者。任何在栈上声明的C++对象,当栈退出或者当前对象离开其作用域时,该对象实例的析构函数都会被自动调用,因此当函数中有任何异常(Exception)发生时,在栈被销毁之前,所有栈对象的析构函数均会被自动调用。然而对于Java的开发者而言,从语言自身视角来看,Java本身并未提供析构函数这样的机制,当然这也是和其资源被JVM托管有一定关系的。
      在Java中完成这样的工作主要是依靠try-finally机制来协助完成的。然而Java中还提供了另外一种被称为finalizer的机制,使用者仅仅需要重载Object对象提供的finalize方法,这样当JVM的在进行垃圾回收时,就可以自动调用该方法。但是由于对象何时被垃圾收集的不确定性,以及finalizer给GC带来的性能上的影响,因此并不推荐使用者依靠该方法来达到关键资源释放的目的。比如,有数千个图形句柄都在等待被终结和回收,可惜的是执行终结方法的线程优先级要低于普通的工作者线程,这样就会有大量的图形句柄资源停留在finalizer的队列中而不能被及时的释放,最终导致了系统运行效率的下降,甚至还会引发JVM报出OutOfMemoryError的错误。
      Java的语言规范中并没有保证该方法会被及时的执行,甚至都没有保证一定会被执行。即便开发者在code中手工调用了System.gc和System.runFinalization这两个方法,这仅仅是提高了finalizer被执行的几率而已。还有一点需要注意的是,被重载的finalize()方法中如果抛出异常,其栈帧轨迹是不会被打印出来的。在Java中被推荐的资源释放方法为,提供显式的具有良好命名的接口方法,如FileInputStream.close()和Graphic2D.dispose()等。然后使用者在finally区块中调用该方法,见如下代码:

1     public void test() {
2 FileInputStream fin = null;
3 try {
4 fin = new FileInputStream(filename);
5 //do something.
6 } finally {
7 fin.close();
8 }
9 }

      那么在实际的开发中,利用finalizer又能给我们带来什么样的帮助呢?见下例:

 1     public class FinalizeTest {
2 //@Override
3 protected void finalize() throws Throwable {
4 try {
5 //在调试过程中通过该方法,打印对象在被收集前的各种状态,
6 //如判断是否仍有资源未被释放,或者是否有状态不一致的现象存在。
7 //推荐将该finalize方法设计成仅在debug状态下可用,而在release
8 //下该方法并不存在,以避免其对运行时效率的影响。
9 System.out.println("The current status: " + _myStatus);
10 } finally {
11 //在finally中对超类finalize方法的调用是必须的,这样可以保证整个class继承
12 //体系中的finalize链都被执行。
13 super.finalize();
14 }
15 }
16 }
顶一下
(0)
0%
踩一下
(0)
0%
------分隔线----------------------------
最新评论 查看所有评论
发表评论 查看所有评论
请自觉遵守互联网相关的政策法规,严禁发布色情、暴力、反动的言论。
评价:
表情:
用户名: 密码: 验证码:
发布者资料
小朱 查看详细资料 发送留言 加为好友 用户等级:超级会员 注册时间:2008-11-18 17:11 最后登录:2012-02-09 23:02
推荐内容
热点内容