本文思惟導圖以下:java
讀過 HashMap 源碼的同窗,應該都知道 map 在擴容的時候,有一個 rehash 的過程。git
沒有讀過也沒有關係,能夠花時間閱讀下 從零開始手寫 redis(13) HashMap源碼詳解 簡單瞭解下整個過程便可。github
這裏簡單介紹下:redis
擴容(resize)就是從新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組沒法裝載更多的元素時,對象就須要擴大數組的長度,以便能裝入更多的元素。數據庫
固然Java裏的數組是沒法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像咱們用一個小桶裝水,若是想裝更多的水,就得換大水桶。數組
HashMap 的擴容須要對集合中大部分的元素進行從新計算,可是對於 redis 這種企業級應用,特別是單線程的應用,若是像傳統的 rehash 同樣把全部元素來一遍的話,估計要十幾秒的時間。安全
十幾秒對於常見的金融、電商等相對高併發的業務場景,是沒法忍受的。服務器
那麼 redis 的 rehash 是如何實現的呢?數據結構
實際上 redis 的 rehash 動做並非一次性、集中式地完成的, 而是分屢次、漸進式地完成的。併發
這裏補充一點,不僅僅是擴容,縮容也是同樣的道理,兩者都須要進行 rehash。
只增不降就是對內存的浪費,浪費就是犯罪,特別是內存還這麼貴。
ps: 這種思想和 key 淘汰有殊途同歸之妙,一口吃不了一個大胖子,一次搞不定,那就 1024 次,慢慢來總能解決問題。
這部分直接選自經典入門書籍《Redis 設計與實現》
實際上 redis 內部有兩個 hashtable,咱們稱之爲 ht[0] 和 ht[1]。傳統的 HashMap 中只有一個。
爲了不 rehash 對服務器性能形成影響, 服務器不是一次性將 ht[0] 裏面的全部鍵值對所有 rehash 到 ht[1] , 而是分屢次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash 到 ht[1] 。
哈希表漸進式 rehash 的詳細步驟:
(1)爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。
(2)在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工做正式開始。
(3)在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操做時, 程序除了執行指定的操做之外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的全部鍵值對 rehash 到 ht[1] , 當 rehash 工做完成以後, 程序將 rehashidx 屬性的值增1。
(4)隨着字典操做的不斷執行, 最終在某個時間點上, ht[0] 的全部鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操做已完成。
漸進式 rehash 的好處在於它採起分而治之的方式, 將 rehash 鍵值對所需的計算工做均灘到對字典的每一個添加、刪除、查找和更新操做上, 從而避免了集中式 rehash 而帶來的龐大計算量。
由於在進行漸進式 rehash 的過程當中, 字典會同時使用 ht[0] 和 ht[1] 兩個哈希表, 那這期間的操做如何保證正常進行呢?
(1)查詢一個信息
這個相似於咱們的數據庫信息等遷移,先查詢一個庫,沒有的話,再去查詢另外一個庫。
ht[0] 中沒找到,咱們去 ht[1] 中查詢便可。
(2)新數據怎麼辦?
這個和數據遷移同樣的道理。
當咱們有新舊的兩個系統時,新來的用戶等信息直接落在新系統便可,
這一措施保證了 ht[0] 包含的鍵值對數量會只減不增, 並隨着 rehash 操做的執行而最終變成空表。
咱們來看圖:
(1)準備 rehash
(2)rehash index=0
(3)rehash index=1
(4)rehash index=2
(5)rehash index=3
(6)rehash 完成
看完了上面的流程,不知道你對 rehash 是否有一個大概了思路呢?
下面讓咱們來一塊兒思考下幾個縮擴容的問題。
redis 在每次執行 put 操做的時候,就能夠檢查是否須要擴容。
其實也很好理解,put 插入元素的時候,判斷是否須要擴容,而後開始擴容,是直接的一種思路。
留一個思考題:咱們能夠在其餘的時候判斷嗎?
/* Expand the hash table if needed */ static int _dictExpandIfNeeded(dict *d) { /* Incremental rehashing already in progress. Return. */ // 若是正在進行漸進式擴容,則返回OK if (dictIsRehashing(d)) return DICT_OK; /* If the hash table is empty expand it to the initial size. */ // 若是哈希表ht[0]的大小爲0,則初始化字典 if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); /* If we reached the 1:1 ratio, and we are allowed to resize the hash * table (global setting) or we should avoid it but the ratio between * elements/buckets is over the "safe" threshold, we resize doubling * the number of buckets. */ /* * 若是哈希表ht[0]中保存的key個數與哈希表大小的比例已經達到1:1,即保存的節點數已經大於哈希表大小 * 且redis服務當前容許執行rehash,或者保存的節點數與哈希表大小的比例超過了安全閾值(默認值爲5) * 則將哈希表大小擴容爲原來的兩倍 */ if (d->ht[0].used >= d->ht[0].size && (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) { return dictExpand(d, d->ht[0].used*2); } return DICT_OK; }
擴容的條件總結下來就是兩句話:
(1)服務器目前沒有在執行 BGSAVE/BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1;
(2)服務器目前正在執行 BGSAVE/BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5;
這裏其實體現了做者的一種設計思想:若是負載因子超過5,說明信息已經不少了,管你在不在保存,都要執行擴容,優先保證服務可用性。若是沒那麼高,那就等持久化完成再作 rehash。
咱們本身在實現的時候能夠簡化一下,好比只考慮狀況2。
知道了何時應該開始擴容,可是要擴容到多大也是值得思考的一個問題。
擴容的過小,會致使頻繁擴容,浪費性能。
擴容的太大,會致使資源的浪費。
其實這個最好的方案是結合咱們實際的業務,不過這部分對用戶是透明的。
通常是擴容爲原來的兩倍。
咱們在實現 ArrayList 的時候須要擴容,由於數據放不下了。
咱們知道 HashMap 的底層是數組 + 鏈表(紅黑樹)的數據結構。
那麼會存在放不下的狀況嗎?
我的理解實際上不會。由於鏈表能夠一直加下去。
那爲何須要擴容呢?
實際上更多的是處於性能的考慮。咱們使用 HashMap 就是爲了提高性能,若是一直不擴容,能夠理解爲元素都 hash 到相同的 bucket 上,這時就退化成了一個鏈表。
這會致使查詢等操做性能大大下降。
看了前面的擴容,咱們比較直觀地方式是在用戶 remove 元素的時候執行是否須要縮容。
不過 redis 並不徹底等同於傳統的 HashMap,還有數據的淘汰和過時,這些是對用戶透明的。
redis 採用的方式其實是一個定時任務。
我的理解內存縮容很重要,可是沒有那麼緊急,咱們能夠 1min 掃描一次,這樣能夠節省機器資源。
實際工做中,通常 redis 的內存都是逐步上升的,或者穩定在一個範圍內,不多去大批量刪除數據。(除非數據搞錯了,我就遇到過一次,數據同步錯地方了)。
因此數據刪除,通常幾分鐘內給用戶一個反饋就行。
知其然,知其因此然。
咱們懂得了這個道理也就懂得了爲何有時候刪除 redis 的幾百萬 keys,內存也不是直接降下來的緣由。
/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL * we resize the hash table to save memory */ void tryResizeHashTables(int dbid) { if (htNeedsResize(server.db[dbid].dict)) dictResize(server.db[dbid].dict); if (htNeedsResize(server.db[dbid].expires)) dictResize(server.db[dbid].expires); } /* Hash table parameters */ #define HASHTABLE_MIN_FILL 10 /* Minimal hash table fill 10% */ int htNeedsResize(dict *dict) { long long size, used; size = dictSlots(dict); used = dictSize(dict); return (size > DICT_HT_INITIAL_SIZE && (used*100/size < HASHTABLE_MIN_FILL)); } /* Resize the table to the minimal size that contains all the elements, * but with the invariant of a USED/BUCKETS ratio near to <= 1 */ int dictResize(dict *d) { int minimal; if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR; minimal = d->ht[0].used; if (minimal < DICT_HT_INITIAL_SIZE) minimal = DICT_HT_INITIAL_SIZE; return dictExpand(d, minimal); }
和擴容相似,不過這裏的縮容比例不是 5 倍,而是當哈希表保存的key數量與哈希表的大小的比例小於 10% 時須要縮容。
最簡單的方式是直接變爲原來的一半,不過這麼作有時候也不是那麼好用。
redis 是縮容後的大小爲第一個大於等於當前key數量的2的n次方。
這個可能不太好理解,舉幾個數字就懂了:
keys數量 | 縮容大小 |
---|---|
3 | 4 |
4 | 4 |
5 | 8 |
9 | 16 |
主要保障如下3點:
(1)縮容以後,要大於等於 key 的數量
(2)儘量的小,節約內存
(3)2 的倍數。
第三個看過 HashMap 源碼講解的小夥伴應該深有體會。
固然也不能過小,redis 限制的最小爲 4。
實際上若是 redis 中只放 4 個 key,實在是殺雞用牛刀,通常不會這麼小。
咱們在實現的時候,直接參考 jdk 好了,給個最小值限制 8。
最核心的目的就是爲了節約內存,其實還有一個緣由,叫 small means fast(小便是快——老馬)。
好了,擴容和縮容就聊到這裏,那麼這個漸進式 rehash 到底怎麼一個漸進法?
不須要擴容時應該有至少須要初始化兩個元素:
hashtable[0] = new HashTable(size); hashIndex=-1; hashtable[1] = null;
hashtable 中存儲着當前的元素信息,hashIndex=-1 標識當前沒有在進行擴容。
當須要擴容的時候,咱們再去建立一個 hashtable[1],而且 size 是原來的 2倍。
hashtable[0] = new HashTable(size); hashtable[1] = new HashTable(2 * size); hashIndex=-1;
主要是爲了節約內存,使用惰性初始化的方式建立 hashtable。
調整 hashIndex=0...size,逐步去 rehash 到新的 hashtable[1]
新的插入所有放入到 hashtable[1]
擴容後咱們應該把 hashtable[0] 的值更新爲 hashtable[1],而且釋放掉 hashtable[1] 的資源。
而且設置 hashIndex=-1,標識已經 rehash 完成
hashtable[0] = hashtable[1]; hashIndex=-1; hashtable[1] = null;
這樣總體的實現思路就已經差很少了,光說不練假把式,咱們下一節就來本身實現一個漸進式 rehash 的 HashMap。
至於如今,先讓 rehash 的思路飛一下子~
本節咱們對 redis rehash 的原理進行了講解,其中也加入了很多本身的思考。
文章的結尾,也添加了簡單的實現思路,固然實際實現還會有不少問題須要解決。
下一節咱們將一塊兒手寫一個漸進式 rehash 的 HashMap,感興趣的夥伴能夠關注一波,即便獲取最新動態~
以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波。你的鼓勵,是我最大的動力~
不知道你有哪些收穫呢?或者有其餘更多的想法,歡迎留言區和我一塊兒討論,期待與你的思考相遇。