用户工具

站点工具


code:java:create-object

Java 创建对象

Java 对象的创建(实例化),在语法层面上可以分为:

  1. 使用 new 关键字创建
  2. 使用反射方法创建

但是在类或者程序的设计上来说,我们还可以有:

1 使用静态方法返回实例

在某些场景下使用静态工厂返回实例可以带来以下好处:

1.1 更加清晰准确地描述对象实例的特征

在 Effective Java 的书中用 BigInteger 进行举例的,我们首先可以看 BigIngeter 已有的构造函数:

public BigInteger(byte[])
 
public BigInteger(int, byte[])
 
public BigInteger(String, int)
 
public BigInteger(String)
 
public BigInteger(int, Random)
 
public BigInteger(int, int Random)

不说那些使用 byte[] 作为参数的构造函数不知道什么含义,即使最后两个看着参数类似的构造函数也其实是完全不同的意思,而最后一个构造函数就是要返回一个可能为质数的 BigInteger 实例…

这种希望构造函数在不同的参数情况下返回不同特征的实例的场景,尤其是不同构造函数参数个数、类型还非常相似的情况下,非常容易让用户迷惑,适合使用静态工厂方法来替代:

public static probablePrime(int, Random): BigInteger

1.2 控制每一次是否返回新实例化的对象

单例模式、多例模式以及注册模式都有使用这种方法来返回实例,除此之外,如果我们的程序中有一些类设计成 immutable 的话,那么由于对象是不可改变的,所以可以放心地把同一个对象实例交给不同的用户去使用,不用担心大家互相修改对象属性导致的问题。举例如下:

public final class Boolean {
    public static final Boolean TRUE = new Boolean(true);
    public static final Boolean FALSE = new Boolean(false);
 
    public static Boolean valueOf(boolean b) {
        return (b ? TRUE : FALSE);
    }
}

又比如我们平时经常使用到的获取空列表(EmptyList不支持add操作)

public class Collections {
    public static final List EMPTY_LIST = new EmptyList<>();
 
    public static final <T> List<T> emptyList() {
        return (List<T>) EMPTY_LIST;
    }
}

还有像 jdk 1.8 里新增的 Optional 类:

public final Class Optional<T> {
    private static final Optional<?> EMPTY = new Optional<>();
 
    public static<T> Optional<T> empty() {
        Optional<T> t = (Optional<T>) EMPTY;
        return t;
    }
}

这里几个例子的思想都是实例化一个不可变的对象,然后通过统一的方法提供出来,避免重复实例化造成的资源浪费,同时也可以提升性能。类似概念的设计还可以应用在其他地方,比如 jdk 1.8 新增的 CompletableFuture 类内部实例化一个 AltResult 类型的静态 NIL 对象,表示没有内容的结果(而不是结果为 null)

public class CompletableFuture {
    static final AltResult NIL = new AltResult(null);
 
    public boolean completeValue(T value) {
        return UNSAFE.compareAndSwapObject(this, RESULT, null,
                                           (t == null) ? NIL : t);
    }
}

1.3 返回接口的不同实现或者子类

在面向接口编程的思想下,我们通常不愿意将具体的接口实现类直接暴露给用户,所以通常就开一个类似注册中心角色的类,通过静态或者非静态的方法去获取接口的实现类实例。

比如在创建可执行服务(ExecutorService)的时候,JDK 的 Executors 提供了很多的静态方法:

public class Executors {
    public static ExecutorService newCachedThreadPool();
 
    public static ExecutorService newSingleThreadExecutor();
}

又比如大家都非常熟悉的 w3c dom 相关的接口

public abstract class DocumentBuilderFactory {
    public static DocumentBuilderFactory newInstance();
 
    public abstract DocumentBuilder newDocumentBuilder();
}
 
public abstract class DocumentBuilder {
    public abstract Document newDocument();
}

这里的 DocumentBuilderFactory, DocumentBuilder, Document 都不是用户直接 new 出来的,而是通过方法包装返回的。

1.4 减少创建对象时的参数

方法名本身就可以描述实例的特征,所以使用工厂方法来创建实例时可以提供更少的参数。如前面提到过的 Executors 对于 ExecutorService 的封装:

public class Executors {
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
}

原本需要提供 5 个参数来初始化一个基于线程池的可执行服务,但是经过封装一个参数都不需要传递了。又比如 guava 库里对于各种 Map 的封装:

public final class Maps {
    public static <K, V> HashMap<K, V> newHashMap() {
        return new HashMap<K, V>();
    }
}

当然其实现在初始化一个 HashMap 本身也可以不在等号右边写泛型的参数,原本的初始化写法已经比较简单了。还有像 Arrays.asList 方法可以直接将字面量的数组元素转换成 List,可以帮助节省代码(注意这样创建的List不支持增删元素):

public class Arrays {
    public static <T> List<T> asList(T... a);
}

尽管有这么多优点,也需要注意使用工厂方法可能带来的问题: 1. 如果原有的类直接禁止外部通过构造函数初始化,那也会使得其他人无法继承它。2. 需要提供详细的类注释、使用样例等,否则别人可能不知道该到哪里如何使用静态工厂方法。

2. 使用 Builder 创建实例

尤其适用于类的构造需要提供非常多参数的场景,使用 Builder 可以让参数的提供更加可读、可维护,同时 Builder 也可能帮助设置默认参数。即:

2.1 命名的参数

因为通过 Builder 进行参数设置时需要调用方法,比一大排参数按照顺序放在构造函数里的可维护性要好得多,以 jdk 的 ProcessBuilder 为例:

