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

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

建議75:集合中的元素必須做到compareTo和equals同步

  實現了Comparable介面的元素就可以排序,compareTo方法是Comparable介面要求必須實現的,它與equals方法有關係嗎?有關係,在compareTo的返回為0時,它表示的是 進行比較的兩個元素時相等的。equals是不是也應該對此作出相應的動作呢?我們看如下程式碼:

 1 class City implements Comparable<City> {
 2     private String code;
 3 
 4     private String name;
 5 
 6     public City(String _code, String _name) {
 7         code = _code;
 8         name = _name;
 9     }
10     //code、name的setter和getter方法略
11     @Override
12     public int compareTo(City o) {
13         //按照城市名稱排序
14         return new CompareToBuilder().append(name, o.name).toComparison();
15     }
16 
17     @Override
18     public boolean equals(Object obj) {
19         if (null == obj) {
20             return false;
21         }
22         if (this == obj) {
23             return true;
24         }
25         if (obj.getClass() == getClass()) {
26             return false;
27         }
28         City city = (City) obj;
29         // 根據code判斷是否相等
30         return new EqualsBuilder().append(code, city.code).isEquals();
31     }
32 
33 }

  我們把多個城市物件放在一個list中,然後使用不同的方法查詢同一個城市,看看返回值有神麼異常?程式碼如下:

 1     public static void main(String[] args) {
 2         List<City> cities = new ArrayList<City>();
 3         cities.add(new City("021", "上海"));
 4         cities.add(new City("021", "滬"));
 5         // 排序
 6         Collections.sort(cities);
 7         // 查詢物件
 8         City city = new City("021", "滬");
 9         // indexOf方法取得索引值
10         int index1 = cities.indexOf(city);
11         // binarySearch查詢索引值
12         int index2 = Collections.binarySearch(cities, city);
13         System.out.println(" 索引值(indexOf) :" + index1);
14         System.out.println(" 索引值(binarySearch) :" + index2);
15     }

  輸出的index1和index2應該一致吧,都是從一個列表中查詢相同的元素,只是使用的演算法不同嘛。但是很遺憾,結果不一致:

  

  indexOf返回的是第一個元素,而binarySearch返回的是第二個元素(索引值為1),這是怎麼回事呢?

  這是因為indexOf是通過equals方法判斷的,equals方法等於true就認為找到符合條件的元素了,而binarySearch查詢的依據是compareTo方法的返回值,返回0即認為找到符合條件的元素了。

  仔細審查一下程式碼,我們覆寫了compareTo和equals方法,但是兩者並不一致。使用indexOf方法查詢時 ,遍歷每個元素,然後比較equals方法的返回值,因為equals方法是根據code判斷的,因此當第一次迴圈時 ,equals就返回true,indexOf方法結束,查詢到指定值。而使用binarySearch二分法查詢時,依據的是每個元素的compareTo方法返回值,而compareTo方法又是依賴屬性的,name相等就返回0,binarySearch就認為找到元素了。

  問題明白了,修改很easy,將equals方法修改成判斷name是否相等即可,雖然可以解決問題,但這是一個很無奈的辦法,而且還要依賴我們的系統是否支援此類修改,因為邏輯已經發生了很大的變化,從這個例子,我們可以理解兩點:

  • indexOf依賴equals方法查詢,binarySearch則依賴compareTo方法查詢;
  • equals是判斷元素是否相等,compareTo是判斷元素在排序中的位置是否相同。

   既然一個決定排序位置,一個是決定相等,那我們就應該保證當排序相同時,其equals也相同,否則就會產生邏輯混亂。

  注意:實現了compareTo方法就應該覆寫equals方法,確保兩者同步。

建議76:集合運算時使用最優雅方式

  在初中代數中,我們經常會求兩個集合的並集、交集、差集等,在Java中也存在著此類運算,那如何實現呢?一提到此類集合操作,大部分的實現者都會說:對兩個集合進行遍歷,即可求出結果。是的。遍歷可以實現並集、交集、差集等運算,但這不是最優雅的處理方式,下面來看看如何進行更優雅、快速、方便的集合操作:

  (1)、並集:也叫作合集,把兩個集合加起來即可,這非常簡單,程式碼如下:   

1 public static void main(String[] args) {
2         List<String> list1 = new ArrayList<String>();
3         list1.add("A");
4         list1.add("B");
5         List<String> list2 = new ArrayList<String>();
6         list2.add("C");
7         // 並集
8         list1.addAll(list2);
9     }

  (2)、交集:計算兩個集合的共有元素,也就是你有我也有的元素集合,程式碼如下:

//交集
list1.retainAll(list2);

  其中的變數list1和list2是兩個列表,僅此一行,list1中就只包含了list1、list2中共有的元素了,注意retailAll方法會刪除list1中沒有出現在list2中的元素。

  (3)、差集:由所有屬於A但不屬於B的元素組成的集合,叫做A與B的差集,也就是我有你沒有的元素,程式碼如下:

//差集
list1.removeAll(list2);

  也很簡單,從list1中刪除出現在list2中的元素,即可得出list1和list2的差集部分。

  (4)、無重複的並集:並集是集合A加集合B,那如果集合A和集合B有交集,就需要確保並集的結果中只有一份交集,此為無重複的並集,此操作也比較簡單,程式碼如下:

        //刪除在list1中出現的元素
        list2.removeAll(list1);
        //把剩餘的list2元素加到list1中
        list1.addAll(list2);

  可能有人會說,求出兩個集合的並集,然後轉成hashSet剔除重複元素不就解決了嗎?錯了,這樣解決是不行的,比如集合A有10個元素(其中有兩個元素值是相同的),集合B有8個元素,它們的交集有兩個元素,我們可以計算出它們的並集是18個元素,而無重複的並集有16個元素,但是如果用hashSet演算法,算出來則只有15個元素,因為你把集合A中原本就重複的元素也剔除了。

  之所以介紹並集、交集、差集,那是因為在實際開發中,很少有使用JDK提供的方法實現集合這些操作,基本上都是採用了標準的巢狀for迴圈:要並集就是加法,要交集就是contains判斷是否存在,要差集就使用了!contains(不包含),有時候還要為這類操作提供了一個單獨的方法看似很規範,其實應經脫離了優雅的味道。

  集合的這些操作在持久層中使用的非常頻繁,從資料庫中取出的就是多個資料集合,之後我們就可以使用集合的各種方法構建我們需要的資料,需要兩個集合的and結果,那是交集,需要兩個集合的or結果,那是並集,需要兩個集合的not結果,那是差集。

建議77:使用shuffle打亂列表

   在網站上,我們經常會看到關鍵字雲(word cloud)和標籤雲(tag cloud),用於表達這個關鍵字或標籤是經常被查閱的,而且還可以看到這些標籤的動態運動,每次重新整理都會有不一樣的關鍵字或標籤,讓瀏覽者覺得這個網站的訪問量很大,短短的幾分鐘就有這麼多的搜尋量。不過,在Java中該如何實現呢?程式碼如下:  

 1 public static void main(String[] args) {
 2         int tagCloudNum = 10;
 3         List<String> tagClouds = new ArrayList<String>(tagCloudNum);
 4         // 初始化標籤雲,一般是從資料庫讀入,省略
 5         Random rand = new Random();
 6         for (int i = 0; i < tagCloudNum; i++) {
 7             // 取得隨機位置
 8             int randomPosition = rand.nextInt(tagCloudNum);
 9             // 當前元素與隨機元素交換
10             String temp = tagClouds.get(i);
11             tagClouds.set(i, tagClouds.get(randomPosition));
12             tagClouds.set(randomPosition, temp);
13         }
14     }

  現從資料庫中讀取標籤,然後使用隨機數打亂,每次產生不同的順序,嗯,確實能讓瀏覽者感覺到我們的標籤雲順序在變化---瀏覽者多嘛!但是,對於亂序處理我們可以有更好的實現方式,先來修改第一版:

 1 public static void main(String[] args) {
 2         int tagCloudNum = 10;
 3         List<String> tagClouds = new ArrayList<String>(tagCloudNum);
 4         // 初始化標籤雲,一般是從資料庫讀入,省略
 5         Random rand = new Random();
 6         for (int i = 0; i < tagCloudNum; i++) {
 7             // 取得隨機位置
 8             int randomPosition = rand.nextInt(tagCloudNum);
 9             // 當前元素與隨機元素交換
10             Collections.swap(tagClouds, i, randomPosition);
11         }
12     }

  上面使用了Collections的swap方法,該方法會交換兩個位置的元素值,不用我們自己寫交換程式碼了。難道亂序到此就優化完了嗎?沒有,我們可以繼續重構,第二版如下:

1     public static void main(String[] args) {
2         int tagCloudNum = 10;
3         List<String> tagClouds = new ArrayList<String>(tagCloudNum);
4         // 初始化標籤雲,一般是從資料庫讀入,省略
5         //打亂順序
6         Collections.shuffle(tagClouds);
7     }

 這才是我們想要的結果,就這一行,即可打亂一個列表的順序,我們不用費盡心思的遍歷、替換元素了。我們一般很少用到shuffle這個方法,那它在什麼地方用呢?

  • 可用在程式的 "偽裝" 上:比如我們例子中的標籤雲,或者是遊俠中的打怪、修行、群毆時寶物的分配策略。
  • 可用在抽獎程式中:比如年會的抽獎程式,先使用shuffle把員工順序打亂,每個員工的中獎機率相等,然後就可以抽出第一名、第二名。
  • 可以用在安全傳輸方面:比如傳送端傳送一組資料,先隨機打亂順序,然後加密傳送,接收端解密,然後進行排序,即可實現即使是相同的資料來源,也會產生不同密文的效果,加強了資料的安全性。

