Android 面試準備進行曲(數據結構 Map /List)v1.1

Java數據結構 之 HashMap 重溫學習

個人能力有限,暫時不整理溫習 紅黑二叉樹
該篇文章主要講述 HashMap 、ConcurrentHashMap 部分區別(從擴容消耗內存方面 介紹下ArrayMap),在文章末尾會簡單的提到 List 部分的面試知識點
update time 2019年12月09日13:33:53

1. HashMap

參考文章

這裏的HashMap 主要針對 JDK 1.8 版本,JDK1.7 沒有引入紅黑樹概念

HashMap 實際上是一個“鏈表散列”的數據結構,即數組和鏈表的結合體。它是基於哈希表的 Map 接口的非同步實現。

數組:存儲區間連續,佔用內存嚴重,尋址容易,插入刪除困難;
鏈表:存儲區間離散,佔用內存比較寬鬆,尋址困難,插入刪除容易;
Hashmap 綜合應用了這兩種數據結構,實現了尋址容易,插入刪除也容易。
效果圖
在這裏插入圖片描述

主要參數說明

/** 
   * 主要參數 同  JDK 1.7 
   * 即:容量、加載因子、擴容閾值(要求、範圍均相同)
   */

  // 1. 容量(capacity): 必須是2的冪 & <最大容量(2的30次方)
  static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默認容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十進制的2^4=16
  static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 =  2的30次方(若傳入的容量過大,將被最大值替換)

  // 2. 加載因子(Load factor):HashMap在其容量自動增加前可達到多滿的一種尺度 
  final float loadFactor; // 實際加載因子
  static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默認加載因子 = 0.75

  // 3. 擴容閾值(threshold):當哈希表的大小 ≥ 擴容閾值時,就會擴容哈希表(即擴充HashMap的容量) 
  // a. 擴容 = 對哈希表進行resize操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數
  // b. 擴容閾值 = 容量 x 加載因子
  int threshold;

  // 4. 其他
  transient Node<K,V>[] table;  // 存儲數據的Node類型 數組,長度 = 2的冪;數組的每個元素 = 1個單鏈表
  transient int size;// HashMap的大小,即 HashMap中存儲的鍵值對的數量
 

  /** 
   * 與紅黑樹相關的參數
   */
   // 1. 桶的樹化閾值:即 鏈表轉成紅黑樹的閾值,在存儲數據時,當鏈表長度 > 該值時,則將鏈表轉換成紅黑樹
   static final int TREEIFY_THRESHOLD = 8; 
   // 2. 桶的鏈表還原閾值:即 紅黑樹轉爲鏈表的閾值,當在擴容(resize())時(此時HashMap的數據存儲位置會重新計算),在重新計算存儲位置後,當原有的紅黑樹內數量 < 6時,則將 紅黑樹轉換成鏈表
   static final int UNTREEIFY_THRESHOLD = 6;
   // 3. 最小樹形化容量閾值:即 當哈希表中的容量 > 該值時,才允許樹形化鏈表 (即 將鏈表 轉換成紅黑樹)
   // 否則,若桶內元素太多時,則直接擴容,而不是樹形化
   // 爲了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD
    static final int MIN_TREEIFY_CAPACITY = 64;

核心參數圖解
image

2. hash() 方法

hash方法其實在java8中也做了優化:只向右移動一次使高位移動向低位,和hash值做 異或處理,使高位添加到運算 但是由於計算出來的值太大,hashmap初始大小隻有16,所以要和(長度-1)做一次並運算,保留長度內的數據以此來達到降低key衝突的百分比
image
測試的運算過程
image

3. HashMap 的put方法

流程圖如下
(唉 那裏都有二叉樹麼)

在這裏插入圖片描述

  1. 判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容,設置容量 闕值等初始化工作,(注意 若哈希表的數組tab爲空,則 通過resize() 創建,所以,初始化哈希表的時機 = 第1次調用put函數時,即調用resize() 初始化創建)
  2. 根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向第六步,如果table[i]不爲空,轉向第三部;
  3. 判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向第四步,這裏的相同指的是hashCode以及equals;
  4. 判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向第五步;
  5. 遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
  6. 插入成功後,判斷實際存在的鍵值對數量size是否超過了最大容量threshold,如果超過,進行擴容,然後結束整個流程。

