Java中集合相關知識點複習

一、List

1、ArrayList

  • ArrayList是一種變長的集合類,基於定長數組實現,使用默認構造方法初始化出來的容量是10(1.7之後都是延遲初始化,即第一次調用add方法添加元素的時候纔將elementData容量初始化爲10)
  • ArrayList允許空值和重複元素,當往ArrayList中添加的元素數量大於其底層數組容量時,其會通過擴容機制重新生成一個更大的數組。ArrayList擴容的長度是原長度的1.5倍
  • 由於ArrayList底層基於數組實現,所以其可以保證在O(1)O(1)的時間複雜度下完成隨機查找操作
  • ArrayList是非線程安全類
  • 刪除和插入需要調用System.arraycopy方法複製數組,性能差

2、LinkedList

1)、特性

LinkedList進行節點插入、刪除時間複雜度是O(1)O(1),但是隨機訪問時間複雜度是O(n)O(n)

2)、底層數據結構

LinkedList底層實現是一個雙向鏈表

    private static class Node<E> {
        //節點的值
        E item;
        
        //後繼節點
        Node<E> next;
        
        //前驅結點
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

二、Set

HashSet、TreeSet、LinkedHashSet底層都是基於其相對應的Map實現的,只使用了Map的key,保證了Set中的元素不重複

三、Map

1、HashMap

HashMap是基於鍵的hashCode值唯一標識一條數據,同時基於鍵的hashCode值進行數據的存取,因此可以快速地更新和查詢數據,但其每次遍歷的順序無法保證相同。HashMap的key和value允許爲null

HashMap是非線程安全的,即在同一時刻有多個線程同時寫HashMap時將可能導致數據的不一致。如果需要滿足線程安全的條件,則可以用Collections.synchronizedMap使HashMap具有線程安全的能力,或者使用ConcurrentHashMap

1)、底層數據結構

HashMap的數據結構如下圖所示,其內部是一個數組,數組中的每個元素都是一個單向鏈表,鏈表中的每個元素都是嵌套類Entry的實例,Entry實例包含4個屬性:key、value、hash值和用於指向單向鏈表下一個元素的next

鏈表主要是爲了解決數組中的key發生哈希衝突時,將發生碰撞的key存儲到鏈表中

在這裏插入圖片描述

在這裏插入圖片描述

當哈希衝突嚴重時,在桶上形成的鏈表會變得越來越長,這樣在查詢時的效率就會越來越低,時間複雜度爲O(n)O(n)

所以在JDK1.8中,當鏈表長度大於8且HashMap數組長度大於等於64(數組長度小於64進行擴容操作)時,會將鏈表轉換爲紅黑樹,修改爲紅黑樹之後查詢效率變爲了O(logn)O(logn)

在這裏插入圖片描述
HashMap不直接使用紅黑樹,是因爲樹節點所佔空間是普通節點的兩倍,所以只有當節點足夠的時候,纔會使用樹節點。也就是說,儘管時間複雜度上,紅黑樹比鏈表好一點,但是紅黑樹所佔的空間比較大,所以綜合考慮之下,只有在鏈表節點數太多的時候,紅黑樹佔空間大這一劣勢不太明顯的時候,纔會捨棄鏈表,使用紅黑樹

2)、put方法流程圖

在這裏插入圖片描述

3)、默認初始化大小是多少?HashMap的擴容方式?負載因子是多少?

    //默認容量
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

	//默認的負載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //用於判斷是否需要將鏈表轉換爲紅黑樹的閾值
    static final int TREEIFY_THRESHOLD = 8;

    //JDK1.7中的HashEntry修改爲Node
    transient Node<K,V>[] table;

	//HashMap中存放KV的數量
    transient int size;

	//當HashMap的size大於threshold時會執行resize操作,threshold=capacity*loadFactor
    int threshold;

	//負載因子
    final float loadFactor;

	...

