源碼之HashMap

前言

HashMap是Map的一種實現,它存放的形式是以key-value形式存放的。但是底層HashMap這種數據結構是怎麼實現的呢?是以什麼數據結構實現的存儲呢?這篇文章也是我自己對這些問題的一個總結與深入學習,同時也爲了跟同事做知識分享,作爲碼字練手。本篇文章除了分析上面的幾個問題,我也會深入到HashMap的源碼,去閱讀分析HashMap的實現者所寫的算法實現。

因爲在同版本的JDK中,對Map實現的數據結構所採用算法是不同的,同時不同版本JDK相同數據結構實現也有差異,而這種差異值得我們去思考和分析,這裏面所包含的東西,是沒有深入源碼的你所無法體會的,這裏會給我們帶來智商上不斷的衝擊(可能有些誇張,但如果你有興趣讀下去,那麼一定發現很多有趣知識)。這篇文章羅列JDK7和JDK8中HashMap進行源碼閱讀分析,如果你想了解更多,可以查閱更早版本的實現,以及更新版本的實現。


必備知識

Hash

Hash,一般翻譯做“散列”,也有直接音譯爲“哈希”的,就是把任意長度的輸入,通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,所以不可能從散列值來唯一的確定輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數。

備註:通過hash函數返回的hash值如果不相同,那麼值肯定不相同;如果返回的hash值相同,不能確定值相同。原因會在後續詳細說明hash算法原理時進行解釋說明。

碰撞

所謂的發生碰撞,就是兩個不相同的值,通過hash函數返回的hash值相同,那麼我們就說發生了碰撞。

常見hash算法

直接定址法:直接以關鍵字k或者k加上某個常數(k+c)作爲哈希地址。
數字分析法:提取關鍵字中取值比較均勻的數字作爲哈希地址。
除留餘數法:用關鍵字k除以某個不大於哈希表長度m的數p,將所得餘數作爲哈希表地址。
分段疊加法:按照哈希表地址位數將關鍵字分成位數相等的幾部分,其中最後一部分可以比較短。然後將這幾部分相加,捨棄最高進位後的結果就是該關鍵字的哈希地址。
平方取中法:如果關鍵字各個部分分佈都不均勻的話,可以先求出它的平方值,然後按照需求取中間的幾位作爲哈希地址。
僞隨機數法:採用一個僞隨機數當作哈希函數。

備註:衡量一個哈希函數的好壞的重要指標就是發生碰撞的概率以及發生碰撞的解決方案。任何哈希函數都無法徹底避免碰撞

常見碰撞解決方案

開放定址法
開放定址法就是一旦發生了衝突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。

鏈地址法
將哈希表的每個單元作爲鏈表的頭結點,所有哈希地址爲i的元素構成一個同義詞鏈表。即發生衝突時就把該關鍵字鏈在以該單元爲頭結點的鏈表的尾部。

再哈希法
當哈希地址發生衝突用其他的函數計算另一個哈希函數地址,直到衝突不再產生爲止。

建立公共溢出區
將哈希表分爲基本表和溢出表兩部分,發生衝突的元素都放入溢出表中。

HashMap數據結構

java中最常見兩種數據結構:數組和鏈表,事實上很多Java數據集結構都是通過他兩實現的,比如說ArrayList和Vector就是數組實現的,LinkedList是雙向鏈表實現的。數組:尋址快,鏈表尋址慢;但是數組必須空間連續,而鏈表則不需要,同時數組增刪慢,鏈表增刪快。它們兩者各有優缺點。而今天我們要學習的HashMap就是將數組和連接進行結合進行實現的,它可以很好的綜合它們的優缺點。簡單的說HashMap就是鏈表數組(數組元素是鏈表),也叫作桶數組。如下圖:
源碼之HashMap

根據上面的示意圖,我們就不難理解這種結構,左邊就是一個數組,而數組裏面的元素就是一個單向鏈表。

