Java集合框架(黃圖是思路)

你怎麼敢的呀發表於2020-12-04

先看紅線

 

Vector 和 Stack

Stack:繼承Vector,基於動態陣列實現的一個執行緒安全的棧;

     有同步          

public synchronized E peek();

返回棧頂的值;

public E push(E item);

入棧操作;

public synchronized E pop();

出棧操作;

Vector:隨機訪問速度快,插入和移除效能較差(陣列的特點);支援null元素;有順序;元素可以重複;執行緒安全;

 

Vector 和 ArrayList 

Vector與ArrayList基本是一致的,不同的是Vector是執行緒安全的,會在可能出現執行緒安全的方法前面加上synchronized關鍵字;

 

Vector 和 AbstractList

Vector 繼承於 AbstractList

 

AbstractList 和 AbstractCollection 和 List

 

 

AbstractCollection  和 Collection

 

 

 

ArrayList 和 LinkedList的區別?

常考點:

ArrayList :底層是基於動態陣列(可以動態的擴容),根據下標隨機訪問陣列的元素的效率高,向陣列尾部新增元素的效率高

但是 刪除陣列中的元素以及向陣列中間新增資料效率低,因為需要移動陣列

ArrayList的擴容機制:

default ca'pacity 預設容量是10

可以自己定義 剛開始的ArrayList的容量大小

直接呼叫ArrayList的構造方法建立的是空的集合

擴容的新的容量是原來的1.5倍

 

LinkedList 

LinkedList的底層資料結構是雙向連結串列

LinkedList繼承於AbstractSequentialList,實現List介面,因此也可以對其進行佇列操作,它也實現了Deque介面,所以LinkedList也可當做雙端佇列使用,還有LinkedList是非同步的。

 

從原始碼上可以非常清楚的瞭解LinkedList加入元素是直接放在連結串列尾的,主要點構成雙向連結串列

由於雙向連結串列,順序訪問效率高,而隨機訪問效率較低。

 

 

HashSet和TreeSet

HashSet繼承於  AbstractSet

Set的概念:

Set可以理解為集合,非常類似資料概念中的集合,集合三大特徵:1、確定性;2、互異性;3、無序性,因此Set實現類也有類似的特徵。

HashSet和HashMap的關係?

HashSet內部使用HashMap來儲存資料,資料儲存在HashMap的key中,value都是同一個預設值:

 

LinkedHashSet和HashSet的關係?

HashSet和HashMap都不保證順序,LinkedHashSet能保證順序(引出下一個問題)。

LinkedHashSet繼承於HashSet

 

為什麼LinkedHashSet是有序的?

LinkedHashSet是一個雜湊表和連結串列的結合,而且還是一個雙向連結串列。LinedHashSet內部維護了個LinkedList來維護元素的順序

 

HashSet和TreeSet的關係?(一個無序,一個有序)

當向HashSet集合中存入一個元素時,HashSet會呼叫該物件的hashCode()方法來得到該物件的hashCode值,然後根據 hashCode值來決定該物件在HashSet中儲存位置。所以儲存位置是隨機的(跟HashMap的儲存原理一樣)

當再次向HashSet集合中插入資料時,先根據hashcode計算出儲存的位置,然後根據equals判斷兩個物件是否是相等的

HashSet可以儲存null值,由於兩個null值的hashcode(hashcode根據內容求值)一致,呼叫equals也一樣,判定是同一個資料,所以只能儲存一個null值

 

TreeSet的兩個特點:Tree 表示有序,Set表示唯一

TreeSet在使用二叉樹儲存物件,物件必須要實現compareTo方法,判斷物件是不是同一個就是看該方法返回的值是否為0

 

SortedSet 和 TreeSet

SortedSet 繼承於Set,同時又提供了一些功能增強的方法,比如 comparator 從而實現了元素的有序性。

SortedSet 插入到有序集中的所有元素必須實現Comparable介面(或者被指定的Comparator接受),並且所有這些元素必須是可相互比較的,比如:e1.compareTo(e2)

TreeSet是SortedSet的唯一實現類,紅黑樹實現,樹形結構,它的本質可以理解為是有序,無重複的元素的集合。

上面提到了Comparable和Comparator

Comparable和Comparator的區別?

Comaarable是類可以實現的介面,實現了該介面,就必須實現compareTo方法   