給定的默認容量爲16負載因子爲0.75。Map在使用過程中不斷地往裏面存放數據,當數量達到了160.75=1216*0.75=12就需要將當前16的容量進行擴容

擴容過程分爲兩步:

  • 擴容:創建一個新的Entry空數組,長度是原數組的2倍
  • ReHash:遍歷原Entry數組,把所有的Entry重新Hash到新數組

長度擴大以後,Hash的規則也隨之改變,所以要進行ReHash操作

index = (length - 1) & hash(key)

負載因子需要在時間和空間成本上尋求一種折衷

負載因子越大,填滿的元素越多,空間利用率越高,但發生衝突的機會變大了

負載因子越小,填滿的元素越少,衝突發生的機會減小,但空間浪費了更多了,而且還會提高擴容rehash操作的次數

所以,選擇0.75作爲默認的負載因子,完全是時間和空間成本上尋求的一種折衷選擇

4)、爲什麼容量總是爲2的n次冪?這樣設計的目的是什麼?

HashMap的tableSizeFor()方法做了處理,能保證HashMap的容量永遠都是2的n次冪

因爲在使用2的冪的數字的時候,length-1的值是所有二進制位全爲1,這種情況下,index的結果等同於hashCode後幾位的值

只要輸入的hashCode本身分佈均勻,hash算法的結果就是均勻的,這樣設計的目的爲了實現均勻分佈

5)、線程不安全的原因

1)JDK1.7中擴容造成死循環分析過程

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

在對table進行擴容到newTable後,需要將原來數據轉移到newTable中,JDK1.7在轉移元素的過程中,使用的是頭插法,也就是鏈表的順序會翻轉

假設:

  • hash算法爲簡單的用key mod鏈表的大小
  • 最開始hash表size=2,key=3、7、5,則都在table[1]中
  • 然後進行resize,使size變成4

resize前狀態如下:

在這裏插入圖片描述

如果在單線程環境下,最後的結果如下:

在這裏插入圖片描述

在多線程環境下,假設有兩個線程A和B都在進行put操作。線程A在執行到transfer函數中代碼(1)處掛起

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;//(1)
                e = next;
            }
        }
    }

此時線程A中運行結果如下:

在這裏插入圖片描述

線程A掛起後,此時線程B正常執行,並完成resize操作,結果如下:

在這裏插入圖片描述

由於線程B已經執行完畢,根據Java內存模型,現在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null

此時切換到線程A上,在線程A掛起時內存中值如下:e=3,next=7,newTable[3]=null,代碼執行過程如下:

                newTable[i] = e;//newTable[3] = 3
                e = next;//e = 7

在這裏插入圖片描述

繼續循環:

        		e = 7;
                Entry<K,V> next = e.next;//next = 3
                e.next = newTable[i];//e.next = 3
                newTable[i] = e;//newTable[3] = 7
                e = next;//e = 3

在這裏插入圖片描述

再次循環:

        		e = 3;
                Entry<K,V> next = e.next;//next = null
                e.next = newTable[i];//3.next = 7
                newTable[i] = e;//newTable[3] = 3
                e = next;//e = null

e.next=7,而在上次循環中7.next=3,出現環形鏈表,並且此時e=null循環結束

在這裏插入圖片描述

2)JDK1.7中擴容造成數據丟失分析過程

在這裏插入圖片描述

線程A和線程B進行put操作,同樣線程A掛起:

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;//(1)
                e = next;
            }
        }
    }

此時線程A的運行結果如下:

在這裏插入圖片描述

此時線程B已獲得CPU時間片,並完成resize操作:

在這裏插入圖片描述

此時切換到線程A,在線程A掛起時:e=7,next=5,newTable[3]=null

執行newtable[i]=e,就將7放在了table[3]的位置,此時next=5。接着進行下一次循環:

        		e = 5;
                Entry<K,V> next = e.next;//next = null
                e.next = newTable[i];//e.next = 5
                newTable[i] = e;//newTable[1] = 5
                e = next;//e = null

