3 手寫Java HashMap核心原始碼
手寫Java HashMap核心原始碼
上一章手寫LinkedList核心原始碼,本章我們來手寫Java HashMap的核心原始碼。
我們來先了解一下HashMap的原理。HashMap 字面意思 hash + map,map是對映的意思,HashMap就是用hash進行對映的意思。不明白?沒關係。我們來具體講解一下HashMap的原理。
HashMap 使用分析
//1 存 HashMap<String,String> map = new HashMap<>(); map.put("name","tom"); //2 取 System.out.println(map.get("name"));//輸出 tom
使用就是這麼簡單。
HashMap 原理分析
我們知道,Object類有一個hashCode()方法,返回物件的hashCode值,可以理解為返回了物件的記憶體地址,暫且不管返回的是記憶體地址或者其它什麼也好,先不管,至於hashCode()方法回返的數是怎麼算的?我們也不管
第1 我們只需要記住:這個函式返回的是一個數就行了。
第2 HashMap內部是用了一個數組來存放資料
1 HashMap是如何把 name,tom存放的?
下面我們用一張圖來演示
從上圖可以看出:
注:上圖中陣列的大小是7,是多少都行,只是我們這裡就畫了7個元素,我們就以陣列大小為7來說明HashMap的原理。
- 陣列的大小是7,那麼陣列的索引範圍是[0 , 6]
- 取得key也就是"name"的hashCode,這是一個數,不管這個數是多少,對7進行取餘數,那麼範圍肯定是 [0 , 6],正好和陣列的索引是一樣的。
- "name".hashCode() % 7 的值假如為2 ,那麼value也就是"tom"應該存放的位置就是2
- data[2] = "tom" ,存到陣列中。是不是很巧妙。
2 下面再來看看如何取?
也用一張圖來演示底層原理,如下
由上圖可知:
- 首先也是獲取key也就是"name"的hashCode值
- 用hashCode值對陣列的大小 7 進行取餘數,和存的時候執行一樣,肯定也是2
- 從陣列的第 2 個位置把value取出,即: String value = data[2]
注:有幾點需要注意
- 某個物件的hashCode()方法返回的值,在任何時候呼叫,返回的值都是一樣的
- 對一個數 n 取餘數 ,範圍是 [ 0, n - 1 ]
注:有幾個問題需要解決
存的時候,如果不同的key的hashCode對陣列取餘數,都正好相同了,也就是都對映在了陣列的同一位置,怎麼辦?這就是hash衝突問題
比如9 % 7 == 2 , 16 % 7 == 2
都等於2
答:陣列中存放的是一個節點的資料結構,節點有next屬性,如果hash衝突了,單鏈表進行存放,取的時候也是一樣,遍歷連結串列如果陣列已經存滿了怎麼辦?
答:和ArrayList一樣,進行擴容,重新對映直接使用hashCode()值進行對映,產生hash衝突的概論很大,怎麼辦?
答:參考JDK中HashMap中的實現,有一個hash()函式,再對hashCode()的值進行執行一下,再進行對映
由上可知:HashMap是用一個數組來存放資料,如果遇到對映的位置上面已經有值了,那麼就用連結串列存放在當前的前面。陣列+連結串列結構,是HashMap的底層結構
假如我們的數組裡面存放的元素是QEntry,如下圖:
手寫 HashMap 核心原始碼
上面分析了原理,接下來我們用最少的程式碼來提示HashMap的原理。
我們就叫QHashMap類,同時數組裡面的元素需要也需要定義一個類,我們定義在QHashMap類的內部。就叫QEntry
QEntry的定義如下:
//底層陣列中存放的元素類
public static class QEntry<K, V> {
K key; //存放key
V value; //存放value
int hash; //key對應的hash值
//hash衝突時,也就是對映的位置上已經有一個元素了
//那麼新加的元素作為連結串列頭,已經存放的放在後面
//即儲存在next中,一句話:新增新元素時,新增在表頭
QEntry<K, V> next;
public QEntry(K key, V value, int hash, QEntry<K, V> next) {
this.key = key;
this.value = value;
this.hash = hash;
this.next = next;
}
}
QEntry類的定義有了,下面看下QHashMap類中需要哪些屬性?
QHashMap類的定義如下圖:
public class QHashMap<K, V> {
//預設的陣列的大小
private static final int DEFAULT_INITIAL_CAPACITY = 16;
//預設的擴容因子,當資料中元素的個數越多時,hash衝突也容易發生
//所以,需要在陣列還沒有用完的情況下就開始擴容
//這個 0.75 就是元素的個數達到了陣列大小的75%的時候就開始擴容
//比如陣列的大小是100,當裡面的元素增加到75的時候,就開始擴容
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
//存放元素的陣列
private QEntry[] table;
//陣列中元素的個數
private int size;
......
}
只需要兩個常量和兩個變數就夠了。
下面我們看下QHashMap的建構函式,為了簡單,只實現一個預設的建構函式
public QHashMap() {
//建立一個數組,預設大小為16
table = new QEntry[DEFAULT_INITIAL_CAPACITY];
//此時元素個數是0
size = 0;
}
我們來看下QHashMap是如何存放資料的 map.put("name","tom")
put()函式的實現如下:
/**
* 1 引數key,value很容易理解
* 2 返回V,我們知道,HashMap有一個特點,
* 如果呼叫了多次 map.put("name","tom"); map.put("name","lilei");
* 後面的值會把前面的覆蓋,如果出現這種情況,返回舊值,在這裡返回"tom"
*/
public V put(K key, V value) {
//1 為了簡單,key不支援null
if (key == null) {
throw new RuntimeException("key is null");
}
//不直接用key.hashCode(),我們對key.hashCode()再作一次運算作為hash值
//這個hash()的方法我是直接從HashMap原始碼拷貝過來的。可以不用關心hash()演算法本身
//只需要知道hash()輸入一個數,返回一個數就行了。
int hash = hash(key.hashCode());
//用key的hash值和陣列的大小,作一次對映,得到應該存放的位置
int index = indexFor(hash, table.length);
//看看陣列中,有沒有已存在的元素的key和引數中的key是相等的
//相等則把老的值替換成新的,然後返回舊值
QEntry<K, V> e = table[index];
while (e != null) {
//先比較hash是否相等,再比較物件是否相等,或者比較equals方法
//如果相等了,說明有一樣的key,這時要更新舊值為新的value,同時返回舊的值
if (e.hash == hash && (key == e.key || key.equals(e.key))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
e = e.next;
}
//如果陣列中沒有元素的key與傳的key相等的話
//把當前位置的元素儲存下來
QEntry<K, V> next = table[index];
//next有可能為null,也有可能不為null,不管是否為null
//next都要作為新元素的下一個節點(next傳給了QEntry的建構函式)
//然後新的元素儲存在了index這個位置
table[index] = new QEntry<>(key, value, hash, next);
//如果需要擴容,元素的個數大於 table.length * 0.75 (別問為什麼是0.75,經驗)
if (size++ >= (table.length * DEFAULT_LOAD_FACTOR)) {
resize();
}
return null;
}
註釋很詳細,這裡有幾個函式
hash()函式是直接從HashMap原始碼中拷貝的,不用糾結這個演算法。
indexFor(),傳入hash和陣列的大小,從而知道我們應該去哪個位置查詢或儲存
這兩個函式的原始碼如下:
//對hashCode進行運算,JDK中HashMap的實現,直接拷貝過來了
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//根據 h 求key落在陣列的哪個位置
static int indexFor(int h, int length) {
//或者 return h & (length-1) 效能更好
//這裡我們用最容易理解的方式,對length取餘數,範圍就是[0,length - 1]
//正好是table陣列的所有的索引的範圍
h = h > 0 ? h : -h; //防止負數
return h % length;
}
還有一個擴容函式。當元素的個數大於 table.length * 0.75時,我們就開始擴容
resize()的原始碼如下 :
//擴容,元素的個數大於 table.length * 0.75
//陣列擴容到原來大小的2倍
private void resize() {
//新建一個數組,大小為原來陣列大小的2倍
int newCapacity = table.length * 2;
QEntry[] newTable = new QEntry[newCapacity];
QEntry[] src = table;
//遍歷舊陣列,重新對映到新的陣列中
for (int j = 0; j < src.length; j++) {
//獲取舊陣列元素
QEntry<K, V> e = src[j];
//釋放舊陣列
src[j] = null;
//因為e是一個連結串列,有可能有多個節點,迴圈遍歷進行對映
while (e != null) {
//把e的下一個節點儲存下來
QEntry<K, V> next = e.next;
//e這個當前節點進行在新的陣列中對映
int i = indexFor(e.hash, newCapacity);
//newTable[i] 位置上有可能是null,也有可能不為null
//不管是否為null,都作為e這個節點的下一個節點
e.next = newTable[i];
//把e儲存在新陣列的 i 的位置
newTable[i] = e;
//繼續e的下一個節點的同樣的處理
e = next;
}
}
//所有的節點都對映到了新陣列上,別忘了把新陣列的賦值給table
table = newTable;
}
相比put()函式來說,get()就簡單多了。
只需要通過hash值找到相應的陣列的位置,再遍歷連結串列,找到一個元素裡面的key與傳的key相等就行了。
put()方法的原始碼如下:
//根據key獲取value
public V get(K key) {
//同樣為了簡單,key不支援null
if (key == null) {
throw new RuntimeException("key is null");
}
//對key進行求hash值
int hash = hash(key.hashCode());
//用hash值進行對映,得到應該去陣列的哪個位置上取資料
int index = indexFor(hash, table.length);
//把index位置的元素儲存下來進行遍歷
//因為e是一個連結串列,我們要對連結串列進行遍歷
//找到和key相等的那個QEntry,並返回value
QEntry<K, V> e = table[index];
while (e != null) {
//比較 hash值是否相等
if (hash == e.hash && (key == e.key || key.equals(e.key))) {
return e.value;
}
//如果不相等,繼續找下一個
e = e.next;
}
return null;
}
上面就是QHashMap的核心原始碼,我們沒有實現刪除。
下面是把QHashMap整個類的原始碼發出來
QHashMap完整原始碼如下:
public class QHashMap<K, V> {
//預設的陣列的大小
private static final int DEFAULT_INITIAL_CAPACITY = 16;
//預設的擴容因子,當陣列的大小大於或者等於當前容量 * 0.75的時候,就開始擴容
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
//底層用一個數組來存放資料
private QEntry[] table;
//陣列大小
private int size;
//一個點節,陣列中存放的單位
public static class QEntry<K, V> {
K key;
V value;
int hash;
QEntry<K, V> next;
public QEntry(K key, V value, int hash, QEntry<K, V> next) {
this.key = key;
this.value = value;
this.hash = hash;
this.next = next;
}
}
public QHashMap() {
table = new QEntry[DEFAULT_INITIAL_CAPACITY];
size = 0;
}
//根據key獲取value
public V get(K key) {
//同樣為了簡單,key不支援null
if (key == null) {
throw new RuntimeException("key is null");
}
//對key進行求hash值
int hash = hash(key.hashCode());
//用hash值進行對映,得到應該去陣列的哪個位置上取資料
int index = indexFor(hash, table.length);
//把index位置的元素儲存下來進行遍歷
//因為e是一個連結串列,我們要對連結串列進行遍歷
//找到和key相等的那個QEntry,並返回value
QEntry<K, V> e = table[index];
while (e != null) {
//比較 hash值是否相等
if (hash == e.hash && (key == e.key || key.equals(e.key))) {
return e.value;
}
//如果不相等,繼續找下一個
e = e.next;
}
return null;
}
/**
* 1 引數key,value很容易理解
* 2 返回V,我們知道,HashMap有一個特點,
* 如果呼叫了多次 map.put("name","tom"); map.put("name","lilei");
* 後面的值會把前面的覆蓋,如果出現這種情況,返回舊值,在這裡返回"tom"
*/
public V put(K key, V value) {
//1 為了簡單,key不支援null
if (key == null) {
throw new RuntimeException("key is null");
}
//不直接用key.hashCode(),我們對key.hashCode()再作一次運算作為hash值
//這個hash()的方法我是直接從HashMap原始碼拷貝過來的。可以不用關心hash()演算法本身
//只需要知道hash()輸入一個數,返回一個數就行了。
int hash = hash(key.hashCode());
//用key的hash值和陣列的大小,作一次對映,得到應該存放的位置
int index = indexFor(hash, table.length);
//看看陣列中,有沒有已存在的元素的key和引數中的key是相等的
//相等則把老的值替換成新的,然後返回舊值
QEntry<K, V> e = table[index];
while (e != null) {
//先比較hash是否相等,再比較物件是否相等,或者比較equals方法
//如果相等了,說明有一樣的key,這時要更新舊值為新的value,同時返回舊的值
if (e.hash == hash && (key == e.key || key.equals(e.key))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
e = e.next;
}
//如果陣列中沒有元素的key與傳的key相等的話
//把當前位置的元素儲存下來
QEntry<K, V> next = table[index];
//next有可能為null,也有可能不為null,不管是否為null
//next都要作為新元素的下一個節點(next傳給了QEntry的建構函式)
//然後新的元素儲存在了index這個位置
table[index] = new QEntry<>(key, value, hash, next);
//如果需要擴容,元素的個數大於 table.length * 0.75 (別問為什麼是0.75,經驗)
if (size++ >= (table.length * DEFAULT_LOAD_FACTOR)) {
resize();
}
return null;
}
//擴容,元素的個數大於 table.length * 0.75
//陣列擴容到原來大小的2倍
private void resize() {
//新建一個數組,大小為原來陣列大小的2倍
int newCapacity = table.length * 2;
QEntry[] newTable = new QEntry[newCapacity];
QEntry[] src = table;
//遍歷舊陣列,重新對映到新的陣列中
for (int j = 0; j < src.length; j++) {
//獲取舊陣列元素
QEntry<K, V> e = src[j];
//釋放舊陣列
src[j] = null;
//因為e是一個連結串列,有可能有多個節點,迴圈遍歷進行對映
while (e != null) {
//把e的下一個節點儲存下來
QEntry<K, V> next = e.next;
//e這個當前節點進行在新的陣列中對映
int i = indexFor(e.hash, newCapacity);
//newTable[i] 位置上有可能是null,也有可能不為null
//不管是否為null,都作為e這個節點的下一個節點
e.next = newTable[i];
//把e儲存在新陣列的 i 的位置
newTable[i] = e;
//繼續e的下一個節點的同樣的處理
e = next;
}
}
//所有的節點都對映到了新陣列上,別忘了把新陣列的賦值給table
table = newTable;
}
//對hashCode進行運算,JDK中HashMap的實現,直接拷貝過來了
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//根據 h 求key落在陣列的哪個位置
static int indexFor(int h, int length) {
//或者 return h & (length-1) 效能更好
//這裡我們用最容易理解的方式,對length取餘數,範圍就是[0,length - 1]
//正好是table陣列的所有的索引的範圍
h = h > 0 ? h : -h; //防止負數
return h % length;
}
}
上面就是QHashMap的原理。下面我們寫一段測試程式碼來看下我們的QHashMap能不能正常執行。測試程式碼如下:
public static void main(String[] args) {
QHashMap<String, String> map = new QHashMap<>();
map.put("name", "tom");
map.put("age", "23");
map.put("address", "beijing");
String oldValue = map.put("address", "shanghai"); //key一樣,返回舊值,儲存新值
System.out.println(map.get("name"));
System.out.println(map.get("age"));
System.out.println("舊值=" + oldValue);
System.out.println("新值=" + map.get("address"));
}
輸出如下:
tom
23
舊值=beijing
新值=shanghai
通過上面的簡單的實現了QHashMap,還有好多功能沒有實現,比較remove,clear,containsKey()等,還有遍歷相關,有興趣的讀者可以自己實現