編寫高質量程式碼:改善Java程式的151個建議(第5章:陣列和集合___建議60~64)

阿赫瓦里發表於2016-09-19

      噢,它明白了,河水既沒有牛伯伯說的那麼淺,也沒有小松鼠說的那麼深,只有親自試過才知道。

                                            ---寓言故事《小馬過河》

  資料處理是每種語言必備的功能,Java更甚之,資料集可以允許重複,也可以不允許重複,可以允許null存在,也可以不允許null存在,可以自動排序,也可以不自動排序,可以是阻塞式的,也可以是非阻塞式的,可以是棧,也可以是佇列......

  本章將圍繞我們使用最多的三個資料集合(陣列,ArrayList和HashMap)來闡述在開發過程中要注意的事項,並由此延伸至Set、Quene、Stack等集合。

建議60:效能考慮,陣列是首選

  陣列在實際的系統開發中用的越來越少了,我們通常只有在閱讀一些開源專案時才會看到它們的身影,在Java中它確實沒有List、Set、Map這些集合類用起來方便,但是在基本型別處理方面,陣列還是佔優勢的,而且集合類的底層也都是通過陣列實現的,比如對一資料集求和這樣的計算:

1 //對陣列求和
2     public static int sum(int datas[]) {
3         int sum = 0;
4         for (int i = 0; i < datas.length; i++) {
5             sum += datas[i];
6         }
7         return sum;
8     }

  對一個int型別 的陣列求和,取出所有陣列元素並相加,此演算法中如果是基本型別則使用陣列效率是最高的,使用集合則效率次之。再看使用List求和: 

1 // 對列表求和計算
2     public static int sum(List<Integer> datas) {
3         int sum = 0;
4         for (int i = 0; i < datas.size(); i++) {
5             sum += datas.get(i);
6         }
7         return sum;
8     }

  注意看sum += datas.get(i);這行程式碼,這裡其實已經做了一個拆箱動作,Integer物件通過intValue方法自動轉換成了一個int基本型別,對於效能瀕於臨界的系統來說該方案是比較危險的,特別是大數量的時候,首先,在初始化List陣列時要進行裝箱操作,把一個int型別包裝成一個Integer物件,雖然有整型池在,但不在整型池範圍內的都會產生一個新的Integer物件,而且眾所周知,基本型別是在棧記憶體中操作的,而物件是堆記憶體中操作的,棧記憶體的特點是:速度快,容量小;堆記憶體的特點是:速度慢,容量大(從效能上講,基本型別的處理佔優勢)。其次,在進行求和運算時(或者其它遍歷計算)時要做拆箱動作,因此無謂的效能消耗也就產生了。在實際測試中發現:對基本型別進行求和運算時,陣列的效率是集合的10倍。

  注意:效能要求較高的場景中使用陣列代替集合。

建議61:若有必要,使用變長陣列

  Java中的陣列是定長的,一旦經過初始化宣告就不可改變長度,這在實際使用中非常不方便,比如要對班級學生的資訊進行統計,因為我們不知道一個班級會有多少學生(隨時都可能會有學生入學、退學或轉學),所以需要一個足夠大的陣列來容納所有的學生,但問題是多大才算足夠大?20年前一臺桌上型電腦64MB的記憶體已經很牛了,現在要是沒有2GB的記憶體(現在這個都太小了)你都不好意思跟別人交流計算機的配置,所以呀,這個足夠大是相對於當時的場景而言的,隨著環境的變化,"足夠大"也可能會轉變成"足夠小",然後就會出現超出陣列最大容量的情況,那該如何解決呢?事實上,可以通過對陣列擴容"婉轉" 地解決該問題,程式碼如下:

1     public static <T> T[] expandCapacity(T[] datas, int newLen) {
2         // 不能是負值
3         newLen = newLen < 0 ? 0 : newLen;
4         // 生成一個新陣列,並拷貝原值
5         return Arrays.copyOf(datas, newLen);
6     }

  上述程式碼採用的是Arrays陣列工具類的copyOf方法,產生了一個newLen長度的新陣列,並把原有的值拷貝了進去,之後就可以對超長的元素進行賦值了(依據型別的不同分別賦值0、false或null),使用方法如下:

