面試題--HashMap詳解

先上hashCode和equals源碼:

/** JNI,調用底層其它語言實現 */  
public native int hashCode();  

/** 默認同==,直接比較對象 */  
public boolean equals(Object obj) {  
    return (this == obj);  
}  

equals方法:String類中重寫了equals方法,比較的是字符串值,看一下源碼實現:

public boolean equals(Object anObject) {  
    if (this == anObject) {  
        return true;  
    }  
    if (anObject instanceof String) {  
        String anotherString = (String) anObject;  
        int n = value.length;  
        if (n == anotherString.value.length) {  
            char v1[] = value;  
            char v2[] = anotherString.value;  
            int i = 0;  
            // 逐個判斷字符是否相等  
            while (n-- != 0) {  
                if (v1[i] != v2[i])  
                        return false;  
                i++;  
            }  
            return true;  
        }  
    }  
    return false;  
}  

重寫equals要滿足幾個條件:

  • 自反性:對於任何非空引用值 x,x.equals(x) 都應返回 true。
  • 對稱性:對於任何非空引用值 x 和 y,當且僅當 y.equals(x) 返回 true 時,x.equals(y) 才應返回 true。
  • 傳遞性:對於任何非空引用值 x、y 和 z,如果 x.equals(y) 返回 true,並且 y.equals(z) 返回 true,那麼 x.equals(z) 應返回 true。
  • 一致性:對於任何非空引用值 x 和 y,多次調用 x.equals(y) 始終返回 true 或始終返回 false,前提是對象上 equals 比較中所用的信息沒有被修改。
  • 對於任何非空引用值 x,x.equals(null) 都應返回 false
  • Object 類的 equals 方法實現對象上差別可能性最大的相等關係;即,對於任何非空引用值 x 和 y,當且僅當 x 和 y 引用同一個對象時,此方法才返回 true(x == y 具有值 true)。 當此方法被重寫時,通常有必要重寫 hashCode 方法,以維護 hashCode 方法的常規協定,該協定聲明相等對象必須具有相等的哈希碼。

    下面來說說hashCode方法,這個方法我們平時通常是用不到的,它是爲哈希家族的集合類框架(HashMap、HashSet、HashTable)提供服務,hashCode 的常規協定是:

  • 在 Java 應用程序執行期間,在同一對象上多次調用 hashCode 方法時,必須一致地返回相同的整數,前提是對象上 equals 比較中所用的信息沒有被修改。從某一應用程序的一次執行到同一應用程序的另一次執行,該整數無需保持一致。
  • 如果根據 equals(Object) 方法,兩個對象是相等的,那麼在兩個對象中的每個對象上調用 hashCode 方法都必須生成相同的整數結果。
  • 以下情況不 是必需的:如果根據 equals(java.lang.Object) 方法,兩個對象不相等,那麼在兩個對象中的任一對象上調用 hashCode 方法必定會生成不同的整數結果。但是,程序員應該知道,爲不相等的對象生成不同整數結果可以提高哈希表的性能。
  • HashMap的類結構如下:
    java.util 
    類 HashMap < K,V>
    
    java.lang.Object
      繼承者 java.util.AbstractMap< K,V>
          繼承者 java.util.HashMap< K,V>
    
    所有已實現的接口:
    Serializable,Cloneable,Map< K,V>
    直接已知子類:
    LinkedHashMap,PrinterStateReasons

    HashMap中我們最常用的就是put(K, V)和get(K)。我們都知道,HashMap的K值是唯一的,那如何保證唯一性呢?我們首先想到的是用equals比較,沒錯,這樣可以實現,但隨着內部元素的增多,put和get的效率將越來越低,這裏的時間複雜度是O(n),假如有1000個元素,put時需要比較1000次。實際上,HashMap很少會用到equals方法,因爲其內通過一個哈希表管理所有元素,哈希是通過hash單詞音譯過來的,也可以稱爲散列表,哈希算法可以快速的存取元素,當我們調用put存值時,HashMap首先會調用K的hashCode方法,獲取哈希碼,通過哈希碼快速找到某個存放位置,這個位置可以被稱之爲bucketIndex,通過上面所述hashCode的協定可以知道,如果hashCode不同,equals一定爲false,如果hashCode相同,equals不一定爲true。所以理論上,hashCode可能存在衝突的情況,有個專業名詞叫碰撞,當碰撞發生時,計算出的bucketIndex也是相同的,這時會取到bucketIndex位置已存儲的元素,最終通過equals來比較,equals方法就是哈希碼碰撞時纔會執行的方法,所以前面說HashMap很少會用到equals。HashMap通過hashCode和equals最終判斷出K是否已存在,如果已存在,則使用新V值替換舊V值,並返回舊V值,如果不存在 ,則存放新的鍵值對< K, V>到bucketIndex位置。

    這裏寫圖片描述

    現在我們知道,執行put方法後,最終HashMap的存儲結構會有這三種情況,情形3是最少發生的,哈希碼發生碰撞屬於小概率事件。到目前爲止,我們瞭解了兩件事:

  • HashMap通過鍵的hashCode來快速的存取元素。
  • 當不同的對象hashCode發生碰撞時,HashMap通過單鏈表來解決,將新元素加入鏈表表頭,通過next指向原有的元素。單鏈表在Java中的實現就是對象的引用(複合)。
  • HashMap中put方法源碼:

    public V put(K key, V value) {  
        // 處理key爲null,HashMap允許key和value爲null  
        if (key == null)  
            return putForNullKey(value);  
        // 得到key的哈希碼  
        int hash = hash(key);  
        // 通過哈希碼計算出bucketIndex  
        int i = indexFor(hash, table.length);  
        // 取出bucketIndex位置上的元素,並循環單鏈表,判斷key是否已存在  
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
            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;  
            }  
        }  
    
        // key不存在時,加入新元素  
        modCount++;  
        addEntry(hash, key, value, i);  
        return null;  
    }  

    Java Collections Framework中實際操作的都是數組或者鏈表,而我們通常不需要顯示的維護集合的大小,而是集合類框架中內部維護,方便的同時,也帶來了性能的問題。

    HashMap有兩個參數影響其性能:初始容量和加載因子。默認初始容量是16,加載因子是0.75。容量是哈希表中桶(Entry數組)的數量,初始容量只是哈希表在創建時的容量。加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了加載因子與當前容量的乘積時,通過調用 rehash 方法將容量翻倍。

    HashMap中定義的成員變量如下:

    /** 
     * The default initial capacity - MUST be a power of two. 
     */  
    static final int DEFAULT_INITIAL_CAPACITY = 16;// 默認初始容量爲16,必須爲2的冪  
    
    /** 
     * The maximum capacity, used if a higher value is implicitly specified 
     * by either of the constructors with arguments. 
     * MUST be a power of two <= 1<<30. 
     */  
    static final int MAXIMUM_CAPACITY = 1 << 30;// 最大容量爲2的30次方  
    
    /** 
     * The load factor used when none specified in constructor. 
     */  
    static final float DEFAULT_LOAD_FACTOR = 0.75f;// 默認加載因子0.75  
    
    /** 
     * The table, resized as necessary. Length MUST Always be a power of two. 
     */  
    transient Entry<K,V>[] table;// Entry數組,哈希表,長度必須爲2的冪  
    
    /** 
     * The number of key-value mappings contained in this map. 
     */  
    transient int size;// 已存元素的個數  
    
    /** 
     * The next size value at which to resize (capacity * load factor). 
     * @serial 
     */  
    int threshold;// 下次擴容的臨界值,size>=threshold就會擴容  
    
    
    /** 
     * The load factor for the hash table. 
     * 
     * @serial 
     */  
    final float loadFactor;// 加載因子  

    我們看字段名稱大概就能知道其含義,看Doc描述就能知道其詳細要求,這也是我們日常編碼中特別需要注意的地方,不要寫讓別人看不懂的代碼,除非你寫的代碼是一次性的。需要注意的是,HashMap中的容量MUST be a power of two,翻譯過來就是必須爲2的冪,這裏的原因稍後再說。再來看一下HashMap初始化,HashMap一共重載了4個構造方法,分別爲:

    構造方法摘要
    HashMap()
              構造一個具有默認初始容量 (16) 和默認加載因子 (0.75) 的空 HashMap。
    HashMap(int initialCapacity)
              構造一個帶指定初始容量和默認加載因子 (0.75) 的空 HashMap。
    HashMap(int initialCapacity, float loadFactor)
              構造一個帶指定初始容量和加載因子的空 HashMap。
    HashMap(Map<? extendsK,? extendsV> m)
              構造一個映射關係與指定 Map 相同的 HashMap。

    看一下第三個構造方法源碼,其它構造方法最終調用的都是它。

    public HashMap(int initialCapacity, float loadFactor) {  
        // 參數判斷,不合法拋出運行時異常  
        if (initialCapacity < 0)  
            throw new IllegalArgumentException("Illegal initial capacity: " +  
                                               initialCapacity);  
        if (initialCapacity > MAXIMUM_CAPACITY)  
            initialCapacity = MAXIMUM_CAPACITY;  
        if (loadFactor <= 0 || Float.isNaN(loadFactor))  
            throw new IllegalArgumentException("Illegal load factor: " +  
                                               loadFactor);  
    
        // Find a power of 2 >= initialCapacity  
        // 這裏需要注意一下  
        int capacity = 1;  
        while (capacity < initialCapacity)  
            capacity <<= 1;  
    
        // 設置加載因子  
        this.loadFactor = loadFactor;  
        // 設置下次擴容臨界值  
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);  
        // 初始化哈希表  
        table = new Entry[capacity];  
        useAltHashing = sun.misc.VM.isBooted() &&  
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);  
        init();  
    }  

    我們在日常做底層開發時,必須要嚴格控制入參,可以參考一下Java源碼及各種開源項目源碼,如果參數不合法,適時的拋出一些運行時異常,最後到應用層捕獲。看第14-16行代碼,這裏做了一個移位運算,保證了初始容量一定爲2的冪,假如你傳的是5,那麼最終的初始容量爲8。源碼中的位運算隨處可見啊=。=!

    到現在爲止,我們有一個很強烈的問題,爲什麼HashMap容量一定要爲2的冪呢?HashMap中的數據結構是數組+單鏈表的組合,我們希望的是元素存放的更均勻,最理想的效果是,Entry數組中每個位置都只有一個元素,這樣,查詢的時候效率最高,不需要遍歷單鏈表,也不需要通過equals去比較K,而且空間利用率最大。那如何計算纔會分佈最均勻呢?我們首先想到的就是%運算,哈希值%容量=bucketIndex,SUN的大師們是否也是如此做的呢?我們閱讀一下這段源碼:

    /** 
     * Returns index for hash code h. 
     */  
    static int indexFor(int h, int length) {  
        return h & (length-1);  
    }  

    又是位運算,高帥富啊!這裏h是通過K的hashCode最終計算出來的哈希值,並不是hashCode本身,而是在hashCode之上又經過一層運算的hash值,length是目前容量。這塊的處理很有玄機,與容量一定爲2的冪環環相扣,當容量一定是2^n時,h & (length - 1) == h % length,它倆是等價不等效的,位運算效率非常高,實際開發中,很多的數值運算以及邏輯判斷都可以轉換成位運算,但是位運算通常是難以理解的,因爲其本身就是給電腦運算的,運算的是二進制,而不是給人類運算的,人類運算的是十進制,這也是位運算在普遍的開發者中間不太流行的原因(門檻太高)。這個等式實際上可以推理出來,2^n轉換成二進制就是1+n個0,減1之後就是0+n個1,如16 -> 10000,15 -> 01111,那根據&位運算的規則,都爲1(真)時,才爲1,那0≤運算後的結果≤15,假設h <= 15,那麼運算後的結果就是h本身,h >15,運算後的結果就是最後三位二進制做&運算後的值,最終,就是%運算後的餘數,我想,這就是容量必須爲2的冪的原因。HashTable中的實現對容量的大小沒有規定,最終的bucketIndex是通過取餘來運算的。

    通常,默認加載因子 (.75) 在時間和空間成本上尋求一種折衷。加載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數 HashMap 類的操作中,包括 get 和 put 操作,都反映了這一點,可以想想爲什麼)。在設置初始容量時應該考慮到映射中所需的條目數及其加載因子,以便最大限度地降低 rehash 操作次數。如果初始容量大於最大條目數除以加載因子(實際上就是最大條目數小於初始容量*加載因子),則不會發生 rehash 操作。

    如果很多映射關係要存儲在 HashMap 實例中,則相對於按需執行自動的 rehash 操作以增大表的容量來說,使用足夠大的初始容量創建它將使得映射關係能更有效地存儲。 當HashMap存放的元素越來越多,到達臨界值(閥值)threshold時,就要對Entry數組擴容,這是Java集合類框架最大的魅力,HashMap在擴容時,新數組的容量將是原來的2倍,由於容量發生變化,原有的每個元素需要重新計算bucketIndex,再存放到新數組中去,也就是所謂的rehash。HashMap默認初始容量16,加載因子0.75,也就是說最多能放16*0.75=12個元素,當put第13個時,HashMap將發生rehash,rehash的一系列處理比較影響性能,所以當我們需要向HashMap存放較多元素時,最好指定合適的初始容量和加載因子,否則HashMap默認只能存12個元素,將會發生多次rehash操作。

    HashMap所有集合類視圖所返回迭代器都是快速失敗的(fail-fast),在迭代器創建之後,如果從結構上對映射進行修改,除非通過迭代器自身的 remove 或 add 方法,其他任何時間任何方式的修改,迭代器都將拋出 ConcurrentModificationException。因此,面對併發的修改,迭代器很快就會完全失敗。注意,迭代器的快速失敗行爲不能得到保證,一般來說,存在不同步的併發修改時,不可能作出任何堅決的保證。快速失敗迭代器盡最大努力拋出 ConcurrentModificationException。

    HashMap是線程不安全的實現,而HashTable是線程安全的實現,所謂線程不安全,就是在多線程情況下直接使用HashMap會出現一些莫名其妙不可預知的問題,多線程和單線程的區別:單線程只有一條執行路徑,而多線程是併發執行(非並行),會有多條執行路徑。如果HashMap是隻讀的(加載一次,以後只有讀取,不會發生結構上的修改),那使用沒有問題。那如果HashMap是可寫的(會發生結構上的修改),則會引發諸多問題,如上面的fail-fast,也可以看下這裏,這裏就不去研究了。

    那在多線程下使用HashMap我們需要怎麼做,幾種方案:

  • 在外部包裝HashMap,實現同步機制
  • 使用Map m = Collections.synchronizedMap(new HashMap(…));,這裏就是對HashMap做了一次包裝
  • 使用java.util.HashTable,效率最低
  • 使用java.util.concurrent.ConcurrentHashMap,相對安全,效率較高
  • 本文轉載自:http://blog.csdn.net/ghsau/article/details/16890151

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章