HashMap等數據結構簡析

ArrayList
  • 數組的實現,連續的存儲空間,隨機讀取快,增刪性能差,每次擴容都比較耗性能
LinkedList
  • 雙向鏈表實現,隨機讀取性能不如ArrayList,增刪性能好。forEach 讀取性能遠好for循環,get(index)有個查找過程:
/**
     * Returns the (non-null) Node at the specified element index.
     */
    Node<E> node(int index) {
        // assert isElementIndex(index);

        if (index < (size >> 1)) {
            Node<E> x = first;//從首鏈接開始查找
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }
HashTable

鑑於Hashtable是歷史遺留的類,現在很少有人使用它,即使我們在對線程安全有要求的場景中,也是通過使用ConcurrentHashMap來解決,而不是使用Hashtable 。這裏可以簡要的說一下原因:Hashtable使用synchronized來實現線程安全,效率不高,而ConcurrentHashMap採用鎖分段技術來實現線程安全,大大提高了效率。在多線程環境中,當A線程訪問Hashtable的put方法時,其他線程是不能訪問諸如get,clear這些方法的,但是在ConcurrentHashMap中只要保證A線程與B線程不是持有一個段鎖,是可以A線程訪問put時其他線程同時訪問get操作。

SparseArray

  • 使用int[]數組存放key,避免了HashMap中基本數據類型需要裝箱的步驟,其次不使用額外的結構體(Entry),單個元素的存儲成本下降。(變量簡寫lo:low hi:higher)

  • 爲什麼不用String做key stackoverflow

    SparseArray is only a thing when integers are the key. It’s a memory optimization that’s only possible with integer values because you need to binary search the keys. Binary searches on strings are expensive and not well defined (should ‘1’ be less than or greater than ‘a’ or ‘crazy japanese character’?), so they don’t do it.

  • 數據量不大,最好在千級以內
    key必須爲int類型,這中情況下的HashMap可以用SparseArray代替:

  • 存放key的數組是有序的(二分查找的前提條件)
    如果衝突,新值直接覆蓋原值,並且不會返回原值(HashMap會返回原值)
    如果當前要插入的 key 的索引上的值爲DELETE,直接覆蓋
    前幾步都失敗了,檢查是否需要gc()並且在該索引上插入數據
    事實上,SparseArray在進行remove()操作的時候分爲兩個步驟:

  • 刪除value — 在remove()中處理,數組不會壓縮(不像ArrayList的實現,每次都會壓縮一次數組)
    刪除key — 在gc()中處理,注意這裏不是系統的 GC,只是SparseArray 的一個方法,remove()中,將這個key指向了DELETED,這時候value失去了引用,如果沒有其它的引用,會在下一次系統內存回收的時候被幹掉。但是可以看到key仍然保存在數組中,並沒有馬上刪除,目的應該是爲了保持索引結構,同時不會頻繁壓縮數組,保證索引查詢不會錯位,那麼key什麼時候被刪除呢?當SparseArray的gc()被調用時。

  • size的大小獲取,每次都會gc一次

public int size() {
        if (mGarbage) {
            gc();
        }

        return mSize;
    }
  • 總結
    瞭解了SparseArray的實現原理,就該來總結一下它與HashMap之間來比較的優缺點

    • 優勢:
      避免了基本數據類型的裝箱操作
      不需要額外的結構體,單個元素的存儲成本更低
      數據量小的情況下,隨機訪問的效率更高

    • 有優點就一定有缺點
      插入操作需要複製數組,增刪效率降低
      數據量巨大時,複製數組成本巨大,gc()(非系統gc)成本也巨大
      數據量巨大時,查詢效率也會明顯下降

forEach
  • JAVA提供的語法糖,其原理是Iterator實現(反編譯後可以看到源碼的iterator實現)

  • ArrayList的遍歷中for比Iterator快,而LinkedList中卻是Iterator遠快於for

  • ArrayList是基於索引(index)的數組,索引在數組中搜索和讀取數據的時間複雜度是O(1),但是要增加和刪除數據卻是開銷很大的,因爲這需要重排數組中的所有數據。

    LinkedList的底層實現則是一個雙向循環帶頭節點的鏈表,因此LinkedList中插入或刪除的時間複雜度僅爲O(1),但是獲取數據的時間複雜度卻是O(n)。 明白了兩種List的區別之後,就知道,ArrayList用for循環隨機讀取的速度是很快的,因爲ArrayList的下標是明確的,讀取一個數據的時間複雜度僅爲O(1)。但LinkedList若是用for來遍歷效率很低,讀取一個數據的時間複雜度就達到了爲O(n)。而用Iterator的next()則是順着鏈表節點順序讀取數據的效率就很高了

LinkedList
  • 底層實現雙鏈表結構,隨機讀取慢,增刪快
  • 通過索引獲取值需要遍歷整個list
 Node<E> node(int index) {
        // assert isElementIndex(index);

        if (index < (size >> 1)) {//index 大於size/2
            Node<E> x = first;
            for (int i = 0; i < index; i++)//index和存儲順序一致
                x = x.next;
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }
HashMap
  • 數組+鏈表+紅黑樹(java8優化點)
  • HashMap內部是使用一個默認容量爲16的數組來存儲數據的,而數組中每一個元素卻又是一個鏈表的頭結點,所以,更準確的來說,HashMap內部存儲結構是使用哈希表的拉鍊結構(數組+鏈表),如圖:
    這種存儲數據的方法叫做拉鍊法
    這種存儲數據的方法叫做拉鍊法
  • HashMap中處理hash衝突的方法是鏈地址法
  • put實現
    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            // 空檢查
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 不存在,直接插入
            // 注意 i = (n - 1) & hash 就是取模定位數組的索引
            tab[i] = newNode(hash, key, value, null);
        else {
            // 已存在:一模一樣的值或者碰撞衝突
            Node<K,V> e; K k;
            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 {
                // 碰撞衝突,順藤摸瓜掛在鏈表的最後一個next上
                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;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 注意:if true, don't change existing value
            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;
    }
    
  • 數組長度爲什麼總是設定爲 2 的 N 次方?
  1. 取模快。
    其實就是上面爲什麼快的原因:位與取模比 % 取模要快的多。
  2. 分散平均,減少碰撞。
    這個是主要原因。
    如果二進制某位包含 0,則此位置上的數據不同對應的 hash 卻是相同,碰撞發生,而 (2^x - 1) 的二進制是 0111111…,分散非常平均,碰撞也是最少的。
  • 巧妙的取模
    加入數組長度是 n, 如果要對 hash 取模,大家可能想到的解法是:
    hash % n
    而 HashMap 採用的方法是:

    // n 是 2 的次方,所以 n - 1 的二進制01111111111…
    // hash “與” 01111111111實際上是取保留低位值,結果在 n 的範圍之內,類似於取模。
    // 還是很巧妙的。
    hash & (n - 1)

  • HashMap 迭代器實現

 HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
                //這段循環的邏輯是找到在table數組中第一個不爲null的Node
            }
**實現迭代的地方**
final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
               **//如果當前鏈的next節點已經爲空了表示該拉鍊已經讀取完了,則尋找下一個拉鍊頭節點。直到全部讀取完成**
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
LinkedHashMap
  • LinkedHashMap的迭代輸出的結果保持了插入順序,底層又雙向鏈表實現順序,LRU算法底層是基於LinkedHashMap實現的

  • final boolean accessOrder; //按元素插入順序(默認)或元素最近訪問順序(LRU)排列

  • 重寫了HashMap newNode()每次新增節點的時候都會記錄它的前後節點

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
        LinkedHashMapEntry<K,V> p =
            new LinkedHashMapEntry<K,V>(hash, key, value, e);
        linkNodeLast(p);
        return p;
    }
運算符
<< 左移運算符,num << 1,相當於num乘以2  低位補0
>> 右移運算符,num >> 1,相當於num除以2  高位補0
>>> 無符號右移,忽略符號位,空位都以0補齊
 % : 模運算 取餘
^ :   位異或 第一個操作數的的第n位於第二個操作數的第n位相反,那麼結果的第n爲也爲1,否則爲0
 & : 與運算 第一個操作數的的第n位於第二個操作數的第n位如果都是1,那麼結果的第n爲也爲1,否則爲0
 | :  或運算 第一個操作數的的第n位於第二個操作數的第n位 只要有一個是1,那麼結果的第n爲也爲1,否則爲0
 ~ : 非運算 操作數的第n位爲1,那麼結果的第n位爲0,反之,也就是取反運算(一元操作符:只操作一個數)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章