public class Client61 {
    public static void main(String[] args) {
        //一個班級最多容納60個學生
        Stu [] stuNums= new Stu[60];
        //stuNums初始化......
        //偶爾一個班級可以容納80人,陣列加長
        stuNums=expandCapacity(stuNums,80);
        /* 重新初始化超過限額的20人...... */
        
    }

    public static <T> T[] expandCapacity(T[] datas, int newLen) {
        // 不能是負值
        newLen = newLen < 0 ? 0 : newLen;
        // 生成一個新陣列,並拷貝原值
        return Arrays.copyOf(datas, newLen);
    }
}
class Stu{
    
}

  通過這樣的處理方式,曲折的解決了陣列的變長問題,其實,集合的長度自動維護功能的原理與此類似。在實際開發中,如果確實需要變長的資料集,陣列也是在考慮範圍之內的,不能因固定長度而將其否定之。

建議62:警惕陣列的淺拷貝

   有這樣一個例子,第一個箱子裡有赤橙黃綠青藍紫7色氣球,現在希望在第二個箱子中也放入7個氣球,其中最後一個氣球改為藍色,也就是赤橙黃綠青藍藍7個氣球,那我們很容易就會想到第二個箱子中的氣球可以通過拷貝第一個箱子中的氣球來實現,畢竟有6個氣球是一樣的嘛,來看實現程式碼: 

 1 import java.util.Arrays;
 2 import org.apache.commons.lang.builder.ToStringBuilder;
 3 
 4 public class Client62 {
 5     public static void main(String[] args) {
 6         // 氣球數量
 7         int ballonNum = 7;
 8         // 第一個箱子
 9         Balloon[] box1 = new Balloon[ballonNum];
10         // 初始化第一個箱子中的氣球
11         for (int i = 0; i < ballonNum; i++) {
12             box1[i] = new Balloon(Color.values()[i], i);
13         }
14         // 第二個箱子的氣球是拷貝第一個箱子裡的
15         Balloon[] box2 = Arrays.copyOf(box1, box1.length);
16         // 修改最後一個氣球顏色
17         box2[6].setColor(Color.Blue);
18         // 列印出第一個箱子中的氣球顏色
19         for (Balloon b : box1) {
20             System.out.println(b);
21         }
22 
23     }
24 }
25 
26 // 氣球顏色
27 enum Color {
28     Red, Orange, Yellow, Green, Indigo, Blue, Violet
29 }
30 
31 // 氣球
32 class Balloon {
33     // 編號
34     private int id;
35     // 顏色
36     private Color color;
37 
38     public Balloon(Color _color, int _id) {
39         color = _color;
40         id = _id;
41     }
42 
43     public int getId() {
44         return id;
45     }
46 
47     public void setId(int id) {
48         this.id = id;
49     }
50 
51     public Color getColor() {
52         return color;
53     }
54 
55     public void setColor(Color color) {
56         this.color = color;
57     }
58 
59     @Override
60     public String toString() {
61         //apache-common-lang包下的ToStringBuilder重寫toString方法
62         return new ToStringBuilder(this).append("編號", id).append("顏色", color).toString();
63     }
64 
65 }

  第二個箱子裡最後一個氣球的顏色毫無疑問是被修改為藍色了,不過我們是通過拷貝第一個箱子裡的氣球然後再修改的方式來實現的,那會對第一個箱子的氣球顏色有影響嗎?我們看看輸出結果:

  

  最後一個氣球顏色竟然也被修改了,我們只是希望修改第二個箱子的氣球啊,這是為何?這是典型的淺拷貝(Shallow  Clone)問題,以前第一章序列化時講過,但是這裡與之有一點不同:陣列中的元素沒有實現Serializable介面。

  確實如此,通過copyOf方法產生的陣列是一個淺拷貝,這與序列化的淺拷貝完全相同:基本型別是直接拷貝值,其它都是拷貝引用地址。需要說明的是,陣列的clone方法也是與此相同的,同樣是淺拷貝,而且集合的clone方法也都是淺拷貝,這就需要大家在拷貝時多留心了。

  問題找到了,解決辦法也很簡單,遍歷box1的每個元素,重新生成一個氣球(Balloon)物件,並放置到box2陣列中,程式碼比較簡單,不再贅述。

  該方法用的最多的地方是在使用集合(如List),進行業務處理時,比如發覺需要拷貝集合中的元素,可集合沒有提供拷貝方法,如果自己寫會很麻煩,所以乾脆使用List.toArray方法轉換成陣列,然後通過Arrays.copyOf拷貝,再轉換回集合,簡單便捷!但是,非常遺憾的是,這裡我們又撞到淺拷貝的槍口上了,雖然很多時候淺拷貝可以解決業務問題,但更多時候會留下隱患,我們需要提防又提防。