如果類沒有實現Comparable介面,又想對兩個類進行比較。或者對實現類實現了Comparble介面,但是對compareTo方法裡的演算法不滿意,那麼可以實現Comparator介面,自定義一個比較器,寫比較演算法

實現Comparable介面的方式比實現Comparator介面的耦合性 要強一些,如果要修改比較演算法,要修改Comparable介面的實現類,而實現Comparator的類是在外部進行比較的,不需要對實現類有任何修 改。

 

 

LinkedHashMap 與 HashMap

LinkedHashMap 繼承於HashMap

 

 

LinkedHashMap的構造方法有三種:

       

 

檢視LinedHashMap原始碼之前,建議先檢視HashMap的原始碼

HashMap的基本原理:

HashMap 使用了拉鍊式的雜湊演算法,並在jdk1.8中引入了紅黑樹優化過長的連結串列

ps:  為什麼叫做拉鍊式的演算法,仔細看下面這張圖,認真想

發現裡面定義了很多的常量

 : 當前 HashMap 所能容納鍵值對數量的最大值,超過這個值,則需擴容

 :預設初始容量 16 

 :最大的容量

  預設的載入因子 0.75

 連結串列轉化成二叉樹的閾值 : 8

:二叉樹轉成連結串列的閾值 :6

 :轉化成二叉樹的最小容量(桶)為 64

預設情況下,HashMap 初始容量是16,負載因子為 0.75。這裡並沒有預設閾值,原因是閾值可由容量乘上負載因子計算而來(註釋中有說明),即threshold = capacity * loadFactor

HashMap的查詢原始碼:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // 1. 定位鍵值對所在桶的位置
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                // 2. 如果 first 是 TreeNode 型別,則呼叫黑紅樹查詢方法    
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 3. 對連結串列進行查詢
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

查詢的核心邏輯是封裝在 getNode 方法中的,getNode 方法原始碼有一些註釋,應該不難看懂。我們先來看看查詢過程的第一步 - 確定桶位置,其實現程式碼如下:

n -1 與 hash 的運算的結果等價於 對 length取餘,這樣就可以找到桶的位置

舉個例子說明一下吧,假設 hash = 185,n = 16。計算過程示意圖如下:

還有一個計算 hash 的方法。這個方法原始碼如下:

看這個方法的邏輯好像是通過位運算重新計算 hash,那麼這裡為什麼要這樣做呢?為什麼不直接用鍵的 hashCode 方法產生的 hash 呢?

這樣做有兩個好處,我來簡單解釋一下。我們再看一下上面求餘的計算圖,圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,由於 n 比較小,hash 只有低4位參與了計算,高位的計算可以認為是無效的。這樣導致了計算結果只與低位資訊有關,高位資料沒發揮作用。為了處理這個缺陷,我們可以上圖中的 hash 高4位資料與低4位資料進行異或運算,即 hash ^ (hash >>> 4)。通過這種方式,讓高位資料與低位資料進行異或,以此加大低位資訊的隨機性,變相的讓高位資料參與到計算中。此時的計算過程如下:

在 Java 中,hashCode 方法產生的 hash 是 int 型別,32 位寬。前16位為高位,後16位為低位,所以要右移16位。上面所說的是重新計算 hash 的一個好處,除此之外,重新計算 hash 的另一個好處是可以增加 hash 的複雜度。當我們覆寫 hashCode 方法時,可能會寫出分佈性不佳的 hashCode 方法,進而導致 hash 的衝突率比較高。通過移位和異或運算,可以讓 hash 變得更復雜,進而影響 hash 的分佈性。這也就是為什麼 HashMap 不直接使用鍵物件原始 hash 的原因了。

HashMap的插入邏輯

插入操作的原始碼

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;
    // 初始化桶陣列 table,table 被延遲到插入新資料時再進行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果桶中不包含鍵值對節點引用,則將新鍵值對節點的引用存入桶中即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果鍵的值以及節點 hash 等於連結串列中的第一個鍵值對節點時,則將 e 指向該鍵值對
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用型別為 TreeNode,則呼叫紅黑樹的插入方法
        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;
                }
                
                // 條件為 true,表示當前連結串列包含要插入的鍵值對,終止遍歷
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判斷要插入的鍵值對是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 鍵值對數量超過閾值時,則進行擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

