1. 程式人生 > >HashMap實現原理和源碼分析

HashMap實現原理和源碼分析

aci 鍵值對 creat 變化 遍歷數組 沖突的解決 查看 seed 二分

作者: dreamcatcher-cx
出處: <http://www.cnblogs.com/chengxiao/>
原文:https://www.cnblogs.com/chengxiao/p/6059914.html#undefined
本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在頁面明顯位置給出原文鏈接。

哈希表

  哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表,本文會對java集合框架中的對應實現HashMap的實現原理進行講解,然後會對JDK7的HashMap源碼進行分析。

什麽是哈希表?

  在介紹哈希表之間,先介紹一些其他常見的數據結構在增加,刪除,和查找上的性能。

  數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)。

  線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)。

  二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。

  哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。

  我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主幹就是數組

  比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射

到數組中的某個位置,通過數組下標一次定位就可完成操作。

        存儲位置 = f(關鍵字)

  其中,這個函數f一般稱為哈希函數這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:

技術分享圖片

  查找操作同理,先通過哈希函數計算出實際存儲地址,然後從數組中對應地址取出即可。

哈希沖突

  然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎麽辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然後要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那麽哈希沖突如何解決呢?

哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式,

HashMap的實現原理

  HashMap的主幹是一個Entry類型名為table的數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對,Entry是HashMap中的一個靜態內部類。

//HashMap的主幹數組,可以看到就是一個Entry數組,初始值為空數組{},主幹數組的長度一定是2的次冪,至於為什麽這麽做,後面會有詳細分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry靜態內部類部分代碼:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構
        int hash;//對key的hashcode值進行hash運算後得到的值,存儲在Entry,避免重復計算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

HashMap的存儲整體應該是這樣的:

技術分享圖片

簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那麽對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然後通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。

HashMap中的put方法:

public V put(K key, V value) {
        //如果table數組為空數組{},進行數組填充(為table分配實際內存空間),入參為threshold,此時threshold為initialCapacity 默認是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key為null,存儲位置為table[0]或table[0]的沖突鏈上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻
        int i = indexFor(hash, table.length);//獲取在table中的實際位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗
        addEntry(hash, key, value, i);//新增一個entry
        return null;
    }

解釋:

  第一次put數據的時候,會先創建名為table的entry類型的數組;默認長度是16,負載因子是0.75;

先判斷傳入的key是不是null,如果是null的話,就插入到索引值index為0的位置上,若已經存在數據,那麽就會覆蓋原來的數據,並且返回被覆蓋數據。

如果key不為null;

1.使用int hash=hash(key.hashCode());獲取hash值(ps:這個hash(Object k)是HashMap中的一個神奇的方法;多次使用了位運算以及二進制位的調整,來保證後面獲取的index索引值盡可能的均勻分布);

2.再使用int index=indexFor(hash,table.length)來獲取該key存放再table中的索引值(下標);因為不知道是否存在哈希沖突(也就是不知道該索引值對應的位置是否存在數據),所以遍歷該索引值所對應的鏈表,判斷是否存在相同的key(這裏的‘相同’指的是:key的hash相同並且equals相同);如果相同則覆蓋原來的數據並且返回被覆蓋數據的value,如果最後沒有發現相同的key,那麽就插入數據到最後(哈希沖突的情況下)/直接插入到索引值對應的位置(不存在哈希沖突);並且返回null。

總的流程大概是這樣的:

技術分享圖片

HashMap中的get方法:

public V get(Object key) {
     //如果key為null,則直接去table[0]處去檢索即可。
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
 }

Entry中的GetEntry方法:

final Entry<K,V> getEntry(Object key) {
            
        if (size == 0) {
            return null;
        }
        //通過key的hashcode值計算hash值
        int hash = (key == null) ? 0 : hash(key);
        //indexFor (hash&length-1) 獲取最終數組索引,然後遍歷鏈表,通過equals方法比對找出對應記錄
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && 
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

解析:

  get方法是根據key值來獲取value;首先判斷傳入的key是不是null,如果為null,那麽就直接返回索引值index為0的值;如果不為空,調用getEntry方法,並且返回entry.getValue()值。

get方法的實現相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置找到對應位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應的記錄。要註意的是,有人覺得上面在定位到數組位置之後然後遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前對象不一致,這種情況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null,後面的例子會做出進一步解釋。

補充2個方法——hash()和indexFor()

hash()

//這是一個神奇的函數,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置盡量分布均勻
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);
    }

indexFor()

/**
     * 返回數組下標
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

重寫equals方法的同時一定要重寫hashCode方法

  我們再重寫equals方法的時候,一定也要同時重寫hashCode方法;下面演示只重寫equals方法而不重寫hashCode方法會出現什麽情況。

/**
 * Created by chengxiao on 2016/11/15.
 */
public class MyTest {
    private static class Person{
        int idCard;
        String name;

        public Person(int idCard, String name) {
            this.idCard = idCard;
            this.name = name;
        }
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()){
                return false;
            }
            Person person = (Person) o;
            //兩個對象是否等值,通過idCard來確定
            return this.idCard == person.idCard;
        }

    }
    public static void main(String []args){
        HashMap<Person,String> map = new HashMap<Person, String>();
        Person person = new Person(1234,"喬峰");
        //put到hashmap中去
        map.put(person,"天龍八部");
        //get取出,從邏輯上講應該能輸出“天龍八部”
        System.out.println("結果:"+map.get(new Person(1234,"蕭峰")));
    }
}

實際輸出: null

如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。盡管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode2)-->hash-->indexFor-->最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)

  所以,在重寫equals的方法的時候,必須註意重寫hashCode方法同時還要保證通過equals判斷相等的兩個對象,調用hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(只不過會發生哈希沖突,應盡量避免)

HashMap實現原理和源碼分析