建議63:在明確的場景下,為集合指定初始容量

   我們經常使用ArrayList、Vector、HashMap等集合,一般都是直接用new跟上類名宣告出一個集合來,然後使用add、remove等方法進行操作,而且因為它是自動管理長度的,所以不用我們特別費心超長的問題,這確實是一個非常好的優點,但也有我們必須要注意的事項。

  下面以ArrayList為例深入瞭解一下Java是如何實現長度的動態管理的,先從add方法的閱讀開始,程式碼(JDK7)如下:

1  public boolean add(E e) {
2         //擴充套件長度
3         ensureCapacityInternal(size + 1);  // Increments modCount!!
4         //追加元素
5         elementData[size++] = e;
6         return true;
7     }    

  我們知道ArrayList是一個大小可變的陣列,但它在底層使用的是陣列儲存(也就是elementData變數),而且陣列長度是定長的,要實現動態長度必然要進行長度的擴充套件,ensureCapacityInternal方法提供了此功能,程式碼如下:

private void ensureCapacityInternal(int minCapacity) {
        //修改計數器
        modCount++;
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }


private void grow(int minCapacity) {
        // overflow-conscious code
       //上次(原始)定義的陣列長度
        int oldCapacity = elementData.length;
        //新長度為原始長度+原始長度右移一位 ==>原始長度的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        //陣列拷貝,生成新陣列
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

 private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

  大概分析一下這些原始碼,這個原始碼還是JDK7之前的版本上做了優化處理的。先說一下第一個方法ensureCapacityIntenal, 方法名的英文大致意思是“確保內部容量”,這裡要說明,size表示的是現有的元素個數,並非ArrayList的容量,容量應該是陣列 elementData的長度。引數minCapacity是需要檢查的最小容量,即方法的功能就是確保elementData的長度不小於 minCapacity,如果不夠,則呼叫grow增加容量。容量的增長也算結構性變動,所以modCount需要加1。

  grow方法:先對容量擴大1.5倍,這裡oldCapacity >> 1是二進位制操作右移,相當於除以2,如果不知道這個面壁去吧。接再來把新的臨時容量(還沒正式改變容量,應該叫預期容量)和實際需要的最小容量比較,如果還不滿 足,則把臨時容量改成需要的最小容量值。在判斷容量是否超過MAX_ARRAY_SIZE的值,MAX_ARRAY_SIZE值為 Integer.MAX_VALUE - 8,比int的最大值小8,不知道設計初衷是什麼,可能方便判斷吧。如果已經超過,呼叫hugeCapacity方法檢查容量的int值是不是已經溢位。一般很 少用到int最大值的情況,那麼多資料也不會用ArrayList來做容器了,估計沒機會見到hugeCapacity執行一次了。最後確定了新的容量,就使用Arrays.copyOf方法來生成新的陣列,copyOf也已經完成了將就的資料拷貝到新陣列的工作。

  迴歸正題,大家注意看陣列長度的計算方法,並不是增加一個元素,elementData的長度就加1,而是在達到elementData長度的臨界點時,才將elementData擴容1.5倍,這樣實現避免了多次copyOf方法的效能開銷,否則每增加一個元素都要擴容一次,那效能會更差。不知道大家有沒有這樣一個疑問,為啥要擴容1.5倍,而不是2.5,倍、3.5倍呢?其實我也這麼想過,原因是一次擴容太大,佔用的記憶體就越大,浪費的記憶體也就越多(1.5倍擴容,最多浪費33%的陣列空間,而2.5倍則最多消耗60%的記憶體),而一次擴容太小,則需要多次對陣列重新分配記憶體,效能消耗嚴重,經過測試驗證,擴容1.5倍既滿足了效能要求,也減少了記憶體消耗。

  現在我們知道了ArrayList的擴容原則,那還有一個問題:elementData的預設長度是多少呢?答案是10,如果我們使用預設方式宣告ArrayList,如new ArrayList(),則elementData的初始長度是10,我們看看ArrayList的三個建構函式。

//無參構造
public ArrayList() {
        this(10);
    }

//構造一個具有指定初始容量的空列表。
public ArrayList(int initialCapacity) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
    }