分析:
左側數組,h(k)是返回hash值作爲當前值的數組地址,如果發生碰撞就採用鏈地址法解決。


源碼解讀

我們知道,hash方法的功能是根據Key來定位這個K-V在鏈表數組中的位置的。也就是hash方法的輸入應該是個Object類型的Key,輸出應該是個int類型的數組下標。

其實簡單,我們只要調用Object對象的hashCode()方法,該方法會返回一個整數,然後用這個數對HashMap容量進行取模就行了。沒錯,其實基本原理就是這個,只不過,在具體實現上,由兩個方法int hash(Object k)和int indexFor(int h, int length)來實現。但是考慮到效率等問題,HashMap的實現會稍微複雜一點。

Hash函數

jdk7中hash函數的實現,如下代碼:

final int hash(Object k) {
   int h = hashSeed;
   if (0 != h && k instanceof String) {
       return sun.misc.Hashing.stringHash32((String) k);
   }

   h ^= k.hashCode();
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

static int indexFor(int h, int length) {
   return h & (length-1);
}

indexFor方法其實主要是將hash生成的整型轉換成鏈表數組中的下標。那麼return h & (length-1);是什麼意思呢?其實就是取模。Java之所有使用位運算(&)來代替取模運算(%),最主要的考慮就是效率。因爲位運算直接操作的是內存,它不需要轉換,效率最理想。
那hash函數中爲什麼通過幾次的位運算(>>>和^)呢?簡單點說,就是爲了把高位的特徵和低位的特徵組合起來,降低哈希衝突的概率,也就是說,儘量做到任何一位的變化都能對最終得到的結果產生影響。其根本是爲了減低碰撞的概率,就是讓高位和低位進行混合,達到儘可能在改變少數位時,依然能夠得到不同的hash值,你們可以自行做驗證,因爲篇幅關係我就不在這裏驗證了。

JDK8中的hash函數:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }

在jdk8中進行了優化,如上所示,但原理都是一樣的都是爲了通過擾動,然高位與地位混合,達到減低碰撞的效果。但是具體這種方式是否比jdk7的實現方式更加高效,或者是綜合效率,碰撞概率更加好呢?這個 可以留個各位看官進行驗證和思考。

容量與擴容

在說明之前我先貼出一些源碼(JDK8)如下:

// 默認的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認的填充因子(以前的版本也有叫加載因子的)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 當桶(bucket)上的鏈表數大於這個值時會轉成紅黑樹,put方法的代碼裏有用到
static final int TREEIFY_THRESHOLD = 8;
// 也是閾值同上一個相反,當桶(bucket)上的鏈表數小於這個值時樹轉鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// 看源碼註釋裏說是:樹的最小的容量,至少是 4 x 
// TREEIFY_THRESHOLD = 32 
// 然後爲了避免(resizing 和 treeification thresholds) 設置成64
static final int MIN_TREEIFY_CAPACITY = 64;
// 存儲元素的數組,總是2的整數次冪
transient Node<k,v>[] table;
transient Set<map.entry<k,v>> entrySet;
// 存放元素的個數
transient int size;
// 每次擴容和更改map結構的計數器
transient int modCount;
// 臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容
int threshold;
// 填充因子
final float loadFactor;

分析:
上面的HashMap代碼是JDK8中的,裏面引入了這樣一個紅黑樹轉化觸發值8,一旦桶超過8就轉爲紅黑樹。並且擴充的容量必須是2的整數次冪,接下來我們查詢他的構造函數。

// 指定初始容量和填充因子的構造方法
public HashMap(int initialCapacity, float loadFactor) {
// 指定的初始容量非負
if (initialCapacity < 0)
throw new IllegalArgumentException(Illegal initial capacity: +
initialCapacity);
// 如果指定的初始容量大於最大容量,置爲最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 填充比爲正
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException(Illegal load factor: +
loadFactor);
this.loadFactor = loadFactor;
// 指定容量後,tableSizeFor方法計算出臨界值,put數據的時候如果超出該值就會擴容,該值肯定也是2的倍數
// 指定的初始容量沒有保存下來,只用來生成了一個臨界值
this.threshold = tableSizeFor(initialCapacity);
}
// 該方法保證總是返回大於cap並且是2的倍數的值,比如傳入999 返回1024
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;
}
//構造函數2
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//構造函數3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

