写在前面
《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 优点
- 构造函数都以类名命名,区分度不高,而静态工厂方法可以个性化,对用户更加友好。
- 静态工厂方法不是必须重新创建一个对象,例如上面
Boolean
的代码中,返回的是早前已经创建好的对象。这类似于设计模式中的享元模式(Flyweight pattern),典型的,相同内容的String
以及Enum
就用了该模式。 - 静态工厂方法可以返回子类。Java8中,取消了接口不能包含static方法的限制,因此在接口上实现这种静态工厂方法类,简化了文档,用户也只需关注主类。
- 静态工厂方法可以根据参数而返回不同的内容。如下示例,根据参数返回不同的类,屏蔽了一些内部细节。用户只需要知道返回的是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);
}
- 静态工厂方法返回的对象类型,甚至可以在当前位置不存在。 这在SPI中用处较多。例如JDBC服务里面,java.sql.Driver接口是对外公开的一个加载驱动接口,但Jdk中并没有相关实现,实际是由各sql厂商拿到接口后做的实现。
1.2 不足
- 没有
public
或者protected
构造函数的类是无法被继承的。 - 在接口中,静态工厂方法不如构造函数显眼,使用者难以发现。
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 避免使用finalizer
和cleaner
(实际开发中未使用,指导意义不大,暂未阅读)
实践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);
}
}
(完)