public final class ProcessBuilder {
    public ProcessBuilder(String...);
    public ProcessBuilder(List<String>);
    public ProcessBuilder command(List<String>);
    public ProcessBuilder directory(File);
    public ProcessBuilder inheritIO();
    public Process start();
}

作为对比,再看 ProcessImpl 类

public class ProcessImpl {
    static Process start(String cmdarray[],
                         java.util.Map<String,String> environment,
                         String dir,
                         ProcessBuilder.Redirect[] redirects,
                         boolean redirectErrorStream);
}

ProcessBuilder 将很多必须的参数变成了可选了,每一个参数的设置也更加明确了,同时还封装了 ProcessImpl 的具体实现类。通常 Builder 类会提供 flat 模式的API,使用起来更加方便,同样的比如有 OkHttp 项目使用 Builder 模式来构建 Request:

public class Request {
    public static class Builder {
        public Builder();
        public Builder url(String);
        public Builder header(String, String);
        public Builder post();
        public Request build();
    }
}

当然,也可以不使用 flat 风格的 API,那就是 Java Bean 的风格了。

3. 给单例的类的构造函数加上 private 修饰

单例模式是一个大的话题,这里不详细展开了。实际上单例模式带来的坏处很多:

  1. 可测试性差。因为是单例的,所以写单元测试的时候 Mock 起来并不方便,而且一般的单例实现还会将其作为 static 的属性,单元测试之间的隔离就比较麻烦。
  2. 写法复杂。写出一个正确的单例类虽然已经有套路了,但是那些代码看起来并不漂亮。
  3. 可扩展性差。因为是单例的,所以其他人并不好扩展他的实现,对于整个程序来说也是,单例类的功能不便于后续的进程内复用。所以其实也有人倡导单例类并不要将构造函数写成 private 的,通过约定的方式告诉别人不要去直接通过构造函数实例化就 OK 了。

4. 给设计成不能实例化的类加上 private 构造函数

通常用在一些只包含静态方法的工具类上,由于只包含静态方法,所以这个类实际上是不希望别人去实例化的,通常做法是:

public final class XmlUtil {
    private XmlUtil();
}

或者

public abstract class XmlUtli {
}

比较起来声明成 abstract 的类写法较为简单。但其实把心思放在这种堵别人实例化的路上并没什么大用,只要约定好告诉别人不用实例化就好了,别人调用静态方法的时候也不需要实例化出一个对象来,就像在定义接口的时候就可以约束某些参数必须不为 null 一样-.-。

5. 减少不必要的对象创建

一个必说的场景就是字符串的构造和拼接了:

String a = new String("Effective Java"); // 每次执行都会创建一个新的字符串对象
String a = "Effective Java"; // 直接使用常量池里的字符串

然后字符串的拼接,写了如下测试代码:

public class StringTest {
    public String fn1(String name) {
        return "Hello " + ", " + "Lilei" + ".";
    }
 
    public String fn2(String name) {
        return "Hello " + "," + name + ".";
    }
}

然后使用 Zulu 1.8.0_92-b15(openjdk)编译的字节码如下:

可见现在拼接字符串,如果是常量,那么直接用 + 号连接就好了,这样代码简洁、干净,编译器会自动优化成一个大的字符串常量。而如果涉及到动态字符串的拼接,如 fn2 的场景也仍然可以使用 + 号, 编译器会自动优化成 StringBuilder 的 append.

除了字符串外还需要注意避免 jvm 的自动装箱、拆箱操作。至于是否一定要长久、共享方式去持有一些公共对象来减少对象的创建,那是需要仔细斟酌的。尽快地释放对象,jvm的gc可能也能处理得很好,而尽可能减少共享的对象,有利于程序的设计、编写、维护。

6. 释放废弃对象的引用

Java 虽然是有 GC 自动管理内存,但是如果有不正确的代码,仍然可能导致内存泄漏的问题。最为常见的一种场景就是不断地往一个容器类型的对象里增加东西,而没有销毁或者移除元素的操作,又或者由于错误的编码,导致容器里的对象永远无法被移除了。

书中的一个例子:

public class Stack {
    private Object[] elements;
    private int size=0;
 
    public void push(Object e) {
        elemetns[size++] = e;
    }
 
    public Object pop() {
        return elements[--size]; // 后面需要增加 elements[size] = null;
    }
}

由于在 pop 操作时,只返回了对象,并没有从容器(数组)里消除不需要的元素的引用,这就导致了被pop的元素没有任何人再需要了,但是数组对象又一直持有着他的引用,gc也不会清理他,从而导致了内存泄漏。

另外还有一种容易出错的写法:

public class Registry {
    public Map<Key, Value> map = new HashMap<>();
 
    public void add(Key key, Value value) {
        map.put(key, value);
    }
 
    public void remove(Key key) {
        map.remove(key);
    }
} 

看起来没有问题,但是由于Map 的 Key 是一个对象,如果在 remove 的时候,使用者并不是使用当初 add 的时候传入的 key 的实例,而是新 new 出来的 key, 那么可能原本的 <key, value>就再也删不掉了。因此我们在写代码的时候也尽可能地不要把复杂的 object 作为 map 的 key。

在写一些脚本引擎,或者对象缓存的时候,也常容易引起内存泄漏的问题。这种场景下一般没有采用对象池的模式,而是从缓存里直接获取实例,用完了也不会告诉缓存。这样就导致了别人不用的对象,缓存却一直存着,又影响了GC的工作。这时采用的方法一般弱引用。

public class KlassCache {
    private Map<Class<?>, Object> caches = new WeakHashMap<>();
}

参考资料

code/java/create-object.txt · 最后更改: 2018/12/31 18:41 (外部编辑)