將5放置在table[1]位置,此時e=null循環結束,3元素丟失,並形成環形鏈表

在這裏插入圖片描述

3)JDK1.8中的HashMap

在JDK1.8中對HashMap進行了優化,在發生哈希碰撞,不再採用頭插法方式,而是直接插入鏈表尾部,因此不會出現環形鏈表的情況

HashMap的put方法,如果沒有哈希碰撞則會直接插入元素。如果線程A和線程B同時進行put操作,剛好這兩條不同的數據hash值一樣,並且該位置數據爲null。假設一種情況,線程A進入後還未進行數據插入時掛起,而線程B正常執行,從而正常插入數據,然後線程A獲取CPU時間片,此時線程A不用再進行hash判斷了,這樣線程A會把線程B插入的數據給覆蓋,發生線程不安全

6)、哈希衝突解決方法

1)開放尋址法

開放尋址法的核心思想:如果出現了哈希衝突,就重新探測一個空閒位置,將其插入

線性探測插入操作:當往哈希表中插入數據時,如果某個數據經過哈希函數計算之後,存儲位置已經被佔用了,就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止

下圖中黃色的色塊表示空閒位置,橙色的色塊表示已經存儲了數據

在這裏插入圖片描述

當哈希表中插入的數據越來越多時,哈希衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久

2)鏈表法

在哈希表中,每個桶或者槽會對應一條鏈表,所有哈希值相同的元素都放到相同槽位對應的鏈表中

在這裏插入圖片描述

7)、爲什麼要重寫hashcode和equals方法

class Key {
    private Integer id;

    public Integer getId() {
        return id;
    }

    public Key(Integer id) {
        this.id = id;
    }
}

public class WithoutHashCode {
    public static void main(String[] args) {
        Key k1 = new Key(1);
        Key k2 = new Key(1);

        HashMap<Key, String> hashMap = new HashMap<>();
        hashMap.put(k1, "Key with id is 1");
        System.out.println(hashMap.get(k2));//null
    }
}

當向HashMap中存入k1的時候,首先會調用Key這個類的hashcode方法,計算它的hash值,隨後把k1放入hash值所指引的內存位置,在Key這個類中沒有定義hashcode方法,就會調用Object類的hashcode方法,而Object類的hashcode方法返回的hash值是對象的地址。這時用k2去拿也會計算k2的hash值到相應的位置去拿,由於k1和k2的內存地址是不一樣的,所以用k2拿不到k1的值

重寫hashcode方法僅僅能夠k1和k2計算得到的hash值相同,調用get方法的時候會到正確的位置去找,但當出現哈希衝突時,在同一個位置有可能用鏈表的形式存放衝突元素,這時候就需要用到equals方法去對比了,由於沒有重寫equals方法,它會調用Object類的equals方法,Object的equals方法判斷的是兩個對象的內存地址是不是一樣,由於k1和k2都是new出來的,k1和k2的內存地址不相同,所以這時候用k2還是達不到k1的值

什麼時候需要重寫hashcode和equals方法?

在HashMap中存放自定義的鍵時,就需要重寫自定義對象的hashcode和equals方法

2、ConcurrentHashMap

1)、JDK1.7

JDK1.7中ConcurrentHashMap的鎖分段技術可有效提高併發訪問率:ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段地存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問

在這裏插入圖片描述

ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖,在ConcurrentHashMap裏扮演着鎖的角色;HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組。Segment的結構和HashMap類似,是一種數組和鏈表結構。一個Segment裏包含一個HashEntry數組,每個HashEntry是一個鏈表結構的元素,每個Segment守護着一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先獲得與它對應的Segment鎖

ConcurrentHashMap重要的成員變量:

    //Segment數組,存放數據時首先需要定位到具體的Segment中
    final Segment<K,V>[] segments;
    transient Set<K> keySet;
    transient Set<Map.Entry<K,V>> entrySet;

