Java集合:List、Set和Map需要注意的5個問題

前言

Java集合中的List、Set和Map作爲Java集合食物鏈的頂級,可謂是各有千秋。本文將對於List、Set和Map之間的聯繫與區別進行介紹,以及這三者衍生出來的問題進行介紹(若無特地說明,jdk版本皆爲1.8):

  • List、Set和Map的聯繫和區別是什麼?
  • List、Set和Map的使用場景有哪些?
  • List與Set之間的怎麼轉換?
  • Set是怎麼保證元素不重複?
  • 如何在遍歷的同時刪除ArrayList中的元素?

1. List、Set和Map的聯繫和區別

在這裏插入圖片描述

List和Set是Collection的實現類,而Map與Collection是屬於“同級“。
在這裏插入圖片描述

List

在這裏插入圖片描述

List的特性:

  • List允許插入重複元素
  • List允許插入多個null元素
  • List作爲有序集合,保證了元素按照插入的順序進行排列;
  • List提供ListIterator迭代器,可以提供雙向訪問的功能;
  • List常用的實現類有:可隨意訪問元素的ArrayList、應用於增刪頻繁的LinkedList、利用synchronized關鍵字實現線程安全的Vector等。

Set

在這裏插入圖片描述

Set的特性:

  • Set不包含重複元素
  • Set只允許一個null元素的存在;
  • Set接口較爲流行的實現類有:基於HashMap實現的HashSet、實現SortedSet接口且能更具compare()和compareTo()的定義進行排序的TreeSet等。

Map

在這裏插入圖片描述

Map的特性:

  • 存儲結構是鍵值對,一個鍵對應一個值;
  • 不允許包含重複的鍵,Map可能會持有相同的值對象,但鍵對象必須是唯一的;
  • 在Map中可以有多個null值,但最多只能有一個null鍵
  • Map不是Collection的子接口或實現類**,Map是跟Collection“同級”的接口**;
  • Map中比較流行的實現類是採用散列函數的HashMap、以及利用紅黑樹實現排序的TreeMap等。

2. List、Set和Map的使用場景

上文我們介紹完了List、Set和Map之間的聯繫和區別,接下來我們來看下這三者在使用場景上的差異。

List

如果經常使用索引來訪問元素,或者是需要能夠按照插入順序進行存儲,List會是不錯的選擇。

  • 需要使用索引來訪問容器的元素,ArrayList可以提供更快速的訪問(底層是數組實現);
  • 需要經常增刪元素,LinkedList則會是最佳的選擇(底層是鏈表實現);
  • 數據量不大,並且有線程安全(synchronized關鍵字)的要求,可以選擇Vector
  • 有線程安全(ReentrantLock實現)和性能的要求,讀多寫少的情況,CopyOnWriteArrayList會是更好的選擇。

Set

想要保證插入元素的唯一性,可以選擇Set的實現類。

  • 需要快速查詢元素,可以使用HashSet(採用散列函數);
  • 如果有排序元素的需要,可以使用TreeSet(採用紅黑樹的樹結構排序元素);
  • 急需要加快查詢速度,還需要按插入順序來存儲數據,LinkedHashSet是最好的選擇(採用散列函數的同時,還使用鏈表維護元素的次序)。

Map

如果需要按鍵值對<key, value>的形式進行數據存儲,那麼Map是正確的選擇。

  • 需要快速查詢鍵值元素,可以使用HashMap(採用散列函數);
  • 如果需要將鍵進行排序,可以使用TreeMap(按照紅黑樹對鍵進行排序);
  • 在存儲數據少,不允許有null值,又有線程安全(synchronized關鍵字)的要求,可以選擇Hashtable(父類是Dictionary);
  • 如果需要線程安全(Node+CAS+Synchronized),且有數據量和性能要求,ConcurrentHashMap是最佳的選擇。

3. List與Set之間的轉換

因爲List和Set都實現了Collection接口中的addAll(Collection<? extends E> c)方法,而且List和Set也提供了Collection<? extends E> c爲參數的構造函數,所以可以採用構造函數的形式,完成List和Set的互相轉換。

addAll(Collection<? extends E> c)方法
public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c)
            if (add(e))
                modified = true;
        return modified;
    }

以Set接口的實現類HashSet爲例,其提供了Collection<? extends E> c爲參數的構造函數

public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }

以List接口的實現類ArrayList爲例,也可以看到它提供了Collection<? extends E> c爲參數的構造函數

public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        .......
    }

所以我們可以得到Set與List之間的轉換方式

Set<Integer> set = new HashSet<>(list);  //List轉Set
List<Integer> list = new ArrayList<>(set);  //Set轉List

4. Set是怎麼保證元素不重複?

我們以Set接口最流行的實現類HashSet爲例,對Set保證元素不重複的原因進行介紹。

private transient HashMap<E,Object> map;

public boolean add(E e) {
    //如果return true,則表示不包含此元素
	return map.put(e, PRESENT)==null;
}

從上可知,HashSet是依賴HashMap得以實現,其中添加的元素作爲HashMap的鍵來存儲。所以接下來就是在介紹“HashMap是怎麼保證不允許有相同的鍵存在”了。

public V put(K key, V value) {
    //倒數第二個參數爲false,表示允許舊值替換
    //最後一個參數爲true,表示HashMap不處於創建模式
	return putVal(hash(key), key, value, false, true);
}

