1. 程式人生 > >HashMap 源碼解析(一)之使用、構造以及計算容量

HashMap 源碼解析(一)之使用、構造以及計算容量

分享 ... 符號 term actor entryset 對象 手寫 ()

簡介

HashMap 是基於哈希表的 Map 接口的實現。 它的使用頻率是非常的高。

集合和映射

作為集合框架中的一員,在深入之前, 讓我們先來簡單了解一下集合框架以及 HashMap 在集合框架中的位置。

技術分享圖片

從圖中可以看出

  1. 集合框架分為兩種, 即集合(Collections)和映射(Map)
  2. HashMap 是 AbstractMap 的子類。而 AbstractMap 實現了 Map, 因此它有 Map 的特性。
  3. 通過Map接口, 可以生成集合(Collections)。

那集合(Collections)和映射(Map)是什麽關系呢?
從圖中我們可以看出, Map 和 Collection 是一種並行的關系。可以這麽理解:

  1. 集合(Collectin)是一組單獨的元素, 通常應用了某種規則。 List 是按特定順序來存儲元素, 而 Set 存儲的是不重復的元素。
  2. 映射(Map)是一系列 “Key-Value” 的集合。
  3. 在 Map 中可以通過一定的方法產生 Collection。

HashMap 特點

很多時候, 我們都說, HashMap 具有如下的特點:

  1. 根據鍵的 HashCode 存儲數據, 具有很快的訪問速度;
  2. 此類不保證映射的順序,特別是它不保證該順序恒久不變;
  3. 允許鍵為 null, 但最多一條記錄;
  4. 允許多條記錄的值為 null;
  5. 線程不安全。

也許你現在對這些特點的印象還不夠深刻, 在後續的源碼解析過程中, 可以一一的見識廬山真面目。

使用

HashMap 的使用應該算是很簡單的。有以下的方法時使用頻率相對來說最高的。

方法名 作用
V put(K key, V value) 將指定的值與此映射中的指定鍵關聯
V get(Object key) 返回指定鍵所映射的值;如果對於該鍵來說,此映射不包含任何映射關系,則返回 null。
int size() 返回此映射中的鍵-值映射關系數。
V remove(Object key) 從此映射中移除指定鍵的映射關系(如果存在)。
Set<Map.Entry<K,V>> entrySet() 返回此映射所包含的映射關系的 Set 視圖。
Set 返回此映射中所包含的鍵的 Set 視圖。

以下為一個示例


public void testHashMap() {
    HashMap<String, String> animals = new HashMap<String, String>();
    animals.put("Tom", "Cat");
    animals.put("Tedi", "Dog");
    animals.put("Jerry", "Mouse");
    animals.put("Don", "Duck");

    // 遍歷方法1 鍵值視圖
    System.out.println("====================KeySet======================");
    Set<String> names = animals.keySet();
    for (String name:
         names) {
        System.out.println("KeySet: "+name+" is a " + animals.get(name));
    }

    // 通過 Entry 進行遍歷
    System.out.println("==================Entry========================");
    Set<Map.Entry<String, String>> entrys= animals.entrySet();
    for(Map.Entry<String, String> entry:entrys){
        System.out.println("Entry: "+entry.getKey()+" is a " + entry.getValue());
    }
    animals.remove("Don");
    // 通過 KeySet Iterator 進行遍歷
    System.out.println("======= KeySet Iterator after remove()=============");
    Iterator<String > iter = animals.keySet().iterator();
    while (iter.hasNext()) {
        String name = iter.next();
        String pet = animals.get(name);
        System.out.println(" KeySet Iterator : "+name+" is a " + pet);
    }
    animals.clear();
    // 通過 Entry Iterator 進行遍歷
    System.out.println("========== Entry Iterator after clear()==========");
    Iterator<Map.Entry<String, String>> entryIter = animals.entrySet().iterator();
    while (entryIter.hasNext()) {
        Map.Entry<String, String> animal = entryIter.next();
        System.out.println(" Entry Iterator : "+animal.getKey()+" is a " + animal.getValue());
    }
}

以上的例子對 HashMap 的常用的基本方法進行了使用。

構造

相關屬性