Segment是ConcurrentHashMap的一個內部類,主要的組成如下:

    static final class Segment<K,V> extends ReentrantLock implements Serializable {

        private static final long serialVersionUID = 2249069246763182397L;

        static final int MAX_SCAN_RETRIES =
            Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

        //和HashMap中的HashEntry作用一樣,真正存放數據的桶
        transient volatile HashEntry<K,V>[] table;

        transient int count;

        transient int modCount;

        transient int threshold;

        final float loadFactor;

HashEntry的組成:

    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;

        HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

和HashMap非常類似,唯一的區別就是其中的核心數據如value,以及鏈表都是volatile修飾的,保證了獲取時的可見性

ConcurrentHashMap採用了分段鎖技術,其中Segment繼承於ReentrantLock。不會像HashTable那樣不管是put還是get操作都需要做同步處理,理論上ConcurrentHashMap支持CurrencyLevel(Segment數組數量)的線程併發。每當一個線程佔用鎖訪問一個Segment時,不會影響到其他的Segment

1)put方法

    public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          
             (segments, (j << SSHIFT) + SBASE)) == null) 
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

首先通過key定位到Segment,之後在對應的Segment中進行具體的put

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

雖然HashEntry中的value是用volatile關鍵詞修飾的,但是並不能保證併發的原子性,所以put操作時仍然需要加鎖處理

首先第一步的時候會嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,則利用scanAndLockForPut()自旋獲取鎖

        private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first;
            HashEntry<K,V> node = null;
            int retries = -1; 
            //嘗試自旋獲取鎖
            while (!tryLock()) {
                HashEntry<K,V> f; 
                if (retries < 0) {
                    if (e == null) {
                        if (node == null) 
                            node = new HashEntry<K,V>(hash, key, value, null);
                        retries = 0;
                    }
                    else if (key.equals(e.key))
                        retries = 0;
                    else
                        e = e.next;
                }
                //如果重試的次數達到了MAX_SCAN_RETRIES則改爲阻塞鎖獲取,保證能獲取成功
                else if (++retries > MAX_SCAN_RETRIES) {
                    lock();
                    break;
                }
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; 
                    retries = -1;
                }
            }
            return node;
        }

再結合起來看一下put的邏輯:

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            //將當前Segment中的table通過key的hashcode定位到HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        //遍歷該HashEntry,如果不爲空則判斷傳入的key和當前遍歷的key是否相等,相等則覆蓋舊的value
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        //爲空則需要新建一個HashEntry並加入到Segment中,同時會先判斷是否需要庫容弄
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                //最後解除在scanAndLockForPut()方法中獲取的鎖
                unlock();
            }
            return oldValue;
        }

2)get方法

    public V get(Object key) {
        Segment<K,V> s; 
        HashEntry<K,V>[] tab;
        int h = hash(key);
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
            for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                     (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
                 e != null; e = e.next) {
                K k;
                if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                    return e.value;
            }
        }
        return null;
	}

只需要將key通過hash之後定位到具體的Segment,再通過一次hash定位到具體的元素上

由於HashEntry中的value屬性是volatile關鍵詞修飾的,保證了內存可見性,所以每次獲取時都是最新值

ConcurrentHashMap的get方法是非常高效的,因爲整個過程都不需要加鎖

2)、JDK1.8

在這裏插入圖片描述

JDK1.8中拋棄了原有的Segment分段鎖,而採用了volatile+CAS+synchronized來保證併發安全性

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }

        public final K getKey()       { return key; }
        public final V getValue()     { return val; }
        public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
        public final String toString(){ return key + "=" + val; }
        public final V setValue(V value) {
            throw new UnsupportedOperationException();
        }

也將1.7中存放數據的HashEntry改爲了Node,但作用都是相同的

其中的val和next都用了volatile修飾,保證了可見性

