寫著寫著發現簡書提醒我文章接近字數極限,建議我換一篇寫了。
建議52:推薦使用String直接量賦值
一般對象都是通過new關鍵字生成的,但是String還有第二種生成方式,也就是我們經常使用的直接聲明方式,這種方式是極力推薦的,但不建議使用new String("A")的方式賦值。為什么呢?我們看如下代碼:
public class Client58 {
public static void main(String[] args) {
String str1 = "詹姆斯";
String str2 = "詹姆斯";
String str3 = new String("詹姆斯");
String str4 = str3.intern();
// 兩個直接量是否相等
System.out.println(str1 == str2);
// 直接量和對象是否相等
System.out.println(str1 == str3);
// 經過intern處理后的對象與直接量是否相等
System.out.println(str1 == str4);
}
}
注意看上面的程序,我們使用"=="判斷的是兩個對象的引用地址是否相同,也就是判斷是否為同一個對象,打印的結果是true,false,true。即有兩個直接量是同一個對象(進過intern處理后的String與直接量是同一個對象),但直接通過new生成的對象卻與之不等,原因何在?
原因是Java為了避免在一個系統中大量產生String對象(為什么會大量產生,因為String字符串是程序中最經常使用的類型),于是就設計了一個字符串池(也叫作字符串常量池,String pool或String Constant Pool或String Literal Pool),在字符串池中容納的都是String字符串對象,它的創建機制是這樣的:創建一個字符串時,首先檢查池中是否有字面值相等的字符串,如果有,則不再創建,直接返回池中該對象的引用,若沒有則創建之,然后放到池中,并返回新建對象的引用,這個池和我們平常說的池非常接近。對于此例子來說,就是創建第一個"詹姆斯"字符串時,先檢查字符串池中有沒有該對象,發現沒有,于是就創建了"詹姆斯"這個字符串并放到池中,待創建str2字符串時,由于池中已經有了該字符串,于是就直接返回了該對象的引用,此時,str1和str2指向的是同一個地址,所以使用"=="來判斷那當然是相等的了。
那為什么使用new String("詹姆斯")就不相等了呢?因為直接聲明一個String對象是不檢查字符串池的,也不會把對象放到字符串池中,那當然"=="為false了。
那為什么intern方法處理后即又相等了呢?因為intern會檢查當前對象在對象池中是否存在字面值相同的引用對象,如果有則返回池中的對象,如果沒有則放置到對象池中,并返回當前對象。
可能有人要問了,放到池中,是不是要考慮垃圾回收問題呀?不用考慮了,雖然Java的每個對象都保存在堆內存中但是字符串非常特殊,它在編譯期已經決定了其存在JVM的常量池(Constant Pool),垃圾回收不會對它進行回收的。
通過上面的介紹,我們發現Java在字符串的創建方面確實提供了非常好的機制,利用對象池不僅可以提高效率,同時減少了內存空間的占用,建議大家在開發中使用直接量賦值方式,除非必要才建立一個String對象。
建議54:正確使用String、StringBuffer、StringBuilder
CharSequence接口有三個實現類與字符串有關,String、StringBuffer、StringBuilder,雖然它們都與字符串有關,但其處理機制是不同的。
String類是不可變的量,也就是創建后就不能再修改了,比如創建了一個"abc"這樣的字符串對象,那么它在內存中永遠都會是"abc"這樣具有固定表面值的一個對象,不能被修改,即使想通過String提供的方法來嘗試修改,也是要么創建一個新的字符串對象,要么返回自己,比如:
String str = "abc";
String str1 = str.substring(1);
其中str是一個字符串對象,其值是"abc",通過substring方法又重新生成了一個字符串str1,它的值是"bc",也就是說str引用的對象一但產生就永遠不會變。為什么上面還說有可能不創建對象而返回自己呢?那是因為采用substring(0)就不會創建對象。JVM從字符串池中返回str的引用,也就是自身的引用。
StringBuffer是一個可變字符串,它與String一樣,在內存中保存的都是一個有序的字符序列(char 類型的數組),不同點是StringBuffer對象的值是可改變的,例如:
StringBuffer sb = new StringBuffer("a");
sb.append("b");
從上面的代碼可以看出sb的值在改變,初始化的時候是"a" ,經過append方法后,其值變成了"ab"。可能有人會問了,這與String類通過 "+" 連接有什么區別呢?例如:
String s = "a";
s = s + "b";
有區別,字符串變量s初始化時是 "a" 對象的引用,經過加號計算后,s變量就修改為了 “ab” 的引用,但是初始化的 “a” 對象還沒有改變,只是變量s指向了新的引用地址,再看看StringBuffer的對象,它的引用地址雖不變,但值在改變。
StringBuffer和StringBuilder基本相同,都是可變字符序列,不同點是:StringBuffer是線程安全的,StringBuilder是線程不安全的,翻翻兩者的源代碼,就會發現在StringBuffer的方法前都有關鍵字syschronized,這也是StringBuffer在性能上遠遠低于StringBuffer的原因。
在性能方面,由于String類的操作都是產生String的對象,而StringBuilder和StringBuffer只是一個字符數組的再擴容而已,所以String類的操作要遠慢于StringBuffer 和 StringBuilder。
弄清楚了三者之間的原理,我們就可以在不同的場景下使用不同的字符序列了:
- 使用String類的場景:在字符串不經常變化的場景中可以使用String類,例如常量的聲明、少量的變量運算等;
- 使用StringBuffer的場景:在頻繁進行字符串的運算(如拼接、替換、刪除等),并且運行在多線程的環境中,則可以考慮使用StringBuffer,例如XML解析、HTTP參數解析和封裝等;
-
使用StringBuilder的場景:在頻繁進行字符串的運算(如拼接、替換、刪除等),并且運行在單線程的環境中,則可以考慮使用StringBuilder,如SQL語句的拼接,JSON封裝等。
**注意:在適當的場景選用字符串類型 **
事實上這個問題被多個地方研究了很多次,我自己也寫了一篇專門的文章來介紹String類:
http://www.lxweimin.com/p/e494552f2cf0
建議55:注意字符串的位置
看下面一段程序:
public class Client55 {
public static void main(String[] args) {
String str1 = 1 + 2 + "apples";
String str2 = "apples" + 1 + 2;
System.out.println(str1);
System.out.println(str2);
}
}
想想兩個字符串輸出的結果的蘋果數量是否一致,如果一致,會是幾呢?
答案是不一致,str1的值是"3apples" ,str2的值是“apples12”,這中間懸殊很大,只是把“apples” 調換了一下位置,為何會發生如此大的變化呢?
這都源于java對于加號的處理機制:在使用加號進行計算的表達式中,只要遇到String字符串,則所有的數據都會轉換為String類型進行拼接,如果是原始數據,則直接拼接,如是是對象,則調用toString方法的返回值然后拼接,如:
str = str + new ArrayList();
上面就是調用ArrayList對象的toString方法返回值進行拼接的。再回到前面的問題上,對與str1 字符串,Java的執行順序是從左到右,先執行1+2,也就是算術加法運算,結果等于3,然后再與字符串進行拼接,結果就是 "3 apples",其它形式類似于如下計算:
String str1 = (1 + 2 ) + "apples" ;
而對于str2字符串,由于第一個參與運算的是String類型,加1后的結果是“apples 1” ,這仍然是一個字符串,然后再與2相加,結果還是一個字符串,也就是“apples12”。這說明如果第一個參數是String,則后續的所有計算都會轉變為String類型,誰讓字符串是老大呢!
注意: 在“+” 表達式中,String字符串具有最高優先級。
建議57:推薦在復雜字符串操作中使用正則表達式
這是一個很自然的選擇,因為正則表達式實在是太強大了。
建議58:強烈建議使用UTF編碼
Java的亂碼問題由來已久,有經驗的開發人員肯定遇到過亂碼,有時從Web接收的亂碼,有時從數據庫中讀取的亂碼,有時是在外部接口中接收的亂碼文件,這些都讓我們困惑不已,甚至是痛苦不堪,看如下代碼:
public class Client58 {
public static void main(String[] args) throws UnsupportedEncodingException {
String str = "漢字";
// 讀取字節
byte b[] = str.getBytes("UTF-8");
// 重新生成一個新的字符串
System.out.println(new String(b));
}
}
Java文件是通過IDE工具默認創建的,編碼格式是GBK,大家想想看上面的輸出結果會是什么?可能是亂碼吧?兩個編碼格式不同。我們暫時不說結果,先解釋一下Java中的編碼規則。Java程序涉及的編碼包括兩部分:
(1)、Java文件編碼:如果我們使用記事本創建一個.java后綴的文件,則文件的編碼格式就是操作系統默認的格式。如果是使用IDE工具創建的,如Eclipse,則依賴于IDE的設置,Eclipse默認是操作系統編碼(Windows一般為GBK);
(2)、Class文件編碼:通過javac命令生成的后綴名為.class的文件是UTF-8編碼的UNICODE文件,這在任何操作系統上都是一樣的,只要是.class文件就會使UNICODE格式。需要說明的是,UTF是UNICODE的存儲和傳輸格式,它是為了解決UNICODE的高位占用冗余空間而產生的,使用UTF編碼就意味著字符集使用的是UNICODE.
再回到我們的例子上,getBytes方法會根據指定的字符集取出字節數組(這里按照UNICODE格式來提取),然后程序又通過new String(byte [] bytes)重新生成一個字符串,來看看String的這個構造函數:通過操作系統默認的字符集解碼指定的byte數組,構造一個新的String,結果已經很清楚了,如果操作系統是UTF-8的話,輸出就是正確的,如果不是,則會是亂碼。由于這里使用的是默認編碼GBK,那么輸出的結果也就是亂碼了。我們再詳細分解一下運行步驟:
步驟1:創建Client58.java文件:該文件的默認編碼格式GBK(如果是Eclipse,則可以在屬性中查看到)。
步驟2:編寫代碼(如上);
步驟3:保存,使用javac編譯,注意我們沒有使用"javac -encoding GBK Client58.java" 顯示聲明Java的編碼方式,javac會自動按照操作系統的編碼(GBK)讀取Client58.java文件,然后將其編譯成.class文件。
步驟4:生成.class文件。編譯結束,生成.class文件,并保存到硬盤上,此時 .class文件使用的UTF-8格式編碼的UNICODE字符集,可以通過javap 命令閱讀class文件,其中" 漢字"變量也已經由GBK轉變成UNICODE格式了。
步驟5:運行main方法,提取"漢字"的字節數組。"漢字" 原本是按照UTF-8格式保存的,要再提取出來當然沒有任何問題了。
步驟6:重組字符串,讀取操作系統默認的編碼GBK,然后重新編碼變量b的所有字節。問題就在這里產生了:因為UNICODE的存儲格式是兩個字節表示一個字符(注意:這里是指UCS-2標準),雖然GBK也是兩個字節表示一個字符,但兩者之間沒有映射關系,只要做轉換只能讀取映射表,不能實現自動轉換----于是JVM就按照默認的編碼方式(GBK)讀取了UNICODE的兩個字節。
步驟7:輸出亂碼,程序運行結束,問題清楚了,解決方案也隨之產生,方案有兩個。
步驟8:修改代碼,明確指定編碼即可,代碼如下:
System.out.println(new String(b,"UTF-8"));
步驟9:修改操作系統的編碼方式,各個操作系統的修改方式不同,不再贅述。
我們可以把字符串讀取字節的過程看做是數據傳輸的需要(比如網絡、存儲),而重組字符串則是業務邏輯的需求,這樣就可以是亂碼重現:通過JDBC讀取的字節數組是GBK的,而業務邏輯編碼時采用的是UTF-8,于是亂碼就產生了。對于此類問題,最好的解決辦法就是使用統一的編碼格式,要么都用GBK,要么都用UTF-8,各個組件、接口、邏輯層、都用UTF-8,拒絕獨樹一幟的情況。
問題清楚了,我們看看以下代碼:
public class Client58 {
public static void main(String[] args) throws UnsupportedEncodingException {
String str = "漢字";
// 讀取字節
byte b[] = str.getBytes("GB2312");
// 重新生成一個新的字符串
System.out.println(new String(b));
}
}
僅僅修改了讀取字節的編碼方式(修改成了GB2312),結果會怎樣呢?又或者將其修改成GB18030,結果又是怎樣的呢?結果都是"漢字",不是亂碼。這是因為GB2312是中文字符集的V1.0版本,GBK是V2.0版本,GB18030是V3.0版本,版本是向下兼容的,只是它們包含的漢字數量不同而已,注意UNICODE可不在這個序列之內。
注意:一個系統使用統一的編碼。
建議60:性能考慮,數組是首選
數組在實際的系統開發中用的越來越少了,我們通常只有在閱讀一些開源項目時才會看到它們的身影,在Java中它確實沒有List、Set、Map這些集合類用起來方便,但是在基本類型處理方面,數組還是占優勢的,而且集合類的底層也都是通過數組實現的,比如對一數據集求和這樣的計算:
//對數組求和
public static int sum(int datas[]) {
int sum = 0;
for (int i = 0; i < datas.length; i++) {
sum += datas[i];
}
return sum;
}
對一個int類型 的數組求和,取出所有數組元素并相加,此算法中如果是基本類型則使用數組效率是最高的,使用集合則效率次之。再看使用List求和:
// 對列表求和計算
public static int sum(List<Integer> datas) {
int sum = 0;
for (int i = 0; i < datas.size(); i++) {
sum += datas.get(i);
}
return sum;
}
注意看sum += datas.get(i);這行代碼,這里其實已經做了一個拆箱動作,Integer對象通過intValue方法自動轉換成了一個int基本類型,對于性能瀕于臨界的系統來說該方案是比較危險的,特別是大數量的時候,首先,在初始化List數組時要進行裝箱操作,把一個int類型包裝成一個Integer對象,雖然有整型池在,但不在整型池范圍內的都會產生一個新的Integer對象,而且眾所周知,基本類型是在棧內存中操作的,而對象是堆內存中操作的,棧內存的特點是:速度快,容量小;堆內存的特點是:速度慢,容量大(從性能上講,基本類型的處理占優勢)。其次,在進行求和運算時(或者其它遍歷計算)時要做拆箱動作,因此無謂的性能消耗也就產生了。在實際測試中發現:對基本類型進行求和運算時,數組的效率是集合的10倍。
注意:性能要求較高的場景中使用數組代替集合。
建議64:多種最值算法,適時選擇
對一批數據進行排序,然后找出其中的最大值或最小值,這是基本的數據結構知識。在Java中我們可以通過編寫算法的方式,也可以通過數組先排序再取值的方式來實現,下面以求最大值為例,解釋一下多種算法:
(1)、自行實現,快速查找最大值
先看看用快速查找法取最大值的算法,代碼如下:
public static int max(int[] data) {
int max = data[0];
for (int i : data) {
max = max > i ? max : i;
}
return max;
}
這是我們經常使用的最大值算法,也是速度最快的算法。它不要求排序,只要遍歷一遍數組即可找出最大值。
(2)、先排序,后取值
對于求最大值,也可以采用先排序后取值的方式,代碼如下:
public static int max(int[] data) {
Arrays.sort(data);
return data[data.length - 1];
}
從效率上講,當然是自己寫快速查找法更快一些了,只用遍歷一遍就可以計算出最大值,但在實際測試中發現,如果數組量少于10000,兩個基本上沒有區別,但在同一個毫秒級別里,此時就可以不用自己寫算法了,直接使用數組先排序后取值的方式。
如果數組元素超過10000,就需要依據實際情況來考慮:自己實現,可以提高性能;先排序后取值,簡單,通俗易懂。排除性能上的差異,兩者都可以選擇,甚至后者更方便一些,也更容易想到。
現在問題來了,在代碼中為什么先使用data.clone拷貝再排序呢?那是因為數組也是一個對象,不拷貝就改變了原有的數組元素的順序嗎?除非數組元素的順序無關緊要。那如果要查找僅次于最大值的元素(也就是老二),該如何處理呢?要注意,數組的元素時可以重復的,最大值可能是多個,所以單單一個排序然后取倒數第二個元素時解決不了問題的。
此時,就需要一個特殊的排序算法了,先要剔除重復數據,然后再排序,當然,自己寫算法也可以實現,但是集合類已經提供了非常好的方法,要是再使用自己寫算法就顯得有點重復造輪子了。數組不能剔除重復數據,但Set集合卻是可以的,而且Set的子類TreeSet還能自動排序,代碼如下:
public static int getSecond(Integer[] data) {
//轉換為列表
List<Integer> dataList = Arrays.asList(data);
//轉換為TreeSet,剔除重復元素并升序排列
TreeSet<Integer> ts = new TreeSet<Integer>(dataList);
//取得比最大值小的最大值,也就是老二了
return ts.lower(ts.last());
}
剔除重復元素并升序排列,這都是由TreeSet類實現的,然后可再使用lower方法尋找小于最大值的值,大家看,上面的程序非常簡單吧?那如果是我們自己編寫代碼會怎么樣呢?那至少要遍歷數組兩遍才能計算出老二的值,代碼復雜度將大大提升。因此在實際應用中求最值,包括最大值、最小值、倒數第二小值等,使用集合是最簡單的方式,當然從性能方面來考慮,數組才是最好的選擇。
注意:最值計算時使用集合最簡單,使用數組性能最優。
建議82:由點及面,集合大家族總結
Java中的集合類實在是太豐富了,有常用的ArrayList、HashMap,也有不常用的Stack、Queue,有線程安全的Vector、HashTable,也有線程不安全的LinkedList、TreeMap,有阻塞式的ArrayBlockingQueue,也有非阻塞式的PriorityQueue等,整個集合大家族非常龐大,可以劃分以下幾類:
(1)、List:實現List接口的集合主要有:ArrayList、LinkedList、Vector、Stack,其中ArrayList是一個動態數組,LinkedList是一個雙向鏈表,Vector是一個線程安全的動態數組,Stack是一個對象棧,遵循先進后出的原則。
(2)、Set:Set是不包含重復元素的集合,其主要實現類有:EnumSet、HashSet、TreeSet,其中EnumSet是枚舉類型專用Set,所有元素都是枚舉類型;HashSet是以哈希碼決定其元素位置的Set,其原理與HashMap相似,它提供快速的插入和查找方法;TreeSet是一個自動排序的Set,它實現了SortedSet接口。
(3)、Map:Map是一個大家族,他可以分為排序Map和非排序Map,排序Map主要是TreeMap類,他根據key值進行自動排序;非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子類,它的主要用途是從Property文件中加載數據,并提供方便的操作,EnumMap則是要求其Key必須是某一個枚舉類型。
Map中還有一個WeakHashMap類需要說明,它是一個采用弱鍵方式實現的Map類,它的特點是:WeakHashMap對象的存在并不會阻止垃圾回收器對鍵值對的回收,也就是說使用WeakHashMap裝載數據不用擔心內存溢出的問題,GC會自動刪除不用的鍵值對,這是好事。但也存在一個嚴重的問題:GC是靜悄悄的回收的(何時回收,God,Knows!)我們的程序無法知曉該動作,存在著重大的隱患。(4)、Queue:對列,它分為兩類,一類是阻塞式隊列,隊列滿了以后再插入元素會拋出異常,主要包括:ArrayBlockingQueue、PriorityQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一個以數組方式實現的有界阻塞隊列;另一類是非阻塞隊列,無邊界的,只要內存允許,都可以持續追加元素,我們經常使用的是PriorityQuene類。
還有一種隊列,是雙端隊列,支持在頭、尾兩端插入和移除元素,它的主要實現類是:ArrayDeque、LinkedBlockingDeque、LinkedList。(5)、數組:數組與集合的最大區別就是數組能夠容納基本類型,而集合就不行,更重要的一點就是所有的集合底層存儲的都是數組。
(6)、工具類:數組的工具類是java.util.Arrays和java.lang.reflect.Array,集合的工具類是java.util.Collections,有了這兩個工具類,操作數組和集合就會易如反掌,得心應手。
(7)、擴展類:集合類當然可以自行擴展了,想寫一個自己的List?沒問題,但最好的辦法還是"拿來主義",可以使用Apache的common-collections擴展包,也可以使用Google的google-collections擴展包,這些足以應對我們的開發需要。
建議83:推薦使用枚舉定義常量
常量聲明是每一個項目都不可或缺的,在Java1.5之前,我們只有兩種方式的聲明:類常量和接口常量,若在項目中使用的是Java1.5之前的版本,基本上都是如此定義的。不過,在1.5版本以后有了改進,即新增了一種常量聲明方式:枚舉聲明常量,看如下代碼:
enum Season {
Spring, Summer, Autumn, Winter;
}
這是一個簡單的枚舉常量命名,清晰又簡單。順便提一句,JLS(Java Language Specification,Java語言規范)提倡枚舉項全部大寫,字母之間用下劃線分割,這也是從常量的角度考慮的(當然,使用類似類名的命名方式也是比較友好的)。
那么枚舉常量與我們經常使用的類常量和靜態常量相比有什么優勢?問得好,枚舉的優點主要表現在四個方面:
1.枚舉常量簡單:簡不簡單,我們來對比一下兩者的定義和使用情況就知道了。先把Season枚舉翻寫成接口常量,代碼如下:
interface Season {
int SPRING = 0;
int SUMMER = 1;
int AUTUMN = 2;
int WINTER = 3;
}
此處定義了春夏秋冬四個季節,類型都是int,這與Season枚舉的排序值是相同的。首先對比一下兩者的定義,枚舉常量只需定義每個枚舉項,不需要定義枚舉值,而接口常量(或類常量)則必須定義值,否則編譯不通過,即使我們不需要關注其值是多少也必須定義;其次,雖然兩者被引用的方式相同(都是 “類名 . 屬性”,如Season.SPRING),但是枚舉表示的是一個枚舉項,字面含義是春天,而接口常量確是一個int類型,雖然其字面含義也是春天,但在運算中我們勢必要關注其int值。
2.枚舉常量屬于穩態型:例如我們要描述一下春夏秋冬是什么樣子,使用接口常量應該是這樣寫。
public void describe(int s) {
// s變量不能超越邊界,校驗條件
if (s >= 0 && s < 4) {
switch (s) {
case Season.SPRING:
System.out.println("this is spring");
break;
case Season.SUMMER:
System.out.println("this is summer");
break;
......
}
}
}
很簡單,先使用switch語句判斷哪一個是常量,然后輸出。但問題是我們得對輸入值進行檢查,確定是否越界,如果常量非常龐大,校驗輸入就成了一件非常麻煩的事情,但這是一個不可逃避的過程,特別是如果我們的校驗條件不嚴格,雖然編譯能照樣通過,但是運行期就會產生無法預知的后果。
我們再來看看枚舉常量是否能夠避免校驗的問題,代碼如下:
public void describe(Season s){
switch(s){
case Spring:
System.out.println("this is "+Season.Spring);
break;
case Summer:
System.out.println("this is summer"+Season.Summer);
break;
......
}
}
不用校驗,已經限定了是Season枚舉,所以只能是Season類的四個實例,即春夏秋冬4個枚舉項,想輸入一個int類型或其它類型?門都沒有!這是我們最看重枚舉的地方:在編譯期間限定類型,不允許發生越界的情況。
3.枚舉具有內置方法:有一個簡單的問題:如果要列出所有的季節常量,如何實現呢?接口常量或類常量可以通過反射來實現,這沒錯,只是雖然能實現,但會非常繁瑣,大家可以自己寫一個反射類實現此功能(當然,一個一個地動手打印出輸出常量,也可以算是列出)。對于此類問題可以非常簡單的解決,代碼如下:
public void query() {
for (Season s : Season.values()) {
System.out.println(s);
}
}
通過values方法獲得所有的枚舉項,然后打印出來即可。如此簡單,得益于枚舉內置的方法,每個枚舉都是java.lang.Enum的子類,該基類提供了諸如獲得排序值的ordinal方法、compareTo比較方法等,大大簡化了常量的訪問。
4.枚舉可以自定義的方法:這一點似乎并不是枚舉的優點,類常量也可以有自己的方法呀,但關鍵是枚舉常量不僅可以定義靜態方法,還可以定義非靜態方法,而且還能夠從根本上杜絕常量類被實例化。比如我們要在常量定義中獲得最舒服季節的方法,使用常量枚舉的代碼如下:
enum Season {
Spring, Summer, Autumn, Winter;
public static Season getComfortableSeason(){
return Spring;
}
}
我們知道,每個枚舉項都是該枚舉的一個實例,對于我們的例子來說,也就表示Spring其實是Season的一個實例,Summer也是其中一個實例,那我們在枚舉中定義的靜態方法既可以在類(也就是枚舉Season)中引用,也可以在實例(也就是枚舉項Spring、Summer、Autumn、Winter)中引用,看如下代碼:
public static void main(String[] args) {
System.out.println("The most comfortable season is "+Season.getComfortableSeason());
}
那如果使用類常量要如何實現呢?代碼如下:
class Season {
public final static int SPRING = 0;
public final static int SUMMER = 1;
public final static int AUTUMN = 2;
public final static int WINTER = 3;
public static int getComfortableSeason(){
return SPRING;
}
}
想想看,我們怎么才能打印出"The most comfortable season is Spring" 這句話呢?除了使用switch和if判斷之外沒有其它辦法了。
雖然枚舉在很多方面比接口常量和類常量好用,但是有一點它是比不上接口常量和類常量的,那就是繼承,枚舉類型是不能繼承的,也就是說一個枚舉常量定義完畢后,除非修改重構,否則無法做擴展,而接口常量和類常量則可以通過繼承進行擴展。但是,一般常量在項目構建時就定義完畢了,很少會出現必須通過擴展才能實現業務邏輯的場景。
注意: 在項目中推薦使用枚舉常量代替接口常量或類常量。
建議88:用枚舉實現工廠方法模式更簡潔
工廠方法模式(Factory Method Pattern)是" 創建對象的接口,讓子類決定實例化哪一個類,并使一個類的實例化延遲到其它子類"。工廠方法模式在我們的開發中經常會用到。下面以汽車制造為例,看看一般的工廠方法模式是如何實現的,代碼如下:
//抽象產品
interface Car{
}
//具體產品類
class FordCar implements Car{
}
//具體產品類
class BuickCar implements Car{
}
//工廠類
class CarFactory{
//生產汽車
public static Car createCar(Class<? extends Car> c){
try {
return c.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
}
這是最原始的工廠方法模式,有兩個產品:福特汽車和別克汽車,然后通過工廠方法模式來生產。有了工廠方法模式,我們就不用關心一輛車具體是怎么生成的了,只要告訴工廠" 給我生產一輛福特汽車 "就可以了,下面是產出一輛福特汽車時客戶端的代碼:
public static void main(String[] args) {
//生產車輛
Car car = CarFactory.createCar(FordCar.class);
}
這就是我們經常使用的工廠方法模式,但經常使用并不代表就是最優秀、最簡潔的。此處再介紹一種通過枚舉實現工廠方法模式的方案,誰優誰劣你自行評價。枚舉實現工廠方法模式有兩種方法:
(1)、枚舉非靜態方法實現工廠方法模式
我們知道每個枚舉項都是該枚舉的實例對象,那是不是定義一個方法可以生成每個枚舉項對應產品來實現此模式呢?代碼如下:
enum CarFactory {
// 定義生產類能生產汽車的類型
FordCar, BuickCar;
// 生產汽車
public Car create() {
switch (this) {
case FordCar:
return new FordCar();
case BuickCar:
return new BuickCar();
default:
throw new AssertionError("無效參數");
}
}
}
create是一個非靜態方法,也就是只有通過FordCar、BuickCar枚舉項才能訪問。采用這種方式實現工廠方法模式時,客戶端要生產一輛汽車就很簡單了,代碼如下:
public static void main(String[] args) {
// 生產車輛
Car car = CarFactory.BuickCar.create();
}
(2)、通過抽象方法生成產品
枚舉類型雖然不能繼承,但是可以用abstract修飾其方法,此時就表示該枚舉是一個抽象枚舉,需要每個枚舉項自行實現該方法,也就是說枚舉項的類型是該枚舉的一個子類,我們倆看代碼:
enum CarFactory {
// 定義生產類能生產汽車的類型
FordCar{
public Car create(){
return new FordCar();
}
},
BuickCar{
public Car create(){
return new BuickCar();
}
};
//抽象生產方法
public abstract Car create();
}
首先定義一個抽象制造方法create,然后每個枚舉項自行實現,這種方式編譯后會產生CarFactory的匿名子類,因為每個枚舉項都要實現create抽象方法。客戶端調用與上一個方案相同,不再贅述。
大家可能會問,為什么要使用枚舉類型的工廠方法模式呢?那是因為使用枚舉類型的工廠方法模式有以下三個優點:
- 避免錯誤調用的發生:一般工廠方法模式中的生產方法(也就是createCar方法),可以接收三種類型的參數:類型參數(如我們的例子)、String參數(生產方法中判斷String參數是需要生產什么產品)、int參數(根據int值判斷需要生產什么類型的的產品),這三種參數都是寬泛的數據類型,很容易發生錯誤(比如邊界問題、null值問題),而且出現這類錯誤編譯器還不會報警,例如:
public static void main(String[] args) {
// 生產車輛
Car car = CarFactory.createCar(Car.class);
}
Car是一個接口,完全合乎createCar的要求,所以它在編譯時不會報任何錯誤,但一運行就會報出InstantiationException異常,而使用枚舉類型的工廠方法模式就不存在該問題了,不需要傳遞任何參數,只需要選擇好生產什么類型的產品即可。
性能好,使用簡潔:枚舉類型的計算時以int類型的計算為基礎的,這是最基本的操作,性能當然會快,至于使用便捷,注意看客戶端的調用,代碼的字面意思就是" 汽車工廠,我要一輛別克汽車,趕快生產"。
降低類間耦合:不管生產方法接收的是Class、String還是int的參數,都會成為客戶端類的負擔,這些類并不是客戶端需要的,而是因為工廠方法的限制必須輸入的,例如Class參數,對客戶端main方法來說,他需要傳遞一個FordCar.class參數才能生產一輛福特汽車,除了在create方法中傳遞參數外,業務類不需要改Car的實現類。這嚴重違背了迪米特原則(Law of Demeter 簡稱LoD),也就是最少知識原則:一個對象應該對其它對象有最少的了解。
而枚舉類型的工廠方法就沒有這種問題了,它只需要依賴工廠類就可以生產一輛符合接口的汽車,完全可以無視具體汽車類的存在。
建議93:Java的泛型是可以擦除的
Java泛型(Generic) 的引入加強了參數類型的安全性,減少了類型的轉換,它與C++中的模板(Temeplates) 比較類似,但是有一點不同的是:Java的泛型在編譯器有效,在運行期被刪除,也就是說所有的泛型參數類型在編譯后會被清除掉,我們來看一個例子,代碼如下:
public class Foo {
//arrayMethod接收數組參數,并進行重載
public void arrayMethod(String[] intArray) {
}
public void arrayMethod(Integer[] intArray) {
}
//listMethod接收泛型List參數,并進行重載
public void listMethod(List<String> stringList) {
}
public void listMethod(List<Integer> intList) {
}
}
程序很簡單,編寫了4個方法,arrayMethod方法接收String數組和Integer數組,這是一個典型的重載,listMethod接收元素類型為String和Integer的list變量。現在的問題是,這段程序是否能編譯?如果不能?問題出在什么地方?
事實上,這段程序時無法編譯的,編譯時報錯信息如下:
這段錯誤的意思:簡單的的說就是方法簽名重復,其實就是說listMethod(List<Integer> intList)方法在編譯時擦除類型后是listMethod(List<E> intList)與另一個方法重復。這就是Java泛型擦除引起的問題:在編譯后所有的泛型類型都會做相應的轉化。轉換規則如下:
- List<String>、List<Integer>、List<T>擦除后的類型為List
- List<String>[] 擦除后的類型為List[].
- List<? extends E> 、List<? super E> 擦除后的類型為List<E>.
- List<T extends Serializable & Cloneable >擦除后的類型為List< Serializable>.
明白了這些規則,再看如下代碼:
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("abc");
String str = list.get(0);
}
進過編譯后的擦除處理,上面的代碼和下面的程序時一致的:
public static void main(String[] args) {
List list = new ArrayList();
list.add("abc");
String str = (String) list.get(0);
}
Java編譯后字節碼中已經沒有泛型的任何信息了,也就是說一個泛型類和一個普通類在經過編譯后都指向了同一字節碼,比如Foo<T>類,經過編譯后將只有一份Foo.class類,不管是Foo<String>還是Foo<Integer>引用的都是同一字節碼。Java之所以如此處理,有兩個原因:
- 避免JVM的大換血。C++泛型生命期延續到了運行期,而Java是在編譯期擦除掉的,我們想想,如果JVM也把泛型類型延續到運行期,那么JVM就需要進行大量的重構工作了。
- 版本兼容:在編譯期擦除可以更好的支持原生類型(Raw Type),在Java1.5或1.6...平臺上,即使聲明一個List這樣的原生類型也是可以正常編譯通過的,只是會產生警告信息而已。
明白了Java泛型是類型擦除的,我們就可以解釋類似如下的問題了:
1.泛型的class對象是相同的:每個類都有一個class屬性,泛型化不會改變class屬性的返回值,例如:
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
List<Integer> list2 = new ArrayList<Integer>();
System.out.println(list.getClass()==list2.getClass());
}
以上代碼返回true,原因很簡單,List<String>和List<Integer>擦除后的類型都是List,沒有任何區別。
2.泛型數組初始化時不能聲明泛型,如下代碼編譯時通不過:
List<String>[] listArray = new List<String>[];
原因很簡單,可以聲明一個帶有泛型參數的數組,但不能初始化該數組,因為執行了類型擦除操作,List<Object>[]與List<String>[] 就是同一回事了,編譯器拒絕如此聲明。
3.instanceof不允許存在泛型參數:以下代碼不能通過編譯,原因一樣,泛型類型被擦除了:
List<String> list = new ArrayList<String>();
System.out.println(list instanceof List<String>);
建議98:建議的采用順序是List中泛型順序依次為T、?、Object
List<T>、List<?>、List<Object>這三者都可以容納所有的對象,但使用的順序應該是首選List<T>,次之List<?>,最后選擇List<Object>,原因如下:
(1)、List<T>是確定的某一個類型
List<T>表示的是List集合中的元素都為T類型,具體類型在運行期決定;List<?>表示的是任意類型,與List<T>類似,而List<Object>則表示List集合中的所有元素為Object類型,因為Object是所有類的父類,所以List<Object>也可以容納所有的類類型,從這一字面意義上分析,List<T>更符合習慣:編碼者知道它是某一個類型,只是在運行期才確定而已。
(2)List<T>可以進行讀寫操作
List<T>可以進行諸如add,remove等操作,因為它的類型是固定的T類型,在編碼期不需要進行任何的轉型操作。
List<T>是只讀類型的,不能進行增加、修改操作,因為編譯器不知道List中容納的是什么類型的元素,也就無法校驗類型是否安全了,而且List<?>讀取出的元素都是Object類型的,需要主動轉型,所以它經常用于泛型方法的返回值。注意List<?>雖然無法增加,修改元素,但是卻可以刪除元素,比如執行remove、clear等方法,那是因為它的刪除動作與泛型類型無關。
List<Object> 也可以讀寫操作,但是它執行寫入操作時需要向上轉型(Up cast),在讀取數據的時候需要向下轉型,而此時已經失去了泛型存在的意義了。
打個比方,有一個籃子用來容納物品,比如西瓜,番茄等.List<?>的意思是說,“嘿,我這里有一個籃子,可以容納固定類別的東西,比如西瓜,番茄等”。List<?>的意思是說:“嘿,我有一個籃子,我可以容納任何東西,只要是你想得到的”。而List<Object>就更有意思了,它說" 嘿,我也有一個籃子,我可以容納所有物質,只要你認為是物質的東西都可以容納進來 "。
推而廣之,Dao<T>應該比Dao<?>、Dao<Object>更先采用,Desc<Person>則比Desc<?>、Desc<Object>更優先采用。
建議101:注意Class類的特殊性
Java語言是先把Java源文件編譯成后綴為class的字節碼文件,然后再通過ClassLoader機制把這些類文件加載到內存中,最后生成實例執行的,這是Java處理的基本機制,但是加載到內存中的數據的如何描述一個類的呢?比如在Dog.class文件中定義一個Dog類,那它在內存中是如何展現的呢?
Java使用一個元類(MetaClass)來描述加載到內存中的類數據,這就是Class類,它是一個描述類的類對象,比如Dog.class文件加載到內存中后就會有一個class的實例對象描述之。因為是Class類是“類中類”,也就有預示著它有很多特殊的地方:
- 1.無構造函數:Java中的類一般都有構造函數,用于創建實例對象,但是Class類卻沒有構造函數,不能實例化,Class對象是在加載類時由Java虛擬機通過調用類加載器中的difineClass方法自動構造的。
- 2.可以描述基本類型:雖然8個基本類型在JVM中并不是一個對象,它們一般存在于棧內存中,但是Class類仍然可以描述它們,例如可以使用int.class表示int類型的類對象。
- 3.其對象都是單例模式:一個Class的實例對象描述一個類,并且只描述一個類,反過來也成立。一個類只有一個Class實例對象,如下代碼返回的結果都為true:
// 類的屬性class所引用的對象與實例對象的getClass返回值相同
boolean b1=String.class.equals(new String().getClass());
boolean b2="ABC".getClass().equals(String.class);
// class實例對象不區分泛型
boolean b3=ArrayList.class.equals(new ArrayList<String>().getClass());
Class類是Java的反射入口,只有在獲得了一個類的描述對象后才能動態的加載、調用,一般獲得一個Class對象有三種途徑:
- 類屬性方式:如String.class
- 對象的getClass方法,如new String().getClass()
- forName方法加載:如Class.forName(" java.lang.String")
獲得了Class對象后,就可以通過getAnnotations()獲得注解,通過getMethods()獲得方法,通過getConstructors()獲得構造函數等,這位后續的反射代碼鋪平了道路。
建議106:動態代理可以使代理模式更加靈活
Java的反射框架提供了動態代理(Dynamic Proxy)機制,允許在運行期對目標類生成代理,避免重復開發。我們知道一個靜態代理是通過主題角色(Proxy)和具體主題角色(Real Subject)共同實現主題角色(Subject)的邏輯的,只是代理角色把相關的執行邏輯委托給了具體角色而已,一個簡單的靜態代理如下所示:
interface Subject {
// 定義一個方法
public void request();
}
// 具體主題角色
class RealSubject implements Subject {
// 實現方法
@Override
public void request() {
// 實現具體業務邏輯
}
}
class Proxy implements Subject {
// 要代理那個實現類
private Subject subject = null;
// 默認被代理者
public Proxy() {
subject = new RealSubject();
}
// 通過構造函數傳遞被代理者
public Proxy(Subject _subject) {
subject = _subject;
}
@Override
public void request() {
before();
subject.request();
after();
}
// 預處理
private void after() {
// doSomething
}
// 善后處理
private void before() {
// doSomething
}
}
這是一個簡單的靜態代理。Java還提供了java.lang.reflect.Proxy用于實現動態代理:只要提供一個抽象主題角色和具體主題角色,就可以動態實現其邏輯的,其實例代碼如下:
interface Subject {
// 定義一個方法
public void request();
}
// 具體主題角色
class RealSubject implements Subject {
// 實現方法
@Override
public void request() {
// 實現具體業務邏輯
}
}
class SubjectHandler implements InvocationHandler {
// 被代理的對象
private Subject subject;
public SubjectHandler(Subject _subject) {
subject = _subject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
// 預處理
System.out.println("預處理...");
//直接調用被代理的方法
Object obj = method.invoke(subject, args);
// 后處理
System.out.println("后處理...");
return obj;
}
}
注意這里沒有代理主題角色,取而代之的是SubjectHandler 作為主要的邏輯委托處理,其中invoke方法是接口InvocationHandler定義必須實現的,它完成了對真實方法的調用。
我們來詳細解釋一下InvocationHandler接口,動態代理是根據被代理的接口生成的所有方法的,也就是說給定一個或多個接口,動態代理會宣稱“我已經實現該接口下的所有方法了”,那大家想想看,動態代理是怎么才能實現接口中的方法呢?在默認情況下所有方法的返回值都是空的,是的,雖然代理已經實現了它,但是沒有任何的邏輯含義,那怎么辦?好辦,通過InvocationHandler接口的實現類來實現,所有的方法都是由該Handler進行處理的,即所有被代理的方法都由InvocationHandler接管實際的處理任務。
我們開看看動態代理的場景,代碼如下:
public static void main(String[] args) {
//具體主題角色,也就是被代理類
Subject subject = new RealSubject();
//代理實例的處理Handler
InvocationHandler handler =new SubjectHandler(subject);
//當前加載器
ClassLoader cl = subject.getClass().getClassLoader();
//動態代理
Subject proxy = (Subject) Proxy.newProxyInstance(cl,subject.getClass().getInterfaces(),handler);
//執行具體主題角色方法
proxy.request();
}
此時就實現了,不用顯式創建代理類即實現代理的功能,例如可以在被代理的角色執行前進行權限判斷,或者執行后進行數據校驗。
動態代理很容易實現通用的代理類,只要在InvocationHandler的invoke方法中讀取持久化的數據即可實現,而且還能實現動態切入的效果,這也是AOP(Aspect Oriented Programming)變成理念。
建議110:提倡異常封裝
Java語言的異常處理機制可以去確保程序的健壯性,提高系統的可用率,但是Java API提供的異常都是比較低級的(這里的低級是指 " 低級別的 " 異常),只有開發人員才能看的懂,才明白發生了什么問題。而對于終端用戶來說,這些異常基本上就是天書,與業務無關,是純計算機語言的描述,那該怎么辦?這就需要我們對異常進行封裝了。異常封裝有三方面的優點:
(1)、提高系統的友好性
例如,打開一個文件,如果文件不存在,則回報FileNotFoundException異常,如果該方法的編寫者不做任何處理,直接拋到上層,則會降低系統的友好性,代碼如下所示:
public static void doStuff() throws FileNotFoundException {
InputStream is = new FileInputStream("無效文件.txt");
/* 文件操作 */
}
此時doStuff的友好性極差,出現異常時(如果文件不存在),該方法直接把FileNotFoundException異常拋到上層應用中(或者是最終用戶),而上層應用(或用戶要么自己處理),要么接著拋,最終的結果就是讓用戶面對著" 天書 " 式的文字發呆,用戶不知道這是什么問題,只是知道系統告訴他" 哦,我出錯了,什么錯誤?你自己看著辦吧 "。
解決辦法就是封裝異常,可以把異常的閱讀者分為兩類:開發人員和用戶。開發人員查找問題,需要打印出堆棧信息,而用戶則需要了解具體的業務原因,比如文件太大、不能同時編寫文件等,代碼如下:
public static void doStuff2() throws MyBussinessException{
try {
InputStream is = new FileInputStream("無效文件.txt");
} catch (FileNotFoundException e) {
//方便開發人員和維護人員而設置的異常信息
e.printStackTrace();
//拋出業務異常
throw new MyBussinessException();
}
/* 文件操作 */
}
(2)、提高系統的可維護性
看如下代碼:
public void doStuff3(){
try{
//doSomething
}catch(Exception e){
e.printStackTrace();
}
}
這是大家很容易犯的錯誤,拋出異常是吧?分類處理多麻煩,就寫一個catch塊來處理所有的異常吧,而且還信誓旦旦的說" JVM會打印出棧中的錯誤信息 ",雖然這沒錯,但是該信息只有開發人員自己看的懂,維護人員看到這段異常時基本上無法處理,因為需要到代碼邏輯中去分析問題。
正確的做法是對異常進行分類處理,并進行封裝輸出,代碼如下:
public void doStuff4(){
try{
//doSomething
}catch(FileNotFoundException e){
log.info("文件未找到,使用默認配置文件....");
e.printStackTrace();
}catch(SecurityException e1){
log.info(" 無權訪問,可能原因是......");
e1.printStackTrace();
}
}
如此包裝后,維護人員看到這樣的異常就有了初步的判斷,或者檢查配置,或者初始化環境,不需要直接到代碼層級去分析了。
(3)、解決Java異常機制自身的缺陷
Java中的異常一次只能拋出一個,比如doStuff方法有兩個邏輯代碼片段,如果在第一個邏輯片段中拋出異常,則第二個邏輯片段就不再執行了,也就無法拋出第二個異常了,現在的問題是:如何才能一次拋出兩個(或多個)異常呢?
其實,使用自行封裝的異常可以解決該問題,代碼如下:
class MyException extends Exception {
// 容納所有的異常
private List<Throwable> causes = new ArrayList<Throwable>();
// 構造函數,傳遞一個異常列表
public MyException(List<? extends Throwable> _causes) {
causes.addAll(_causes);
}
// 讀取所有的異常
public List<Throwable> getExceptions() {
return causes;
}
}
MyException異常只是一個異常容器,可以容納多個異常,但它本身并不代表任何異常含義,它所解決的是一次拋出多個異常的問題,具體調用如下:
public void doStuff() throws MyException {
List<Throwable> list = new ArrayList<Throwable>();
// 第一個邏輯片段
try {
// Do Something
} catch (Exception e) {
list.add(e);
}
// 第二個邏輯片段
try {
// Do Something
} catch (Exception e) {
list.add(e);
}
// 檢查是否有必要拋出異常
if (list.size() > 0) {
throw new MyException(list);
}
}
這樣一來,DoStuff方法的調用者就可以一次獲得多個異常了,也能夠為用戶提供完整的例外情況說明。可能有人會問:這種情況會出現嗎?怎么回要求一個方法拋出多個異常呢?
絕對有可能出現,例如Web界面注冊時,展現層依次把User對象傳遞到邏輯層,Register方法需要對各個Field進行校驗并注冊,例如用戶名不能重復,密碼必須符合密碼策略等,不要出現用戶第一次提交時系統顯示" 用戶名重復 ",在用戶修改用戶名再次提交后,系統又提示" 密碼長度小于6位 " 的情況,這種操作模式下的用戶體驗非常糟糕,最好的解決辦法就是異常封裝,建立異常容器,一次性地對User對象進行校驗,然后返回所有的異常。
建議114:不要在構造函數中拋出異常
Java異常的機制有三種:
- Error類及其子類表示的是錯誤,它是不需要程序員處理也不能處理的異常,比如VirtualMachineError虛擬機錯誤,ThreadDeath線程僵死等。
- RunTimeException類及其子類表示的是非受檢異常,是系統可能會拋出的異常,程序員可以去處理,也可以不處理,最經典的就是NullPointException空指針異常和IndexOutOfBoundsException越界異常。
- Exception類及其子類(不包含非受檢異常),表示的是受檢異常,這是程序員必須處理的異常,不處理則程序不能通過編譯,比如IOException表示的是I/O異常,SQLException表示的數據庫訪問異常。
我們知道,一個對象的創建過程經過內存分配,靜態代碼初始化、構造函數執行等過程,對象生成的關鍵步驟是構造函數,那是不是也允許在構造函數中拋出異常呢?從Java語法上來說,完全可以在構造函數中拋出異常,三類異常都可以,但是從系統設計和開發的角度來分析,則盡量不要在構造函數中拋出異常,我們以三種不同類型的異常來說明之。
(1)、構造函數中拋出錯誤是程序員無法處理的
在構造函數執行時,若發生了VirtualMachineError虛擬機錯誤,那就沒招了,只能拋出,程序員不能預知此類錯誤的發生,也就不能捕捉處理。
(2)、構造函數不應該拋出非受檢異常
我們來看這樣一個例子,代碼如下:
class Person {
public Person(int _age) {
// 不滿18歲的用戶對象不能建立
if (_age < 18) {
throw new RuntimeException("年齡必須大于18歲.");
}
}
public void doSomething() {
System.out.println("doSomething......");
}
}
這段代碼的意圖很明顯,年齡不滿18歲的用戶不會生成一個Person實例對象,沒有對象,類行為doSomething方法就不可執行,想法很好,但這會導致不可預測的結果,比如我們這樣引用Person類:
public static void main(String[] args) {
Person p = new Person(17);
p.doSomething();
/*其它的業務邏輯*/
}
很顯然,p對象不能建立,因為是一個RunTimeException異常,開發人員可以捕捉也可以不捕捉,代碼看上去邏輯很正確,沒有任何瑕疵,但是事實上,這段程序會拋出異常,無法執行。這段代碼給了我們兩個警示:
- 1.加重了上層代碼編寫者的負擔:捕捉這個RuntimeException異常吧,那誰來告訴我有這個異常呢?只有通過文檔約束了,一旦Person類的構造函數經過重構后再拋出其它非受檢異常,那main方法不用修改也是可以測試通過的,但是這里就可能會產生隱藏的缺陷,而寫還是很難重現的缺陷。不捕捉這個RuntimeException異常,這個是我們通常的想法,既然已經寫成了非受檢異常,main方法的編碼者完全可以不處理這個異常嘛,大不了不執行Person的方法!這是非常危險的,一旦產生異常,整個線程都不再繼續執行,或者鏈接沒有關閉,或者數據沒有寫入數據庫,或者產生內存異常,這些都是會對整個系統產生影響。
- 2.后續代碼不會執行:main方法的實現者原本是想把p對象的建立作為其代碼邏輯的一部分,執行完doSomething方法后還需要完成其它邏輯,但是因為沒有對非受檢異常進行捕捉,異常最終會拋出到JVM中,這會導致整個線程執行結束后,后面所有的代碼都不會繼續執行了,這就對業務邏輯產生了致命的影響。
(3)、構造函數盡可能不要拋出受檢異常
我們來看下面的例子,代碼如下:
//父類
class Base {
// 父類拋出IOException
public Base() throws IOException {
throw new IOException();
}
}
//子類
class Sub extends Base {
// 子類拋出Exception異常
public Sub() throws Exception {
}
}
就這么一段簡單的代碼,展示了在構造函數中拋出受檢異常的三個不利方面:
- 1.導致子類膨脹:在我們的例子中子類的無參構造函數不能省略,原因是父類的無參構造函數拋出了IOException異常,子類的無參構造函數默認調用的是父類的構造函數,所以子類無參構造函數也必須拋出IOException或其父類。
- 2.違背了里氏替換原則:"里氏替換原則" 是說父類能出現的地方子類就可以出現,而且將父類替換為子類也不會產生任何異常。那我們回頭看看Sub類是否可以替換Base類,比如我們的上層代碼是這樣寫的:
public static void main(String[] args) {
try {
Base base = new Base();
} catch (Exception e) {
e.printStackTrace();
}
}
然后,我們期望把new Base()替換成new Sub(),而且代碼能夠正常編譯和運行。非常可惜,編譯不通過,原因是Sub的構造函數拋出了Exception異常,它比父類的構造函數拋出更多的異常范圍要寬,必須增加新的catch塊才能解決。
可能大家要問了,為什么Java的構造函數允許子類的構造函數拋出更廣泛的異常類呢?這正好與類方法的異常機制相反,類方法的異常是這樣要求的:
// 父類
class Base {
// 父類方法拋出Exception
public void testMethod() throws Exception {
}
}
// 子類
class Sub extends Base {
// 父類方法拋出Exception
@Override
public void testMethod() throws IOException {
}
}
子類的方法可以拋出多個異常,但都必須是覆寫方法的子類型,對我們的例子來說,Sub類的testMethod方法拋出的異常必須是Exception的子類或Exception類,這是Java覆寫的要求。構造函數之所以于此相反,是因為構造函數沒有覆寫的概念,只是構造函數間的引用調用而已,所以在構造函數中拋出受檢異常會違背里氏替換原則原則,使我們的程序缺乏靈活性。
- 3.子類構造函數擴展受限:子類存在的原因就是期望實現擴展父類的邏輯,但父類構造函數拋出異常卻會讓子類構造函數的靈活性大大降低,例如我們期望這樣的構造函數。
// 父類
class Base {
public Base() throws IOException{
}
}
// 子類
class Sub extends Base {
public Sub() throws Exception{
try{
super();
}catch(IOException e){
//異常處理后再拋出
throw e;
}finally{
//收尾處理
}
}
}
很不幸,這段代碼編譯不通過,原因是構造函數Sub沒有把super()放在第一句話中,想把父類的異常重新包裝再拋出是不可行的(當然,這里有很多種 “曲線” 的實現手段,比如重新定義一個方法,然后父子類的構造函數都調用該方法,那么子類構造函數就可以自由處理異常了),這是Java語法機制。
將以上三種異常類型匯總起來,對于構造函數,錯誤只能拋出,這是程序人員無能為力的事情;非受檢異常不要拋出,拋出了 " 對己對人 " 都是有害的;受檢異常盡量不拋出,能用曲線的方式實現就用曲線方式實現,總之一句話:在構造函數中盡可能不出現異常。
注意 :在構造函數中不要拋出異常,盡量曲線實現。
建議117:多使用異常,把性能問題放一邊
我們知道異常是主邏輯的例外邏輯,舉個簡單的例子來說,比如我在馬路上走(這是主邏輯),突然開過一輛車,我要避讓(這是受檢異常,必須處理),繼續走著,突然一架飛機從我頭頂飛過(非受檢異常),我們可以選在繼續行走(不捕捉),也可以選擇指責其噪音污染(捕捉,主邏輯的補充處理),再繼續走著,突然一顆流星砸下來,這沒有選擇,屬于錯誤,不能做任何處理。這樣具備完整例外場景的邏輯就具備了OO的味道,任何一個事務的處理都可能產生非預期的效果,問題是需要以何種手段來處理,如果不使用異常就需要依靠返回值的不同來進行處理了,這嚴重失去了面向對象的風格。
我們在編寫用例文檔(User case Specification)時,其中有一項叫做 " 例外事件 ",是用來描述主場景外的例外場景的,例如用戶登錄的用例,就會在" 例外事件 "中說明" 連續3此登錄失敗即鎖定用戶賬號 ",這就是登錄事件的一個異常處理,具體到我們的程序中就是:
public void login(){
try{
//正常登陸
}catch(InvalidLoginException lie){
// 用戶名無效
}catch(InvalidPasswordException pe){
//密碼錯誤的異常
}catch(TooMuchLoginException){
//多次登陸失敗的異常
}
}
如此設計則可以讓我們的login方法更符合實際的處理邏輯,同時使主邏輯(正常登錄,try代碼塊)更加清晰。當然了,使用異常還有很多優點,可以讓正常代碼和異常代碼分離、能快速查找問題(棧信息快照)等,但是異常有一個缺點:性能比較慢。
Java的異常機制確實比較慢,這個"比較慢"是相對于諸如String、Integer等對象來說的,單單從對象的創建上來說,new一個IOException會比String慢5倍,這從異常的處理機制上也可以解釋:因為它要執行fillInStackTrace方法,要記錄當前棧的快照,而String類則是直接申請一個內存創建對象,異常類慢一籌也就在所難免了。
而且,異常類是不能緩存的,期望先建立大量的異常對象以提高異常性能也是不現實的。
難道異常的性能問題就沒有任何可以提高的辦法了?確實沒有,但是我們不能因為性能問題而放棄使用異常,而且經過測試,在JDK1.6下,一個異常對象的創建時間只需1.4毫秒左右(注意是毫秒,通常一個交易是在100毫秒左右),難道我們的系統連如此微小的性能消耗都不予許嗎?
注意:性能問題不是拒絕異常的借口。
建議121:線程優先級只使用三個等級
線程的優先級(Priority)決定了線程獲得CPU運行的機會,優先級越高獲得的運行機會越大,優先級越低獲得的機會越小。Java的線程有10個級別(準確的說是11個級別,級別為0的線程是JVM的,應用程序不能設置該級別),那是不是說級別是10的線程肯定比級別是9的線程先運行呢?我們來看如下一個多線程類:
class TestThread implements Runnable {
public void start(int _priority) {
Thread t = new Thread(this);
// 設置優先級別
t.setPriority(_priority);
t.start();
}
@Override
public void run() {
// 消耗CPU的計算
for (int i = 0; i < 100000; i++) {
Math.hypot(924526789, Math.cos(i));
}
// 輸出線程優先級
System.out.println("Priority:" + Thread.currentThread().getPriority());
}
}
該多線程實現了Runnable接口,實現了run方法,注意在run方法中有一個比較占用CPU的計算,該計算毫無意義,
public static void main(String[] args) {
//啟動20個不同優先級的線程
for (int i = 0; i < 20; i++) {
new TestThread().start(i % 10 + 1);
}
}
這里創建了20個線程,每個線程在運行時都耗盡了CPU的資源,因為優先級不同,線程調度應該是先處理優先級高的,然后處理優先級低的,也就是先執行2個優先級為10的線程,然后執行2個優先級為9的線程,2個優先級為8的線程......但是結果卻并不是這樣的。
Priority:5
Priority:7
Priority:10
Priority:6
Priority:9
Priority:6
Priority:5
Priority:7
Priority:10
Priority:3
Priority:4
Priority:8
Priority:8
Priority:9
Priority:4
Priority:1
Priority:3
Priority:1
Priority:2
Priority:2
println方法雖然有輸出損耗,可能會影響到輸出結果,但是不管運行多少次,你都會發現兩個不爭的事實:
(1)、并不是嚴格按照線程優先級來執行的
比如線程優先級為5的線程比優先級為7的線程先執行,優先級為1的線程比優先級為2的線程先執行,很少出現優先級為2的線程比優先級為10的線程先執行(注意,這里是" 很少 ",是說確實有可能出現,只是幾率低,因為優先級只是表示線程獲得CPU運行的機會,并不代表強制的排序號)。
(2)、優先級差別越大,運行機會差別越明顯
比如優先級為10的線程通常會比優先級為2的線程先執行,但是優先級為6的線程和優先級為5的線程差別就不太明顯了,執行多次,你會發現有不同的順序。
這兩個現象是線程優先級的一個重要表現,之所以會出現這種情況,是因為線程運行是需要獲得CPU資源的,那誰能決定哪個線程先獲得哪個線程后獲得呢?這是依照操作系統設置的線程優先級來分配的,也就是說,每個線程要運行,需要操作系統分配優先級和CPU資源,對于JAVA來說,JVM調用操作系統的接口設置優先級,比如windows操作系統優先級都相同嗎?
事實上,不同的操作系統線程優先級是不同的,Windows有7個優先級,Linux有140個優先級,Freebsd則由255個(此處指的優先級個數,不同操作系統有不同的分類,如中斷級線程,操作系統級等,各個操作系統具體用戶可用的線程數量也不相同)。Java是跨平臺的系統,需要把這10個優先級映射成不同的操作系統的優先級,于是界定了Java的優先級只是代表搶占CPU的機會大小,優先級越高,搶占CPU的機會越大,被優先執行的可能性越高,優先級相差不大,則搶占CPU的機會差別也不大,這就是導致了優先級為9的線程可能比優先級為10的線程先運行。
Java的締造者們也覺察到了線程優先問題,于是Thread類中設置了三個優先級,此意就是告訴開發者,建議使用優先級常量,而不是1到10的隨機數字。常量代碼如下:
public class Thread implements Runnable {
/**
* The minimum priority that a thread can have.
*/
public final static int MIN_PRIORITY = 1;
/**
* The default priority that is assigned to a thread.
*/
public final static int NORM_PRIORITY = 5;
/**
* The maximum priority that a thread can have.
*/
public final static int MAX_PRIORITY = 10;
}
在編碼時直接使用這些優先級常量,可以說在大部分情況下MAX_PRIORITY的線程回比MIN_PRIORITY的線程優先運行,但是不能認為是必然會先運行,不能把這個優先級做為核心業務的必然條件,Java無法保證優先級高肯定會先執行,只能保證高優先級有更多的執行機會。因此,建議在開發時只使用此三類優先級,沒有必要使用其他7個數字,這樣也可以保證在不同的操作系統上優先級的表現基本相同。
大家也許會問,如果優先級相同呢?這很好辦,也是由操作系統決定的。基本上是按照FIFO原則(先入先出,First Input First Output),但也是不能完全保證。
歡迎轉載,轉載請注明出處!
簡書ID:@我沒有三顆心臟
github:wmyskxz
歡迎關注公眾微信號:wmyskxz_javaweb
分享自己的Java Web學習之路以及各種Java學習資料