建議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中的資料會不會造成記憶體溢位呢?