4. HashMap擴容

hashmap 添加的時候 如果長度沒有大於8,保持鏈表插入 ,插入後判斷是否轉換爲紅黑樹(前提Hash表的數量已經超過數組最小),如果依然爲鏈表,判斷長度是否大於闕值,如果擴容則直接長度 *2 ,至於擴容後的位置則通過計算方式來計算

部分源碼:

   /**
     * resize()
     * 該函數有2種使用情況:1.初始化哈希表 2.當前數組容量過小,需擴容
     */
   final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; // 擴容前的數組(當前數組)
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 擴容前的數組的容量 = 長度
    int oldThr = threshold;// 擴容前的數組的閾值
    int newCap, newThr = 0;

    // 針對情況2:若擴容前的數組容量超過最大值,則不再擴充
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }

        // 針對情況2:若無超過最大值,就擴充爲原來的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 通過右移擴充2倍
    }

    // 針對情況1:初始化哈希表(採用指定 or 默認值)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;

    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 計算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    if (oldTab != null) {
        // 把每個bucket都移動到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;

                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                else { // 鏈表優化重hash的代碼塊
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引 + oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket裏
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket裏
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}


通過圖表說明:

image

擴容位置算法示意圖

image
相對與 JDK 1.7在計算新元素的存儲位置有很大區別:JDK 1.7在擴容後,都需按照原來方法重新計算,即
hashCode()->> 擾動處理 ->>(h & length-1)),JDK 1.8 做了部分的優化,提高了擴容效率。

hash() 方法講解 及擴容參考文章

讀完以上內容 我們知道HashMap中默認的存儲大小就是一個容量爲16的數組,所以當我們創建出一個HashMap對象時,即使裏面沒有任何元素,也要分別一塊內存空間給它,而且,我們再不斷的向HashMap裏put數據時,當達到一定的容量限制時(這個容量滿足這樣的一個關係時候將會擴容:HashMap中的數據量>容量*加載因子,而HashMap中默認的加載因子是0.75),HashMap的空間將會擴大,而且擴大後新的空間一定是原來的2倍,所以我們建議在知道數據大小的時候,初始化HashMap時就設置好數據容量,以免在擴容過程中 不斷地Hash計算來消耗內存。畢竟Android 中內存十分的重要

上段文字其實也算一個引子,推薦 使用 parseArray 和 ArrayMap 來代替HashMap

簡單介紹:ArrayMap是一個<key,value>映射的數據結構,它設計上更多的是考慮內存的優化,內部是使用兩個數組進行數據存儲,一個數組記錄key的hash值,另外一個數組記錄Value值,它和SparseArray一樣,也會對key使用二分法進行從小到大排序,在添加、刪除、查找數據的時候都是先使用二分查找法得到相應的index,然後通過index來進行添加、查找、刪除等操作,所以,應用場景和SparseArray的一樣,如果在數據量比較大的情況下,那麼它的性能將退化至少50%。

更加詳細的 源碼級介紹:
ArrayMap詳解

2 HashMap其他 可能面試的問題

大體知識點總覽

image

2.1 哈希表解決Hash 衝突

image

2.2 鍵-值(key-value)都允許爲空、線程不安全、不保證有序、存儲位置隨時間變化

image

HashMap 線程不安全的其中一個重要原因:多線程下容易出現resize()死循環
本質 : 併發 執行 put()操作導致觸發 擴容resize(),轉移數據操作 = 按舊鏈表的正序遍歷鏈表、在新鏈表的頭部依次插入,即在轉移數據、擴容後,容易出現鏈表逆序的情況,從而導致 環形鏈表,使得在獲取數據遍歷鏈表時形成死循環.

由於 JDK 1.8 轉移數據操作 : 按舊鏈表的正序遍歷鏈表、在新鏈表的尾部依次插入,所以不會出現鏈表 逆序、倒置的情況,故不容易出現環形鏈表的情況。(但是還是不建議 多線程高併發中使用Hashmap,官方推薦使用 ConcurrentHashMap)

2.3 爲什麼 key 多推薦使用 String、Integer

image

2.4 HashMap 中的 key若 Object類型, 則需實現哪些方法?