在這裏,我們可以看到在進行putVal()方法之前,會將key代入hash()方法中進行散列

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果哈希表爲空,調用resize()方法創建一個哈希表,並用n記錄哈希表的長度
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //如果指定參數hash(key的hashCode()值)在表中沒有對應的桶,即沒有碰撞
        //(n-1)&hash計算key將被放置的槽位
        //(n-1)&hash本質上是hash%n,只是位運算更快
        if ((p = tab[i = (n - 1) & hash]) == null)
            //如果沒有碰撞,直接將鍵值對插入到map中即可
            tab[i] = newNode(hash, key, value, null);
        else {  //如果桶中已經存在了元素
            Node<K,V> e; K k;
            //比較桶中的第一個元素(數組中的結點)的hash值、key是否相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //如果相等,則將第一個元素p用e來記錄
                e = p;
            else if (p instanceof TreeNode)  //當前桶中無該鍵值對,且桶的結構爲紅黑樹,則按照紅黑樹結構的規則插入元素
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {  //如果桶中無該鍵值對,且桶的結構爲鏈表,則按照鏈表結構將元素插入到尾部
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {  //遍歷到鏈表尾部
                        p.next = newNode(hash, key, value, null);
                        //檢查鏈表長度是否達到閾值,達到則將該槽位的節點組織形式,轉化爲紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //鏈表節點中的元素<key, value>與put操作控制的元素<key, value>相同時,不做重複操作,直接跳出程序
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 如果put操作控制的元素的key和hashCode,與已經插入的元素相等時,執行以下操作
            if (e != null) { // existing mapping for key
                // oldValue記錄e的value
                V oldValue = e.value;
                // onlyIfAbsent爲false,或舊值爲null時,允許替換舊值,否則無需替換
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                //訪問後回調
                afterNodeAccess(e);
                //返回舊值
                return oldValue;
            }
        }
        // 更新結構化修改信息
        ++modCount;
        // 鍵值對數目如果超過閾值時,執行resize()方法
        if (++size > threshold)
            resize();
        // 插入後回調
        afterNodeInsertion(evict);
        return null;
    }

從以上源碼中我們可以看出,將一個鍵值對<key, value>放入HashMap時,首先會根據key的hashCode()返回值和HashMap的長度決定該元素的存儲位置,如果兩個key的hash值相同,那麼它們的存儲位置相同。如果這兩個key的equals比較返回true,那麼新添加的元素newValue就會覆蓋原來的元素oldValue,key不會被覆蓋。

當HashSet中的add()方法裏,map.put(e, PRESENT) == null爲false時,HashSet添加元素失敗。所以如果向HashSet中添加一個已經存在的元素,新添加的元素不會覆蓋原來已有的元素。

5. 如何在遍歷的同時刪除ArrayList中的元素?

平時我們可能會覺得遍歷ArrayList並刪除其中元素是一件很簡單的事情,但其實這個操作很容易出bug,接下來我們一起看下怎麼樣繞過這些坑。

從後向前遍歷元素

我們先從前向後遍歷的同時,進行刪除元素:

public static void main(String[] args){
        List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(3);
        list.add(4);
        for(int i=0; i<list.size()-1; i++){
            if(list.get(i) == 3){
                list.remove(new Integer(3));
            }
        }
        System.out.println(list);
    }

運行結果爲:

[1, 2, 3, 4]

造成這個現象的原因,在【Java集合】ArrayList的使用及原理中筆者稍有提及。在於ArrayList執行remove()操作時,將既定元素刪除時還把該元素後的所有元素向前移動一位。這就導致了在遍歷[1,2,3,3,4]時,刪除前一個元素“3”後,將其後元素向前移動一位,因下標[2]已經被遍歷過了,所以就遺漏了第二個“3”。

對於這個問題,我們只需要換個遍歷的角度即可——從後往前遍歷:

for(int i=list.size()-1; i>=0; i--){
    if(list.get(i) == 3){
    	list.remove(new Integer(3));
    }
}

運行結果爲:

[1, 2, 4]

從後往前遍歷,在刪除某一元素之後,也不用擔心在遍歷過程中會遺漏元素。

Iterator.remove()

除了上述遍歷方法,還有一種遍歷方式是我們經常使用的——for-each遍歷:

for(Integer i : list){
    if(i == 3){
    	list.remove(new Integer(3));
    }
}

運行結果:

Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	...

我們知道,for-each的遍歷方式其實是Iterator、hashNext()、next()的複合簡化版。當點開ArrayList.checkForComodification()方法可以看到:

private class Itr implements Iterator<E> {
    ......
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
	}
}

這裏的modCount是ArrayList的,而expectedModCount是Itr的,所以其實出錯的地方在於,運行ArrayList.remove()方法時改變了modCount,這就打破了原本modCount == expectedModCount之間和平友好的關係,導致報出併發修改異常。

所以在使用迭代器迭代時(顯示或for-each的隱式)不要使用ArrayList.remove(),改爲使用Iterator.remove()即可

Iterator<Integer> i = list.iterator();
while(i.hasNext()){
Integer integer = i.next();
    if(integer == 3){
    i.remove();
    }
}

結語

本來昨天就已經寫好了,然而電腦一卡,啥都沒了,只能重寫…

如果本文對你有幫助,請給一個贊吧,這會是我最大的動力~

參考資料:

List、Set、Map的區別

ArrayList循環遍歷並刪除元素的常見陷阱

Java中Set集合是如何實現添加元素保證不重複的?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章