建議78:減少HashMap中元素的數量 

  本建議是說HahMap中存放資料過多的話會出現記憶體溢位,程式碼如下:  

 1     public static void main(String[] args) {
 2         Map<String, String> map = new HashMap<String, String>();
 3         List<String> list = new ArrayList<String>();
 4         final Runtime rt = Runtime.getRuntime();
 5         // JVM中止前記錄資訊
 6         rt.addShutdownHook(new Thread() {
 7             @Override
 8             public void run() {
 9                 StringBuffer sb = new StringBuffer();
10                 long heapMaxSize = rt.maxMemory() >> 20;
11                 sb.append(" 最大可用記憶體:" + heapMaxSize + " M\n");
12                 long total = rt.totalMemory() >> 20;
13                 sb.append(" 堆記憶體大小:" + total + "M\n");
14                 long free = rt.freeMemory() >> 20;
15                 sb.append(" 空閒記憶體:" + free + "M");
16                 System.out.println(sb);
17             }
18         });
19         for (int i = 0; i < 40*10000; i++) {
20             map.put("key" + i, "value" + i);
21 //            list.add("list"+i);
22         }
23     }  

  這個例子,我經過多次運算,發現在40萬的資料並不會記憶體溢位,如果要復現此問題,需要修改Eclipse的記憶體配置,才會復現。但現在的機器的記憶體逐漸的增大,硬體配置的提高,應該可以容納更多的資料。本人機器是windows64,記憶體8G配置,Eclipse的配置為   -Xms286M    -Xmx1024M,在單獨執行此程式時,資料量加到千萬級別才會復現出此問題。但在生產環境中,如果放的是複雜物件,可能同樣配置的機器存放的資料量會小一些。

  但如果換成list存放,則同樣的配置存放的資料比HashMap要多一些,本人就針對此現象進行分析一下幾點:

  1.HashMap和ArrayList的長度都是動態增加的,不過兩者的擴容機制不同,先說HashMap,它在底層是以陣列的方式儲存元素的,其中每一個鍵值對就是一個元素,也就是說HashMap把鍵值對封裝成了一個Entry物件,然後再把Entry物件放到了陣列中。也就是說HashMap比ArrayList多了一次封裝,多出了一倍的物件。其中HashMap的擴容機制程式碼如下(resize(2 * table.length)這就是擴容核心程式碼):

1    void addEntry(int hash, K key, V value, int bucketIndex) {
2         if ((size >= threshold) && (null != table[bucketIndex])) {
3             resize(2 * table.length);
4             hash = (null != key) ? hash(key) : 0;
5             bucketIndex = indexFor(hash, table.length);
6         }
7 
8         createEntry(hash, key, value, bucketIndex);
9     }

    在插入鍵值對時會做長度校驗,如果大於或者等於閾值,則陣列長度會增大一倍。

 1  void resize(int newCapacity) {
 2         Entry[] oldTable = table;
 3         int oldCapacity = oldTable.length;
 4         if (oldCapacity == MAXIMUM_CAPACITY) {
 5             threshold = Integer.MAX_VALUE;
 6             return;
 7         }
 8 
 9         Entry[] newTable = new Entry[newCapacity];
10         boolean oldAltHashing = useAltHashing;
11         useAltHashing |= sun.misc.VM.isBooted() &&
12                 (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
13         boolean rehash = oldAltHashing ^ useAltHashing;
14         transfer(newTable, rehash);
15         table = newTable;
16         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
17     }

  而閾值就是程式碼中紅色標註的部分,新容量*加權因子和MAXIMUM_CAPACITY + 1兩個值的最小值。MAXIMUM_CAPACITY的值如下: 

static final int MAXIMUM_CAPACITY = 1 << 30;

  而加權因子的值為0.75,程式碼如下:

static final float DEFAULT_LOAD_FACTOR = 0.75f;

  所以hashMap的size大於陣列的0.75倍時,就開始擴容,經過計算得知(怎麼計算的,以文中例子來說,查詢2的N次冪大於40萬的最小值即為陣列的最大長度,再乘以0.75,也就是最後一次擴容點,計算的結果是N=19),在Map的size為393216時,符合了擴容條件,於是393216個元素開始搬家,要擴容則需要申請一個長度為1048576(當前長度的兩倍,2的20次方)的陣列,如果此時記憶體不足以支撐此運算,就會爆出記憶體溢位。這個就是這個問題的根本原因。

 2、我們思考一下ArrayList的擴容策略,它是在小於陣列長度的時候才會擴容1.5倍,經過計算得知,ArrayLsit在超過80萬後(一次加兩個元素,40萬的兩倍),最近的一次擴容是在size為1005305時同樣的道理,如果此時記憶體不足以申請擴容1.5倍時的陣列,也會出現記憶體溢位。

 綜合來說,HashMap比ArrayList多了一層Entry的底層封裝物件,多佔用了記憶體,並且它的擴容策略是2倍長度的遞增,同時還會根據閾值判斷規則進行判斷,因此相對於ArrayList來說,同樣的資料,它就會優先記憶體溢位。

 也許大家會想到,可以在宣告時指定HashMap的預設長度和載入因子來減少此問題的發生,可以緩解此問題,可以不再頻繁的進行陣列擴容,但仍避免不了記憶體溢位問題,因為鍵值對的封裝物件Entry還是少不了的,記憶體依然增長比較快,所以儘量讓HashMap中的元素少量並簡單一點。也可以根據需求以及系統的配置來計算出,自己放入map中的資料會不會造成記憶體溢位呢?

相關文章