1. 程式人生 > >3 手寫Java HashMap核心原始碼

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的原理。

  1. 陣列的大小是7,那麼陣列的索引範圍是[0 , 6]
  2. 取得key也就是"name"的hashCode,這是一個數,不管這個數是多少,對7進行取餘數,那麼範圍肯定是 [0 , 6],正好和陣列的索引是一樣的。
  3. "name".hashCode() % 7 的值假如為2 ,那麼value也就是"tom"應該存放的位置就是2
  4. data[2] = "tom" ,存到陣列中。是不是很巧妙。

2 下面再來看看如何取?
也用一張圖來演示底層原理,如下

由上圖可知:

  1. 首先也是獲取key也就是"name"的hashCode值
  2. 用hashCode值對陣列的大小 7 進行取餘數,和存的時候執行一樣,肯定也是2
  3. 從陣列的第 2 個位置把value取出,即: String value = data[2]

注:有幾點需要注意

  1. 某個物件的hashCode()方法返回的值,在任何時候呼叫,返回的值都是一樣的
  2. 對一個數 n 取餘數 ,範圍是 [ 0, n - 1 ]

注:有幾個問題需要解決

  1. 存的時候,如果不同的key的hashCode對陣列取餘數,都正好相同了,也就是都對映在了陣列的同一位置,怎麼辦?這就是hash衝突問題
    比如 9 % 7 == 2 , 16 % 7 == 2都等於2
    答:陣列中存放的是一個節點的資料結構,節點有next屬性,如果hash衝突了,單鏈表進行存放,取的時候也是一樣,遍歷連結串列

  2. 如果陣列已經存滿了怎麼辦?
    答:和ArrayList一樣,進行擴容,重新對映

  3. 直接使用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()等,還有遍歷相關,有興趣的讀者可以自己實現