根據阿里巴巴Java開發手冊上建議HashMap初始化時設置已知的大小,如果不超過16個,那麼設置成默認大小16:
集合初始化時, 指定集合初始值大小。
說明: HashMap使用HashMap(int initialCapacity)初始化,
正例:initialCapacity = (需要存儲的元素個數 / 負載因子) + 1。注意負載因子(即loader factor)默認爲0.75, 如果暫時無法確定初始值大小,請設置爲16(即默認值)。
反例:HashMap需要放置1024個元素,由於沒有設置容量初始大小,隨着元素不斷增加,容量7次被迫擴大,resize需要重建hash表,嚴重影響性能。

我們可以通過下面這段代碼測試:

int aHundredMillion = 10000000;

        Map<Integer, Integer> map = new HashMap<>();

        long s1 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map.put(i, i);
        }
        long s2 = System.currentTimeMillis();

        System.out.println("未初始化容量,耗時 : " + (s2 - s1));

        Map<Integer, Integer> map1 = new HashMap<>(aHundredMillion / 2);

        long s5 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map1.put(i, i);
        }
        long s6 = System.currentTimeMillis();

        System.out.println("初始化容量5000000,耗時 : " + (s6 - s5));

        Map<Integer, Integer> map2 = new HashMap<>(aHundredMillion);

        long s3 = System.currentTimeMillis();
        for (int i = 0; i < aHundredMillion; i++) {
            map2.put(i, i);
        }
        long s4 = System.currentTimeMillis();

        System.out.println("初始化容量爲10000000,耗時 : " + (s4 - s3));

備註:在JDK7與JDK8中你會得有差異的結果,請自行測試與思考。

接下來就是看它具體是如何擴容和重新構建hash表的,代碼如下:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
                    // 判斷是否超過最大容量值
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                                         // 容量擴大爲原來的兩倍,oldCap大於等於16
                newThr = oldThr << 1; // 雙倍
        }
        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);
        }
        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) {
            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 { // preserve order
                        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;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

上面的代碼不難理解,而且都有註釋。

我們在來看下jdk8中新進的角色紅黑樹,代碼如下:

    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;
        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;
            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 {
                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 (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;
    }
final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            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);
        }
    }

上面就是jdk8中HashMap中put方法所調用的putVal方法實現,以及轉爲紅黑樹的代碼。

總結:

0.HashMap底層是一個單鏈表數組結構實現的
1.HashMap的初始容量16
2.jdk8中,當達到觸發值8,會轉成紅黑樹結構(具體沒有羅列,看源碼瞭解更多)
3.HashMap不是線程安全的
4.hash採用了高低位異或混合方式降低碰撞概率
5.HashMap結合了數組與鏈表的優點
6.等等

上面貼了很多代碼,其實如果要掰開揉碎說,估計可以出一本書。做技術的我想更應該沉下心來,閱讀大神的代碼短時間內不能夠讓你發家致富(現在社會太浮躁,太急功近利),但它可能給你帶來不一樣的頭腦風暴。

通過對HashMap源碼的分析,我們可以對HashMap這種數據結構有更加深入的瞭解。從中我們也體會到,雖然代碼量不多,但Java作者對於每行代碼,每種設計,以及每個算法的選取都是有所考慮的,這也啓發我們JAVA開發者更多的思考,讓我們在工作中也能夠像java作者一樣能夠從細小的地方下功夫,把工作做好。也希望這篇文章能夠給你帶來一定的參考價值。

參考大神文章:
hash算法深入解析

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