從HashMap,Redis 字典看【Hash】。。。

前言

今天摸魚看了下HashMap源碼,想起大神同窗面試遇到過面試官問Redis 字典HashMap的哈希過程有何不一樣。。。老實說,也看過Redis設計與實現(真心推薦),可是準確地描述不出來,故寫下此文。java

:因爲本文偏重於hash過程,源碼部分就看大神們淺顯易懂的敘述吧~面試

散列函數(散列算法)

  • 散列函數又稱散列算法、哈希函數,是一種從任何一種數據中建立小的數字「指紋」的方法,而這個「指紋」就是散列值算法

  • 散列函數的應用領域很廣,例如保護數據、確保傳遞真實的信息、散列表等。本文主要討論的是散列表上的應用。數組

  • 固然咱們但願散列函數能保證每一個key對應一個「指紋」,也就是散列值,所謂的完美散列,但源於對性能、應用場景等考慮,能夠接受不太多的散列碰撞bash

  • 散列碰撞:散列函數的輸入和輸出不是惟一對應關係,例如散列函數的輸入A、B獲得的散列值都是C。數據結構

  • 常見的散列函數:函數

直接定址法 數字分析法 平方取中法 摺疊法 除留餘數法 隨機數法性能

  • 想必你們都知道HashMap、Redis 字典類的場景,都會選擇基於出留餘數法進行優化,來做爲散列函數。這裏就再也不贅述各個方法的概念,請你們到這裏看看

散列衝突(散列碰撞)的解決方法

  • hash?->散列算法的選擇->散列衝突怎麼解決想必這是大多數同行們的思惟定式了。那麼,咱們看下散列衝突的解決算法主要有什麼?

開放定址法

  • 一旦發生了衝突,就去尋找下一個空的散列地址,最簡單的算法公司以下。 f(key) = (f(key) + d) mod  m(d= 1,2,...,m-1) 舉個栗子,設置m爲12,依次插入2六、37,(26+1)% 12 = (37+1)%12,出現了散列衝突,所以再次(37+2)%12 = 4,散列值就不同了,衝突就解決了。

再散列法

  • 同時準備多個散列函數,當第一個散列函數發生衝突時能夠用備用的散列函數計算。

鏈地址法

  • 先用除留餘數法獲得散列值,若是散列值衝突了經過鏈表把衝突的節點挨個插入,造成以下圖的結構。 下面例子爲,f(x) = key mod 12的場景,能夠看到48 % 12 = 12 % 12 = 0,造成了一個鏈表。

公共溢出區法

  • 使用額外的公共存儲空間存儲散列值衝突的元素。

HashMap

HashMap的散列算法

小插曲LOAD_FACTOR(負載因子)

  • 你們都知道HashMap的默認LOAD_FACTOR0.75,它的做用是什麼呢?下面跟着源碼追尋蹤影把~優化

  • new一個HashMap,源碼註釋告訴咱們capacity爲1六、load_factor爲0.75。ui

/** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
複製代碼
  • 隨着put元素,一定要進行擴容,看resize()函數,指貼出標誌性的部分。
else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
複製代碼
  • 能夠看到,閾值是經過capacity乘以load_factor得出的,即16 * 0.75 = 12HashMap中的元素多於閾值就要擴容,能夠理解爲閾值相對於容量小,就下降了散列衝突的概率,由於放入16個元素的散列衝突的概率,在相同的散列函數下,大機率比12個元素的散列衝突概率小

散列函數公式及解析

  • HashMap的散列算法近似於除留餘數法,但沒有用MOD運算,而是用位運算,假設key爲輸入值,散列函數爲f(key)具體公式以下:
f(key) = hash(key) & (table.length - 1) 
hash(key) = (h = key.hashCode()) ^ (h >>> 16)
複製代碼
  • hash(key) & (table.length - 1) 是對table.length取餘的優化版,其實做用差很少,就是基於除留餘數法。因爲HashMap的特性每次擴容table.length都會是2^n,所以位運算明顯效率更高。
  • >>>是無符號右移位運算符,咱們知道hashCode()取值範圍很廣,自己衝突的可能性很小,可是與上table.length - 1 這個概率就變大了,由於table.length是一個較小的值。這就是爲何會使用>>> 16的緣由,hashCode()的高位和低位都對f(key)有了必定影響力,使得分佈更加均勻,散列衝突的概率就小了。

HashMap的散列衝突解決

  • HashMap的散列衝突解決方法明顯是鏈地址法,其實從結構就能夠看出來。
  • 能夠從HashMapresize()方法也能夠看出來,下面請看部分源碼。。。上的註釋。
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;
                            // hash值小於舊數組容量 放入低位鏈表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // hash值大於等於舊數組大小 放入高位鏈表
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 低位鏈表放在原來index下
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 高位鏈表放在原來index + 舊數組大小
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
複製代碼

Redis 字典

數據結構簡介

HashMap差很少的地方

首先,簡單對Redis字典的數據結構進行簡要說明,都是我讀大學時產生陰影的C語言。

typedef struct dictht {
	// 哈希表數組
	dictEntry **table;

	// 哈希表大小
	unsigned long size;

	// 哈希表大小掩碼,用於計算索引值
	// 老是等於size - 1
	unsigned long sizemask;
	
	// 哈希表已使用節點數
	unsigned long used;
	
} dictht
複製代碼
  • 有個哈希表數組,是否是有點眼熟,和Node<K,V>[]殊途同歸之妙,接下來看看dictEntry這個類。
typedef struct dictEntry {
	// 鍵
	void *key

	// 值
	union {
		void *val;
		unit64_tu64;
		int64_ts64;
	} v

	// next指針
	struct dictEntry *next;
}
複製代碼
  • 又有點眼熟了,鍵值對!key屬性保存着鍵值對中的鍵,而v屬性則保存着值,其中鍵值對的值能夠是一個指針、unit64_tint64_t整數。next是指向另外一個哈希表節點的指針,這個指針能夠將多個哈希表相同的鍵值對鏈接成一個鏈表。

  • 能夠看到,目前爲止,HashMap基本沒啥區別,除了多了一些額外屬性(哈希表大小、已使用節點數、掩碼等)。

區別的地方

  • 剛纔看了字典的底層是由哈希表結構實現的,那麼字典的真面目是怎樣的呢?
typedef struct dict {
	// 類型特定函數
	dictType *type;
	
	// 私有數據
	void *privdata;

	// 哈希表(上文講的)
	dictht ht[2];

	// rehash索引
	// 當rehash不在進行時,值爲-1
	int trehashidx;
}
複製代碼
  • 至此,Redis字典的數據結構介紹完了,下圖爲在普通狀態下的字典。

Redis 字典散列算法

  • 計算哈希值的流程爲基於字典的計算哈希值的函數計算哈希值: hash = dict -> type->hashFunction(key)
  • 使用哈希表的sizemask屬性和哈希值,計算出索引值,根據狀況不一樣ht[0]ht[1]。其實和HashMaphash(key) & (table.length - 1) 同樣,由於註釋上說了sizemask老是等於size - 1

index = hash & dict->ht[x].sizemask

  • Redis使用的哈希值計算算法爲MurmurHash2,筆者沒能力說明,給出鏈接。

Redis 字典散列衝突解決

  • Redis的哈希表也使用鏈地址法,每一個節點都有一個next指針,多個節點造成單向鏈表,與HashMap不一樣的是因爲沒有表尾指針,使用頭插法將新節點添加到鏈表的表頭位置。

Redis與HashMap區別

看到散列算法、散列衝突解決方式,沒有太大的區別,那麼差異到底在哪兒呢?那就是再哈希

Rehash

  • 剛纔講到,字典數據結構裏有兩個哈希表(ht[2]),祕密就在這裏。

  • Rehash的目的在於爲了讓哈希表的負載因子維持在合理的範圍內,哈希表在鍵值對太多或者太少時,須要進行擴展或收縮

  • 步驟以下:

    1. 爲字典的ht[1]哈希表分配空間,若是執行的是擴展操做,那麼ht[1]的大小爲第一個大於等於ht[0].used *22^n;若是執行的是收縮操做,那麼ht[1]的大小第一個大於等於ht[0].used2^n
    2. 將保存在ht[0]中的全部鍵值對rehashht[1]上面,即從新計算鍵的哈希值和索引值,而後將鍵值對放置到ht[1]哈希表的指定位置上。
    3. ht[0]包含的全部鍵值對都遷移到了ht[1]以後(ht[0]變爲空哈希表),釋放ht[0],將ht[1]設置爲ht[0],並在ht[1]新建立一個空白哈希表,爲下一次rehash作準備。
  • :漸進式Rehash本文沒講,畫圖筆者實在沒動力,數據結構較複雜,請諒解。

總結

本文從散列算法、散列碰撞解決出發,簡要分析了HashMapRedis 字典hash,觀點不必定都對,請各位大神批評指正!

參考文獻

相關文章
相關標籤/搜索