1)put方法

    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        //根據key計算出hashcode	
        int hash = spread(key.hashCode());
        int binCount = 0; 
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //判斷是否需要進行初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //f爲當前key定位出的Node,如果爲空表示當前位置可以寫入數據,利用CAS嘗試寫入,失敗則自旋保證成功
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   
            }
            //如果當前位置的hashcode==MOVED==-1,則需要進行擴容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //如果都不滿足,則利用synchronized鎖寫入數據
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    //如果數量大於TREEIFY_THRESHOLD則要轉換爲紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

2)get方法

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

根據計算出來的hashcode尋址,如果就在桶上那麼直接返回值。如果是紅黑樹那就按照樹的方式獲取值。都不滿足那就按照鏈表的方式遍歷獲取值

JDK1.8在1.7的數據結構上做了大的改動,採用紅黑樹之後可以保證查詢效率O(logn)O(logn),甚至取消了ReentrantLock改爲了synchronized,這樣可以看出在新版的JDK中對synchronized優化是很到位的

3)、ConcurrentHashMap/Hashtable不允許空值的原因

主要是因爲會產生歧義,如果支持空值,在使用map.get(key)時,返回值爲null,可能有兩種情況:該key映射的值爲null,或者該key未映射到。如果是非併發映射中,可以使用map.contains(key)進行檢查,但是在併發的情況下,兩次調用之間的映射可能已經更改了

3、HashMap和Hashtable的區別

1)線程安全

Hashtable是線程安全的,HashMap不是線程安全的

Hashtable所有的元素操作都是synchronized修飾的,而HashMap並沒有

2)性能優劣

Hashtable是線程安全的,每個方法都要阻塞其他線程,所以Hashtable性能較差,HashMap性能較好,使用更廣

如果要線程安全又要保證性能,建議使用JUC包下的ConcurrentHashMap

3)NULL

Hashtable是不允許鍵或值爲null的,HashMap的鍵值則都可以爲null

Hashtable代碼片段

    public synchronized V put(K key, V value) {
        //如果value爲null,直接拋出空指針異常
        if (value == null) {
            throw new NullPointerException();
        }
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

HashMap代碼片段

    static final int hash(Object key) {
        int h;
        //key爲null做了特殊處理
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

4)實現方式

Hashtable繼承源碼

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

HashMap繼承源碼

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

Hashtable繼承了Dictionary,而HashMap繼承了HashMap

5)容量擴容

HashMap的初始容量爲16,Hashtable初始容量爲11,兩者的負載因子默認都是0.75

Hashtable代碼片段

    public Hashtable() {
        this(11, 0.75f);
    }

HashMap代碼片段

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

當現有容量大於總容量*負載因子時,HashMap擴容規則爲當前容量翻倍,Hashtable擴容規則爲當前容量翻倍+1

4、TreeMap

TreeMap基於紅黑樹實現。映射根據其鍵的自然順序進行排序,或者根據創建映射時提供的Comparator進行排序,具體取決於使用的構造方法

TreeMap因爲需要排序,進行key的compareTo()方法,所以key是不能null值,value是可以的

5、LinkedHashMap

在這裏插入圖片描述

LinkedHashMap直接繼承自HashMap,這也就說明了HashMap一切重要的概念LinkedHashMap都是擁有的,這就包括了,hash算法定位hash桶位置,哈希表由數組和單鏈表構成,並且當單鏈表長度超過8的時候轉化爲紅黑樹,擴容體系,這一切都跟HashMap一樣。除此之外,LinkedHashMap比HashMap更加強大,這體現在:

  • LinkedHashMap內部維護了一個雙向鏈表,解決了HashMap不能隨時保持遍歷順序和插入順序一致的問題
  • LinkedHashMap元素的訪問順序也提供了相關支持,也就是常說的LRU(最近最少使用)原則

在這裏插入圖片描述

圖片中紅黃箭頭代表元素添加順序,藍箭頭代表單鏈表各個元素的存儲順序。head表示雙向鏈表頭部,tail代表雙向鏈表尾部