//構造一個包含指定 collection 的元素的列表,這些元素是按照該 collection 的迭代器返回它們的順序排列的
public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        size = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    }

  ArrayList():預設建構函式,提供初始容量為10的空列表。

     ArrayList(int initialCapacity):構造一個具有指定初始容量的空列表。

     ArrayList(Collection<? extends E> c):構造一個包含指定 collection 的元素的列表,這些元素是按照該 collection 的迭代器返回它們的順序排列的。

   從這裡我們可以看出,如果不設定初始容量,系統會按照1.5倍的規則擴容,每次擴容都是一次陣列的拷貝,如果資料量大,這樣的拷貝會非常消耗資源,而且效率非常低下。所以,我們如果知道一個ArrayList的可能長度,然後對ArrayList設定一個初始容量則可以顯著提高系統效能。

  其它的集合如Vector和ArrayList類似,只是擴容的倍數不同而已,Vector擴容2倍,大家有興趣的話可以看看Vector,HashMap的JDK原始碼。  

建議64:多種最值演算法,適時選擇

   對一批資料進行排序,然後找出其中的最大值或最小值,這是基本的資料結構知識。在Java中我們可以通過編寫演算法的方式,也可以通過陣列先排序再取值的方式來實現,下面以求最大值為例,解釋一下多種演算法:

  (1)、自行實現,快速查詢最大值  

   先看看用快速查詢法取最大值的演算法,程式碼如下: 

1 public static int max(int[] data) {
2         int max = data[0];
3         for (int i : data) {
4             max = max > i ? max : i;
5         }
6         return max;
7     }

  這是我們經常使用的最大值演算法,也是速度最快的演算法。它不要求排序,只要遍歷一遍陣列即可找出最大值。

  (2)、先排序,後取值

  對於求最大值,也可以採用先排序後取值的方式,程式碼如下:

1 public static int max(int[] data) {
2         Arrays.sort(data);
3         return data[data.length - 1];
4     }

  從效率上講,當然是自己寫快速查詢法更快一些了,只用遍歷一遍就可以計算出最大值,但在實際測試中發現,如果陣列量少於10000,兩個基本上沒有區別,但在同一個毫秒級別裡,此時就可以不用自己寫演算法了,直接使用陣列先排序後取值的方式。

  如果陣列元素超過10000,就需要依據實際情況來考慮:自己實現,可以提高效能;先排序後取值,簡單,通俗易懂。排除效能上的差異,兩者都可以選擇,甚至後者更方便一些,也更容易想到。

  現在問題來了,在程式碼中為什麼先使用data.clone拷貝再排序呢?那是因為陣列也是一個物件,不拷貝就改變了原有的陣列元素的順序嗎?除非陣列元素的順序無關緊要。那如果要查詢僅次於最大值的元素(也就是老二),該如何處理呢?要注意,陣列的元素時可以重複的,最大值可能是多個,所以單單一個排序然後取倒數第二個元素時解決不了問題的。

  此時,就需要一個特殊的排序演算法了,先要剔除重複資料,然後再排序,當然,自己寫演算法也可以實現,但是集合類已經提供了非常好的方法,要是再使用自己寫演算法就顯得有點重複造輪子了。陣列不能剔除重複資料,但Set集合卻是可以的,而且Set的子類TreeSet還能自動排序,程式碼如下: 

1 public static int getSecond(Integer[] data) {
2         //轉換為列表
3         List<Integer> dataList = Arrays.asList(data);
4         //轉換為TreeSet,剔除重複元素並升序排列
5         TreeSet<Integer> ts = new TreeSet<Integer>(dataList);
6         //取得比最大值小的最大值,也就是老二了
7         return ts.lower(ts.last());
8     }

  剔除重複元素並升序排列,這都是由TreeSet類實現的,然後可再使用lower方法尋找小於最大值的值,大家看,上面的程式非常簡單吧?那如果是我們自己編寫程式碼會怎麼樣呢?那至少要遍歷陣列兩遍才能計算出老二的值,程式碼複雜度將大大提升。因此在實際應用中求最值,包括最大值、最小值、倒數第二小值等,使用集合是最簡單的方式,當然從效能方面來考慮,陣列才是最好的選擇。

  注意:最值計算時使用集合最簡單,使用陣列效能最優。

相關文章