/**
 * 最大容量, 當傳入容量過大時將被這個值替換
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 *  HashMap的擴容閾值(=負載因子*table的容量),在HashMap中存儲的Node鍵值對超過這個數量時,自動擴容容量為原來的二倍
 */
int threshold;
/**
 * 這就是經常提到的負載因子
 */
final float loadFactor;    

構造方法

HashMap 的構造方法有四個函數, 第四個暫且先不講。 前三個基本最後基本都是為了初始化 initialCapacity 和 loadFactor 的。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

該方法是我們最常用的, 將 loadFactor 和 其余參數定義為默認的值。

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

當我們需要明確指出我們的容量和負載因子時, 使用該函數。

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

當我們需要明確指出我們的容量和負載因子時, 使用該函數。

public HashMap(int initialCapacity, float loadFactor) {
    // 初始化的容量不能小於0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 初始化容量不大於最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 負載因子不能小於 0
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

我們觀察以上的三個構造構造函數, 發現在其中並沒有對存儲的對象 table 的初始化, 源碼中也沒有代碼塊進行初始化或者其他的。其實是延遲到第一次使用時進行初始化, 在 resize() 中進行了初始化。

在構造函數中,最值得我們深究的就是 tableSizeFor 函數。在初始化時,將這個函數的返回值賦給了 threshold , 並不是說 threshold 就等於這個值了, 在後續會從新計算 threshold 的

tableSizeFor 函數

該函數是獲取大於或等於傳入容量 initialCapacity 的2的整數次冪。 試想, 如果我們自己來實現這個函數應該怎麽實現呢?

一般的算法(效率低, 不值得借鑒)

我們要計算比一個數距離最近的二次冪, 大多數人的想法,應該是一次取2的 0 次冪到 31 逐個與當前的數字進行比較, 第一個大於或等於的值就是我們想要的了。函數大致如下:

public int getNearestPowerOfTwo(int cap){
    int num=0;
    for (int i = 0; i < 31; i++) {
        if ((num = (1 << i)) >= cap){
            break;
        }
    }
    return num;
}

這是我隨手寫的, 還有很大的改進空間, 在這裏就不深究了。

tableSizeFor 函數算法

而 HashMap 中的定義如下:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

我們先不說這個算法的原理, 來看和我之前的函數相比效率。

效率比較

public void compare(){
    long start = System.currentTimeMillis();
    for (int i = 0; i < (1 << 30); i++) {
        getNearestPowerOfTwo(i);
    }

    long end = System.currentTimeMillis();
    System.out.println((end-start));

    long start2 = System.currentTimeMillis();
    for (int i = 0; i < (1 << 30); i++) {
        tableSizeFor(i);
    }

    long end2 = System.currentTimeMillis();
    System.out.println((end2-start2));
}

結果如下:

8094

2453

也就是時間上相比是 3.3 倍左右。接下來讓我們看看其實現原理。

tableSizeFor 函數原理

核心思想

將該數的低位二進制位全部變為1, 並加1返回。

舉個例子:

技術分享圖片

低位二進制全部變為1

int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;

其原理是:

首先, 我們忽略最高位之外的所有位數, 看圖解說:
技術分享圖片

Step 1. 右移 1 位,並與之前的數做或運算。 則緊鄰的後 1 位變成了 1. 而此時已經確定了 2 個 1, 因此下一次可以右移2位。

Step 2. 右移 2 位,並與之前的數做或運算, 則緊鄰的後 2 也變成了 1. 而此時已經確定了 4 個 1, 因此下一次可以右移 4 位。

Step 3. 右移 4 位,並與之前的數做或運算, 則緊鄰的後 4 位也變成了1. 而此時已經確定了8 個 1, 因此下一次可以右移 8 位。

...

依次類推, 最後右移了 31 位。

1 + 2 + 4 + 8 + 16 = 31;

由於 int 類型去掉符號位之後就只剩下 31 位了,因此, 右移了 31 位之後可以保證最高位後面的數字都為 1。

第一步為什麽要 n = cap - 1?

如果不做該操作, 則如傳入的 cap 是 2 的整數冪, 則返回值是預想的 2 倍。

HashMap 源碼解析(一)之使用、構造以及計算容量