Effective Java英文第三版读书笔记(1) -- 科学地创建和销毁对象

写在前面

《Effective Java》原书国内的翻译只出版到第二版,书籍的编写日期距今已有十年之久。这期间,Java已经更新换代好几次,有些实践经验已经不再适用。去年底,作者结合Java7、8、9的最新特性,编著了第三版(参考https://blog.csdn.net/u014717036/article/details/80588806)。当前只有英文版本,可以在互联网搜索到PDF原书。本读书笔记都是基于原书的理解。


以下是正文部分

如何科学地创建和销毁对象(Creating and Destroying Objects)

实践1 抛弃构造函数,使用静态工厂方法

什么是静态工厂方法(static factory method)

简单讲,它就是一个返回当前对象实例的静态方法。示例如下:

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

1.1 优点

  1. 构造函数都以类名命名,区分度不高,而静态工厂方法可以个性化,对用户更加友好。
  2. 静态工厂方法不是必须重新创建一个对象,例如上面 Boolean 的代码中,返回的是早前已经创建好的对象。这类似于设计模式中的享元模式(Flyweight pattern),典型的,相同内容的String以及Enum就用了该模式。
  3. 静态工厂方法可以返回子类。Java8中,取消了接口不能包含static方法的限制,因此在接口上实现这种静态工厂方法类,简化了文档,用户也只需关注主类。
  4. 静态工厂方法可以根据参数而返回不同的内容。如下示例,根据参数返回不同的类,屏蔽了一些内部细节。用户只需要知道返回的是EnumSet或其子类即可,哪怕以后EnumSet进一步细分,代码也几乎不需要重构。
public static > EnumSet noneOf(Class elementType) {
    Enum[] universe = getUniverse(elementType);
    if (universe == null)
        throw new ClassCastException(elementType + " not an enum");
    if (universe.length <= 64)
        return new RegularEnumSet<>(elementType, universe);
    else
        return new JumboEnumSet<>(elementType, universe);
}
  1. 静态工厂方法返回的对象类型,甚至可以在当前位置不存在。 这在SPI中用处较多。例如JDBC服务里面,java.sql.Driver接口是对外公开的一个加载驱动接口,但Jdk中并没有相关实现,实际是由各sql厂商拿到接口后做的实现。

1.2 不足

  1. 没有public或者protected构造函数的类是无法被继承的。
  2. 在接口中,静态工厂方法不如构造函数显眼,使用者难以发现。

1.3 最佳实践

Date d = Date.from(instant);
Set faceCards = EnumSet.of(JACK, QUEEN, KING);
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
StackWalker luke = StackWalker.getInstance(options);
Object newArray = Array.newInstance(classObject, arrayLen);
FileStore fs = Files.getFileStore(path);
BufferedReader br = Files.newBufferedReader(path);
List litany = Collections.list(legacyLitany);

实践2 当构造函数包含过多参数时,使用builder

在实际的业务开发中,某些类可能包含丰富多样的属性。例如一个网站用户可能包含用户名、密码、昵称、头像、手机、证件、邮箱、ID、公司名等等信息,有些是必选参数,有些是可选参数。当前端送来一个用户注册请求时,则需要创建一个用户对象。这样,可能根据参数和用户类型,需要N个复杂的构造函数。下面列出几种解决方案:

2.1 Telescoping Constructor 模式

难读、难用。

public class Account {
  private final String name;
  private final String password;
  private final String phone;
  private final String email;

  public Account(String name, String password) {
    this(name, password, null);
  }

  public Account(String name, String password, String phone) {
    this(name, password, phone, null);
  }

  public Account(String name, String password, String phone, String email) {
    this.name = name;
    this.password = password;
    this.phone = phone;
    this.email = email;
  }
}

2.2 JavaBean 模式

把对象初始化拆分成了几条语句,代码层面上更加清晰,阅读顺畅。但是相应的缺点是这种操作是非原子性的,在并发编程中,需要专门为此做保护。

public class Account {
  private String name;
  private String password;
  private String phone;
  private String email;

  public Account() {}

  public String getName() { return name; }
  public String getPassword() { return password; }
  public String getPhone() { return phone; }
  public String getEmail() { return email; }
  public void setName(String name) { this.name = name; }
  public void setPassword(String password) {  this.password = password; }
  public void setPhone(String phone) {  this.phone = phone; }
  public void setEmail(String email) {  this.email = email;
  }

2.3 Builder 模式

结合了前两种方式的优点,同时安全性和可继承性得以保证。但是这种方式也有缺陷。首先,创建真正的对象前需要创建Builder对象,增大了系统开销。在参数量少时,不宜过度使用该模式。

public class Account {
  private String name;
  private String password;
  private String phone;
  private String email;

  private Account(Builder builder) {
    this.name = builder.name;
    this.password = builder.password;
    this.phone = builder.phone;
    this.email = builder.email;
  }

  public static class Builder {
    private String name;
    private String password;
    private String phone = null;
    private String email = null;

    public Builder(String val1, String val2) {
      name = val1;
      password = val2;
    }

    public Builder phone(String val) {
      phone = val;
      return this;
    }

    public Builder email(String val) {
      email = val;
      return this;
    }

    public Account build() {
      return new Account(this);
    }
  }
}
//调用方式
Account account = new Account.Builder("Amy", "123456").phone("15199998888").email("[email protected]").build();

实践3 使用私有构造函数或枚举类型来强制单例

无状态的对象通常采用单例模式。通常,有两种方式来实现单例。这两种方式都是通过私有构造函数+公有的静态实例成员实现的。

3.1 单例实现A

没有公有构造函数确保了只有 INSTANCE 在初始化时调用私有构造函数一次,之后,不能再创建该对象的任何实例。该方式的一个缺点是可能遭受反射攻击 ,参考:AccessibleObject.setAccessible

// Singleton with public final field
public class Elvis { 
  public static final Elvis INSTANCE = new Elvis();
  private Elvis() { ... }
  public void leaveTheBuilding() { ... } 
}

3.2 单例实现B

使用了静态工厂方法,通过getInstance去获取实例。相对来说,该方式更加明晰。并且,如果以后需要改造为非单例,对于用户代码没有影响。

// Singleton with public final field
public class Elvis { 
  private static final Elvis INSTANCE = new Elvis();
  private Elvis() { ... }
  public static Elvis getInstance() { return INSTANCE; }
  public void leaveTheBuilding() { ... } 
}

3.3 Enum实现单例

虽然看起来不太自然,但常常是实现单例的最佳方式。

// Enum singleton - the preferred approach 
public enum Elvis { 
  INSTANCE;
  public void leaveTheBuilding() { ... } 
}

实践4 使用私有构造函数来限制实例化

有时,我们编写的对象只包含一组静态的方法和变量,这样的对象是无需实例化的。但是在Java中,编译器始终会采用默认构造函数的策略。为了避免这种情况有以下2种方法:

4.1 抽象类

引入abstract关键字,使得类类型为抽象类是无法实例化的。但是这种方式有个缺点是如果有其他类继承该抽象类,则继承类是可以实例化的。并且抽象类容易迷惑用户,用户会认为需要继承这个类,而不是直接使用。

4.2 添加私有构造函数

编译器只在没有显式构造函数时为类添加默认构造函数。只要我们在类中显式添加一个私有构造函数,则该类就没法实例化了。示例如下,AssertionError并不是必须的,它只是确保没有在类内部误调用。
该方式也有缺点:无法继承。由于子类的构造函数总会(隐式或显式地)调用父类的构造函数,当父类构造函数为private时,将无法完成该动作。

// Noninstantiable utility class
public class UtilityClass { 
  // Suppress default constructor for noninstantiability 
  private UtilityClass() { throw new AssertionError(); } 
  ... // Remainder omitted 
}

实践5 使用依赖注入代替硬编码资源

类与类之间通常都存在依赖关系。下面是两种如何添加这种依赖关系的反面示例。这两种方式下,对于多线程,多实例及参数化资源都没法很好支持。

// 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 suggestions(String typo) { ... } 
}

// 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 suggestions(String typo) { ... }
 }