putVal 方法主要做了這麼幾件事情:

  1. 當桶陣列 table 為空時,通過擴容的方式初始化 table
  2. 查詢要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
  3. 如果不存在,則將鍵值對鏈入連結串列中,並根據連結串列長度決定是否將連結串列轉為紅黑樹
  4. 判斷鍵值對數量是否大於閾值,大於的話則進行擴容操作

 

jdk 1.7版本採用的是頭插法,在多個執行緒執行transfer方法的時候,會導致迴圈的連結串列,在get資料的時候,就會進入一個死迴圈

jdk 1.8版本採用的尾插法,然後引入了紅黑樹這麼一個概念

 

 

 

擴容的操作

 

從上圖可以發現,重新對映後,兩條連結串列中的節點順序並未發生變化,還是保持了擴容前的順序。以上就是 JDK 1.8 中 HashMap 擴容的程式碼講解。另外再補充一下,JDK 1.8 版本下 HashMap 擴容效率要高於之前版本。如果大家看過 JDK 1.7 的原始碼會發現,JDK 1.7 為了防止因 hash 碰撞引發的拒絕服務攻擊,在計算 hash 過程中引入隨機種子。以增強 hash 的隨機性,使得鍵值對均勻分佈在桶陣列中。在擴容過程中,相關方法會根據容量判斷是否需要生成新的隨機種子,並重新計算所有節點的 hash。而在 JDK 1.8 中,則通過引入紅黑樹替代了該種方式。從而避免了多次計算 hash 的操作,提高了擴容效率。

 

連結串列樹化、紅黑樹鏈化與拆分

 

當桶陣列容量小於該值MIN_TREEIFY_CAPACITY 時,優先進行擴容,而不是樹化

static final int MIN_TREEIFY_CAPACITY = 64;

/**
 * 將普通節點連結串列轉換成樹形節點連結串列
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶陣列容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 為頭節點(head),tl 為尾節點(tail)
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 將普通節點替換成樹形節點
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);  // 將普通連結串列轉成由樹形節點連結串列
        if ((tab[index] = hd) != null)
            // 將樹形連結串列轉換成紅黑樹
            hd.treeify(tab);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在擴容過程中,樹化要滿足兩個條件:

  1. 連結串列長度大於等於 TREEIFY_THRESHOLD
  2. 桶陣列容量大於等於 MIN_TREEIFY_CAPACITY

第一個條件比較好理解,這裡就不說了。這裡來說說加入第二個條件的原因,個人覺得原因如下:

當桶陣列容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致連結串列長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶陣列容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新對映。所以在桶容量比較小的情況下,將長連結串列轉成紅黑樹是一件吃力不討好的事。

 

再來檢視HashMap的構造方法,一共有四個:

只說最常用的一個,我們都直接new HashMap(),它會預設設定載入因子為0.75

 

其他HashMap的細節

 

transient 所修飾 table 變數,HashMap 不序列化 table 的原因?

transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變數不會被預設的序列化機制序列化。我們再回到原始碼中,考慮一個問題:桶陣列 table 是 HashMap 底層重要的資料結構,不序列化的話,別人還怎麼還原呢?

這裡簡單說明一下吧,HashMap 並沒有使用預設的序列化機制,而是通過實現readObject/writeObject兩個方法自定義了序列化的內容。這樣做是有原因的,試問一句,HashMap 中儲存的內容是什麼?不用說,大家也知道是鍵值對所以只要我們把鍵值對序列化了,我們就可以根據鍵值對資料重建 HashMap。有的朋友可能會想,序列化 table 不是可以一步到位,後面直接還原不就行了嗎?這樣一想,倒也是合理。但序列化 talbe 存在著兩個問題:

  1. table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間
  2. 同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。

以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終呼叫 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實現,產生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平臺下可能會產生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。

綜上所述,大家應該能明白 HashMap 不序列化 table 的原因了。

 

hashcode為什麼和jvm有關?

hashcode的值是物件在記憶體的地址算出來的,   不同的程式執行同一個物件,因為記憶體地址不一樣,生成的hashcode當然不一樣。

 

 

TreeMap和SortedMap

 TreeMap 是 SortedMap 介面的基於紅黑樹的實現。此類保證了對映按照升序順序排列關鍵字, 根據使用的構造方法不同,可能會按照鍵的類的自然順序進行排序(參見 Comparable), 或者按照建立時所提供的比較器進行排序。

 

 

 

 

 

 

 

 

 

 

 

相關文章