LinkedHashMap和HashMap相比,唯一的變化是使用雙向鏈表(圖中紅黃箭頭部分)記錄了元素的添加順序,HashMap的Node節點只有next指針,LinkedHashMap對於Node節點進行了擴展:

    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }

LinkedHashMap基本存儲單元Entry繼承自HashMap.Node,並在此基礎上添加了before和after這兩個指針變量。before變量在每次添加元素的時候將會指向上一次添加的元素,而上一次添加元素的after變量將指向該次添加的元素,來形成雙向鏈接

LinkedHashMap支持兩種訪問訪問順序,這主要取決於accessOrder這個參數的值,當accessOrder爲false時按照插入順序訪問(默認),當accessOrder爲true時按照LRU Cache的機制進行訪問

  	//initialCapacity:初始化容量 loadFactor:負載因子 accessOrder:訪問順序(true代表使用LRU/false代表使用插入的順序)
	public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

當某個位置的數據被命中,通過調整該數據的位置,將其移動至尾部。新插入的元素也是直接放入尾部(尾插法)。這樣一來,最近被命中的元素就向尾部移動,那麼鏈表的頭部就是最近最少使用的元素所在的位置

LinkedHashMap中並沒有覆寫任何關於HashMap的put方法,所以調用LinkedHashMap的put方法實際上調用了父類HashMap的方法

HashMap中put方法源碼如下:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    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中會判斷是否需要初始化)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //根據當前key的hashcode定位到具體的桶中並判斷是否爲空,爲空表明沒有Hash衝突就直接在當前位置創建一個新桶即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果當前桶有值(Hash衝突),那麼就要比較當前桶中的key、key的hashcode與寫入的key是否相等,相等就賦值給e,後面統一進行賦值及返回
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果當前桶爲紅黑樹,按照紅黑樹的方式寫入數據
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //如果是個鏈表,就需要將當前的key、value封裝成一個新節點寫入當前桶的後面(採用尾插法)
                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相同時直接退出遍歷
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果e!=null就相當於存在相同的key,那就需要將值覆蓋
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //最後判斷是否需要進行擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

在putVal方法中如果map中存在相同的key時,會調用void afterNodeAccess(Node<K,V> p)方法,該方法在HashMap中是空實現,但是在LinkedHasMap中重寫了該方法實現了將被訪問節點移動到鏈表最後

  	//將被訪問節點移動到鏈表最後
	void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        //accessOrder爲true時才支持LRU Cache
        if (accessOrder && (last = tail) != e) {
            //三個臨時變量:p爲當前被訪問節點,b爲其前驅結點,a爲其後繼節點
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            //訪問節點的後驅節點置爲null
            p.after = null;
            //如果訪問節點的前驅爲null,則說明p=head,由於這時p要移動到鏈表最後,所以a設置爲head
            if (b == null)
                head = a;
            //否則b的後繼設置爲a
            else
                b.after = a;
            
            //如果p不爲尾節點,那麼將a的前驅設置爲b   
            if (a != null)
                a.before = b;
            else
                last = b;
            
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            //將p接在雙向鏈表的最後
            tail = p;
            ++modCount;
        }
    }

舉個例子,比如該次操作訪問的是13這個節點,而14是其後驅,11是其前驅,且tail=14。在通過get訪問13節點後,13變成了tail節點,而14變成了其前驅節點,相應的14的前驅變成11,11的後驅變成了14,14的後驅變成了13

在這裏插入圖片描述

而在putVal方法的最後會調用一個void afterNodeInsertion(boolean evict)方法,,該方法在HashMap中是空實現,但是在LinkedHasMap中重寫了該方法實現了刪除頭節點(最近最少使用的元素)

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {//(1)
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

代碼(1)處:evict在put方法調用putVal時傳參即爲true,所以當map不爲空且removeEldestEntry返回true時就會刪除頭節點,但是在LinkedHasMap中removeEldestEntry方法始終返回true,所以如果要基於LinkedHashMap實現LRU則需要重寫removeEldestEntry方法,當map的size大於初始化容量時返回true

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