寫在前面
《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 <E extends Enum<E>> EnumSet<E> noneOf(Class<E> 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<Rank> 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<Complaint> 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("a@163.com").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<String> 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<String> 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<String> 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);
}
}
(完)