一种较好的解决方案是,在类的构造函数中传入相关资源。这就是依赖注入:在对象创建时注入。该方式在静态工厂方法,Builder模式同样适用。当工程过大时,某个类可能依赖成百上千资源,这时就需要注入框架来帮忙了,例如Dagger, Guice, Spring等。

// 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 suggestions(String typo) { ... } 
}

实践6 不要创建不必要的实例

对于不可修改的对象,采用共享模式而不是每次新建。有助于提升程序的性能。

6.1 示例1

String对象的新建,此处字符串值是固定不变的。

// 每次调用都将创建一个新的对象,浪费
String s = new String("bikini");
// 享元模式
String s = "bikini";

6.2 示例2

一个正则匹配模式的例子。

static boolean isRomanNumeral(String s) { 
  return s.matches("^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"); 
}

此处正则表达式^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$是固定不变的,但上述实现中,每次都会用它新建一个Pattern对象,因此可以把 Pattern 抽取出来。原书作者实测性能提升6倍多。

On my machine, the original versiontakes 1.1 µs on an 8-character input string, while the improved version takes 0.17 µs, which is 6.5 times faster.

public class RomanNumerals {
  private static final Pattern ROMAN = Pattern.compile( "^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
  static boolean isRomanNumeral(String s) { 
    return ROMAN.matcher(s).matches(); 
  }
}

此处有个可能的争议是,如果isRomanNumeral方法从未被调用到,那么ROMAN的初始化是浪费的。原书作者认为:虽然可以通过懒加载的方式来进一步避免该问题,但是增加了代码的复杂性,且性能实际提升价值不大。

6.3 基础类型的使用

对于基础类型,应尽量使用 int, long 而不是 Integer, Long。后者可能触发不必要的大量对象创建。

private static long sum() {
  // 创建大量对象
  Long sum = 0L; 
  // 创建1个对象
  long sum = 0L;
  for (long i = 0; i <= Integer.MAX_VALUE; i++) sum += i;
  return sum; 
}

实践7 解决过时引用问题

Java的自动垃圾回收机制,使得程序员可能产生幻觉:不需要进行内存管理。然而事实并非如此。如下就是一个内存管理不当的示例,在这个场景下,随着栈的增长,elements 可能扩张到很大,但是元素pop()size减小,但是elements 并未联动减小,那些没有被垃圾回收掉的比size标号大的对象成为了过时引用(obsolete reference,意思是再也不会用到的引用)。

// Can you spot the "memory leak"?
public class Stack {
  private static final int DEFAULT_INITIAL_CAPACITY = 16;
  private Object[] elements;
  private int size = 0;

  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);
  }
}