String Integer 中都默認實現了 hashcode() 和 equals() 方法

image

至於其

  1. HashMap 和 ArrayMap 的區別(數組擴容方式 )
    ArrayMap :通過 Hash/ (key/value)的存儲方式優化數組空間,一種獨特的方式,能夠重複的利用因爲數據擴容而遺留下來的數組空間

    HashMap : 初始值16個長度,每次擴容的時候,直接申請雙倍的數組空間,尾插法,添加到數組/鏈表/紅黑樹子節點.
    ArrayMap : 每次擴容的時候,如果size長度大於8時申請size*1.5個長度,大於4小於8時申請8個,小於4時申請4個。

    HashMap 和 ArrayMap 的區別 - 參考文章

  2. HashMap 和 LinkedHashMap的區別
    LinkedHashMap - 參考文章

  3. 深入LinkedHashMap 瞭解 LRU 緩存 (個人喫過很多虧)
    LinkedHashMap 及 LRU 緩存 - 參考文章

HashMap 和 LinkedHashMap 簡單區別:LinkedHashMap 是HashMap的子類,雙向鏈表保存了記錄的插入順序,在遍歷LinkedHashMap時,先得到的記錄是先插入的.也可以在構造時用帶參數,按照應用次數排序。在遍歷的時候會比HashMap慢,不過有種情況例外,當HashMap容量很大,實際數據較少時,遍歷起來可能會比 LinkedHashMap慢,因爲LinkedHashMap的遍歷速度只和實際數據有關,和容量無關,而HashMap的遍歷速度和他的容量有關。

  1. ConcurrentHashMap 瞭解嗎?(多併發)
    ConcurrentHashMap 參考文章

個人對ConcurrentHashMap 多線程併發中做的工作:在添加元素時候,採用synchronized來保證線程安全,然後計算size的時候採用CAS操作進行計算。在擴容期間通過給不同的線程設置不同的下表索引進行擴容操作,就是不同的線程,操作的數組分段不一樣,同時利用synchronized同步鎖鎖住操作的節點,保證了線程安全。

ArrayList、LinkedList、Vector 的區別

ArrayList、LinkedList、Vector 數據結構區別

ArrayList和Vector是按照順序將元素存儲(從爲0開始),刪除元素時,刪除操作完成後,需要使部分元素移位,默認的初始容量都是10.

ArrayList和Vector是基於數組實現的,LinkedList是基於雙向鏈表實現的(含有頭結點)。所以 ArrayList和Vector 更加適合於隨機訪問數據,LinkedList 由於基於鏈表實現,在插入和刪除的時候只需要修改指針指向地址就可以了,所以更適合插入和刪除操作。(不過由於LinkedList雙向鏈表,支持雙向查找。查找前會根據指定位置index判斷是在鏈表的前半段還是後半段,從而決定是從前往後找或是從後往前找,提升查找效率。)

ArrayList、LinkedList、Vector 多線程

ArrayList、LinkedList不具有線程安全性(並且LinkedList在單線程的中,也是線程不安全的),如果在併發環境下使用它們,可以用Collections類中的靜態方法synchronizedList()對ArrayList和LinkedList進行調用即可。

List list = Collections.synchronizedList(new LinkedList(...));

Vector實現線程安全的,即它大部分的方法都包含關鍵字synchronized,但是Vector的效率沒有ArraykList和LinkedList高。

ArrayList、LinkedList、Vector 擴容

ArrayList和Vector都是使用Object的數組形式來存儲的,當向這兩種類型中增加元素的時候,若容量不夠,需要進行擴容。ArrayList擴容後的容量是之前的1.5倍,然後把之前的數據拷貝到新建的數組中去。而Vector默認情況下擴容後的容量是之前的2倍(擴容都通過新建數組,將老數組數據copy到新數組中)。

由於LinkedList 爲雙向鏈表,不存在容量,所以不需要擴容。

Vector可以設置容量增量,而ArrayList不可以。在Vector中,有capacityIncrement:當大小大於其容量時,容量自動增加的量。如果在創建Vector時,指定了capacityIncrement的大小,則Vector中動態數組容量需要增加時,如果容量的增量大於0,則增加的是大小是capacityIncrement,如果增量小於0,則增大爲之前的2倍。

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