7.1 解决方案

相对来说,具有垃圾回收机制的编程语言的内存管理问题潜伏的更深,不易察觉,最终影响程序的性能。修复示例程序中这种类型的问题很简单:将引用设置为null。这样不仅使得垃圾能够尽快回收掉,并且使得随后的引用变得更安全,错误引用将触发NullPointerException

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

需要注意的是,不要过度使用这个方法,它会使编程变得复杂繁琐。通常只有在程序员自行管理内存的时候,才需要这个手段。对于其他情况,在Java中,对象的生命周期通常是在一定范围内,例如 {}中,跳出范围,则自动销毁。因此,将对象定义在最小化使用范围中是一种较好的编程习惯。

7.2 高发场景:

  • 缓存,原书作者建议使用WeakHashMap来解决。
  • 监听与回调,如果向API注册了回调函数而忘记去注册会有问题。同样,原书作者建议使用WeakHashMap来解决。

实践8 避免使用finalizercleaner

(实际开发中未使用,指导意义不大,暂未阅读)

实践9 try-with-resources优于try-finally

对于资源,在Java程序中使用完之后需要进行关闭动作。例如文件流、socket连接等等。如果我们忽视了,则可能给程序带来不良后果,尽管这些资源有finalizer来收尾。

9.1 try-finally方式

一种常见的方式是try-finally来确保资源能够在正常/异常情况下也正确关闭。但是当一段try-finally要使用多个资源时,嵌套后的代码看起来会非常复杂。另外,该方式还有一个问题时,如果IO设备故障,那么 read 以及 close操作都会抛出异常,但是因为close在后,所以之前的异常被冲掉,调试时只看到最后一个异常,增大调试难度。

// try-finally is ugly when used with more than one resource!
static 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();
  }
}

9.1 try-with-resources方式

Java7引入的try-with-resources,规定了资源对象必须实现AutoCloseable接口,这个接口中仅包含了一个方法:void close()

public class MyFile implements AutoCloseable{
  @Override
  public void close() throws Exception {...}
}

try-with-resources的调用语法如下。在这种语法下,当 read 以及 close操作都抛出异常时,close的异常被抑制掉,以确保程序员看到想要的那个异常。并且,整个代码也更加简洁。

static String firstLineOfFile(String path) throws IOException {
  try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
  }
}

static void copy(String src, String dst) throws IOException {
  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);
  }
}

(完)

你可能感兴趣的:(Effective Java英文第三版读书笔记(1) -- 科学地创建和销毁对象)