HashMap的hash算法(解決衝突的方式)

簽名(signature)

public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

能夠看到HashMap繼承了html

  • 標記接口Cloneable,用於代表HashMap對象會重寫java.lang.Object#clone()方法,HashMap實現的是淺拷貝(shallow copy)。java

  • 標記接口Serializable,用於代表HashMap對象能夠被序列化web

比較有意思的是,HashMap同時繼承了抽象類AbstractMap與接口Map,由於抽象類AbstractMap的簽名爲算法

public abstract class AbstractMap<K,V> implements Map<K,V>

Stack Overfloooow上解釋到:api

在語法層面繼承接口Map是多餘的,這麼作僅僅是爲了讓閱讀代碼的人明確知道HashMap是屬於Map體系的,起到了文檔的做用數組

AbstractMap至關於個輔助類,Map的一些操做這裏面已經提供了默認實現,後面具體的子類若是沒有特殊行爲,可直接使用AbstractMap提供的實現。安全

Cloneable接口

<code>It's evil, don't use it. </code>

Cloneable這個接口設計的很是很差,最致命的一點是它裏面居然沒有clone方法,也就是說咱們本身寫的類徹底能夠實現這個接口的同時不重寫clone方法。數據結構

關於Cloneable的不足,你們能夠去看看《Effective Java》一書的做者給出的理由,在所給連接的文章裏,Josh Bloch也會講如何實現深拷貝比較好,我這裏就不在贅述了。多線程

Map接口

在Eclipse中的outline面板能夠看到Map接口裏麪包含如下成員方法與內部類:oracle


Java HashMap 源碼解析Map_field_method

能夠看到,這裏的成員方法不外乎是「增刪改查」,這也反映了咱們編寫程序時,必定是以「數據」爲導向的。

上篇文章講了Map雖然並非Collection,可是它提供了三種「集合視角」(collection views),與下面三個方法一一對應:

  • Set<K> keySet(),提供key的集合視角

  • Collection<V> values(),提供value的集合視角

  • Set<Map.Entry<K, V>> entrySet(),提供key-value序對的集合視角,這裏用內部類Map.Entry表示序對

AbstractMap抽象類

AbstractMapMap中的方法提供了一個基本實現,減小了實現Map接口的工做量。

舉例來講:

若是要實現個不可變(unmodifiable)的map,那麼只需繼承AbstractMap,而後實現其entrySet方法,這個方法返回的set不支持add與remove,同時這個set的迭代器(iterator)不支持remove操做便可。

相反,若是要實現個可變(modifiable)的map,首先繼承AbstractMap,而後重寫(override)AbstractMap的put方法,同時實現entrySet所返回set的迭代器的remove方法便可。

設計理念(design concept)

哈希表(hash table)

HashMap是一種基於哈希表(hash table)實現的map,哈希表(也叫關聯數組)一種通用的數據結構,大多數的現代語言都原生支持,其概念也比較簡單:key通過hash函數做用後獲得一個槽(buckets或slots)的索引(index),槽中保存着咱們想要獲取的值,以下圖所示

Java HashMap 源碼解析hash table demo

很容易想到,一些不一樣的key通過同一hash函數後可能產生相同的索引,也就是產生了衝突,這是在所不免的。
因此利用哈希表這種數據結構實現具體類時,須要:

  • 設計個好的hash函數,使衝突儘量的減小

  • 其次是須要解決發生衝突後如何處理。

後面會重點介紹HashMap是如何解決這兩個問題的。

HashMap的一些特色

  • 線程非安全,而且容許key與value都爲null值,HashTable與之相反,爲線程安全,key與value都不容許null值。

  • 不保證其內部元素的順序,並且隨着時間的推移,同一元素的位置也可能改變(resize的狀況)

  • put、get操做的時間複雜度爲O(1)。

  • 遍歷其集合視角的時間複雜度與其容量(capacity,槽的個數)和現有元素的大小(entry的個數)成正比,因此若是遍歷的性能要求很高,不要把capactiy設置的太高或把平衡因子(load factor,當entry數大於capacity*loadFactor時,會進行resize,reside會致使key進行rehash)設置的太低。

  • 因爲HashMap是線程非安全的,這也就是意味着若是多個線程同時對一hashmap的集合試圖作迭代時有結構的上改變(添加、刪除entry,只改變entry的value的值不算結構改變),那麼會報ConcurrentModificationException,專業術語叫fail-fast,儘早報錯對於多線程程序來講是頗有必要的。

  • Map m = Collections.synchronizedMap(new HashMap(...)); 經過這種方式能夠獲得一個線程安全的map。

源碼剖析

首先從構造函數開始講,HashMap遵循集合框架的約束,提供了一個參數爲空的構造函數與有一個參數且參數類型爲Map的構造函數。除此以外,還提供了兩個構造函數,用於設置HashMap的容量(capacity)與平衡因子(loadFactor)。

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);
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    init();
}
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

從代碼上能夠看到,容量與平衡因子都有個默認值,而且容量有個最大值

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 * 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;
/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

能夠看到,默認的平衡因子爲0.75,這是權衡了時間複雜度與空間複雜度以後的最好取值(JDK說是最好的),太高的因子會下降存儲空間可是查找(lookup,包括HashMap中的put與get方法)的時間就會增長。

這裏比較奇怪的是問題:容量必須爲2的指數倍(默認爲16),這是爲何呢?解答這個問題,須要瞭解HashMap中哈希函數的設計原理。

哈希函數的設計原理

/**
  * Retrieve object hash code and applies a supplemental hash function to the
  * result hash, which defends against poor quality hash functions.  This is
  * critical because HashMap uses power-of-two length hash tables, that
  * otherwise encounter collisions for hashCodes that do not differ
  * in lower bits. Note: Null keys always map to hash 0, thus index 0.
  */
 final int hash(Object k) {
     int h = hashSeed;
     if (0 != h && k instanceof String) {
         return sun.misc.Hashing.stringHash32((String) k);
     }
     h ^= k.hashCode();
     // This function ensures that hashCodes that differ only by
     // constant multiples at each bit position have a bounded
     // number of collisions (approximately 8 at default load factor).
     h ^= (h >>> 20) ^ (h >>> 12);
     return h ^ (h >>> 7) ^ (h >>> 4);
 }
 /**
  * Returns index for hash code h.
  */
 static int indexFor(int h, int length) {
     // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
     return h & (length-1);
 }

看到這麼多位操做,是否是以爲暈頭轉向了呢,仍是搞清楚原理就好了,畢竟位操做速度是很快的,不能由於很差理解就不用了。

網上說這個問題的也比較多,我這裏根據本身的理解,儘可能作到通俗易懂。

在哈希表容量(也就是buckets或slots大小)爲length的狀況下,爲了使每一個key都能在衝突最小的狀況下映射到[0,length)(注意是左閉右開區間)的索引(index)內,通常有兩種作法:

  1. 讓length爲素數,而後用hashCode(key) mod length的方法獲得索引

  2. 讓length爲2的指數倍,而後用hashCode(key) & (length-1)的方法獲得索引

HashTable用的是方法1,HashMap用的是方法2。

由於本篇主題講的是HashMap,因此關於方法1爲何要用素數,我這裏不想過多介紹,你們能夠看這裏

重點說說方法2的狀況,方法2其實也比較好理解:

由於length爲2的指數倍,因此length-1所對應的二進制位都爲1,而後在與hashCode(key)作與運算,便可獲得[0,length)內的索引

可是這裏有個問題,若是hashCode(key)的大於length的值,並且hashCode(key)的二進制位的低位變化不大,那麼衝突就會不少,舉個例子:

Java中對象的哈希值都32位整數,而HashMap默認大小爲16,那麼有兩個對象那麼的哈希值分別爲:0xABAB00000xBABA0000,它們的後幾位都是同樣,那麼與16異或後獲得結果應該也是同樣的,也就是產生了衝突。

形成衝突的緣由關鍵在於16限制了只能用低位來計算,高位直接捨棄了,因此咱們須要額外的哈希函數而不僅是簡單的對象的hashCode方法了。

具體來講,就是HashMap中hash函數乾的事了

首先有個隨機的hashSeed,來下降衝突發生的概率

而後若是是字符串,用了sun.misc.Hashing.stringHash32((String) k);來獲取索引值

最後,經過一系列無符號右移操做,來把高位與低位進行異或操做,來下降衝突發生的概率

右移的偏移量20,12,7,4是怎麼來的呢?由於Java中對象的哈希值都是32位的,因此這幾個數應該就是把高位與低位作異或運算,至於這幾個數是如何選取的,就不清楚了,網上搜了半天也沒統一且讓人信服的說法,你們能夠參考下面幾個連接:

HashMap.Entry

HashMap中存放的是HashMap.Entry對象,它繼承自Map.Entry,其比較重要的是構造函數

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    // setter, getter, equals, toString 方法省略
    public final int hashCode() {
        //用key的hash值與上value的hash值做爲Entry的hash值
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }
    /**
     * This method is invoked whenever the value in an entry is
     * overwritten by an invocation of put(k,v) for a key k that's already
     * in the HashMap.
     */
    void recordAccess(HashMap<K,V> m) {
    }
    /**
     * This method is invoked whenever the entry is
     * removed from the table.
     */
    void recordRemoval(HashMap<K,V> m) {
    }
}

能夠看到,Entry實現了單向鏈表的功能,用next成員變量來級連起來。

介紹完Entry對象,下面要說一個比較重要的成員變量

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
//HashMap內部維護了一個爲數組類型的Entry變量table,用來保存添加進來的Entry對象
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

你也許會疑問,Entry不是單向鏈表嘛,怎麼這裏又須要個數組類型的table呢?

我翻了下以前的算法書,其實這是解決衝突的一個方式:鏈地址法(開散列法),效果以下:

Java HashMap 源碼解析鏈地址法處理衝突獲得的散列表

就是相同索引值的Entry,會以單向鏈表的形式存在

鏈地址法的可視化

網上找到個很好的網站,用來可視化各類常見的算法,很棒。瞬間以爲國外大學比國內的強不知多少倍。

下面的連接能夠模仿哈希表採用鏈地址法解決衝突,你們能夠本身去玩玩

get操做

get操做相比put操做簡單,因此先介紹get操做

public V get(Object key) {
    //單獨處理key爲null的狀況
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
    if (size == 0) {
        return null;
    }
    //key爲null的Entry用於放在table[0]中,可是在table[0]衝突鏈中的Entry的key不必定爲null
    //因此須要遍歷衝突鏈,查找key是否存在
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}
final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    //首先定位到索引在table中的位置
    //而後遍歷衝突鏈,查找key是否存在
    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;
}

put操做(含update操做)

由於put操做有可能須要對HashMap進行resize,因此實現略複雜些

private void inflateTable(int toSize) {
    //輔助函數,用於填充HashMap到指定的capacity
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);
    //threshold爲resize的閾值,超事後HashMap會進行resize,內容的entry會進行rehash
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}
/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 */
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    //這裏的循環是關鍵
    //當新增的key所對應的索引i,對應table[i]中已經有值時,進入循環體
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判斷是否存在本次插入的key,若是存在用本次的value替換以前oldValue,至關於update操做
        //並返回以前的oldValue
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    //若是本次新增key以前不存在於HashMap中,modCount加1,說明結構改變了
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
    //若是增長一個元素會後,HashMap的大小超過閾值,須要resize
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //增長的幅度是以前的1倍
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
    //首先獲得該索引處的衝突鏈Entries,有可能爲null,不爲null
    Entry<K,V> e = table[bucketIndex];
    //而後把新的Entry添加到衝突鏈的開頭,也就是說,後插入的反而在前面(第一次還真沒看明白)
    //須要注意的是table[bucketIndex]自己並不存儲節點信息,
    //它就至關因而單向鏈表的頭指針,數據都存放在衝突鏈中。
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
//下面看看HashMap是如何進行resize,廬山真面目就要揭曉了
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //若是已經達到最大容量,那麼就直接返回
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    //initHashSeedAsNeeded(newCapacity)的返回值決定了是否須要從新計算Entry的hash值
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    //遍歷當前的table,將裏面的元素添加到新的newTable中
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            //最後這兩句用了與put放過相同的技巧
            //將後插入的反而在前面
            newTable[i] = e;
            e = next;
        }
    }
}
/**
 * Initialize the hashing mask value. We defer initialization until we
 * really need it.
 */
final boolean initHashSeedAsNeeded(int capacity) {
    boolean currentAltHashing = hashSeed != 0;
    boolean useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    //這裏說明了,在hashSeed不爲0或知足useAltHash時,會重算Entry的hash值
    //至於useAltHashing的做用能夠參考下面的連接
    // http://stackoverflow.com/questions/29918624/what-is-the-use-of-holder-class-in-hashmap
    boolean switching = currentAltHashing ^ useAltHashing;
    if (switching) {
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}

remove操做

public V remove(Object key) {
    Entry<K,V> e = removeEntryForKey(key);
    //能夠看到刪除的key若是存在,就返回其所對應的value
    return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    //這裏用了兩個Entry對象,至關於兩個指針,爲的是防治衝突鏈發生斷裂的狀況
    //這裏的思路就是通常的單向鏈表的刪除思路
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;
    //當table[i]中存在衝突鏈時,開始遍歷裏面的元素
    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            modCount++;
            size--;
            if (prev == e) //當衝突鏈只有一個Entry時
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }
    return e;
}

到如今爲止,HashMap的增刪改查都介紹完了。
通常而言,認爲HashMap的這四種操做時間複雜度爲O(1),由於它hash函數性質較好,保證了衝突發生的概率較小。

HashMap的序列化

介紹到這裏,基本上算是把HashMap中一些核心的點講完了,但還有個比較嚴重的問題:保存Entry的table數組爲transient的,也就是說在進行序列化時,並不會包含該成員,這是爲何呢?

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

爲了解答這個問題,咱們須要明確下面事實:

  • Object.hashCode方法對於一個類的兩個實例返回的是不一樣的哈希值

咱們能夠試想下面的場景:

咱們在機器A上算出對象A的哈希值與索引,而後把它插入到HashMap中,而後把該HashMap序列化後,在機器B上從新算對象的哈希值與索引,這與機器A上算出的是不同的,因此咱們在機器B上get對象A時,會獲得錯誤的結果。

因此說,當序列化一個HashMap對象時,保存Entry的table是不須要序列化進來的,由於它在另外一臺機器上是錯誤的。

由於這個緣由,HashMap重現了writeObjectreadObject 方法

private void writeObject(java.io.ObjectOutputStream s)
    throws IOException
{
    // Write out the threshold, loadfactor, and any hidden stuff
    s.defaultWriteObject();

    // Write out number of buckets
    if (table==EMPTY_TABLE) {
        s.writeInt(roundUpToPowerOf2(threshold));
    } else {
       s.writeInt(table.length);
    }

    // Write out size (number of Mappings)
    s.writeInt(size);

    // Write out keys and values (alternating)
    if (size > 0) {
        for(Map.Entry<K,V> e : entrySet0()) {
            s.writeObject(e.getKey());
            s.writeObject(e.getValue());
        }
    }
}

private static final long serialVersionUID = 362498820763181265L;

private void readObject(java.io.ObjectInputStream s)
     throws IOException, ClassNotFoundException
{
    // Read in the threshold (ignored), loadfactor, and any hidden stuff
    s.defaultReadObject();
    if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
        throw new InvalidObjectException("Illegal load factor: " +
                                           loadFactor);
    }

    // set other fields that need values
    table = (Entry<K,V>[]) EMPTY_TABLE;

    // Read in number of buckets
    s.readInt(); // ignored.

    // Read number of mappings
    int mappings = s.readInt();
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                           mappings);

    // capacity chosen by number of mappings and desired load (if >= 0.25)
    int capacity = (int) Math.min(
                mappings * Math.min(1 / loadFactor, 4.0f),
                // we have limits...
                HashMap.MAXIMUM_CAPACITY);

    // allocate the bucket array;
    if (mappings > 0) {
        inflateTable(capacity);
    } else {
        threshold = capacity;
    }

    init();  // Give subclass a chance to do its thing.

    // Read the keys and values, and put the mappings in the HashMap
    for (int i = 0; i < mappings; i++) {
        K key = (K) s.readObject();
        V value = (V) s.readObject();
        putForCreate(key, value);
    }
}
private void putForCreate(K key, V value) {
    int hash = null == key ? 0 : hash(key);
    int i = indexFor(hash, table.length);

    /**
     * Look for preexisting entry for key.  This will never happen for
     * clone or deserialize.  It will only happen for construction if the
     * input Map is a sorted map whose ordering is inconsistent w/ equals.
     */
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            e.value = value;
            return;
        }
    }

    createEntry(hash, key, value, i);
}

簡單來講,在序列化時,針對Entry的key與value分別單獨序列化,當反序列化時,再單獨處理便可。

總結

在總結完HashMap後,發現這裏面一些核心的東西,像哈希表的衝突解決,都是算法課上學到,不過因爲「年代久遠」,已經忘得差很少了,我以爲忘

  • 一方面是因爲時間久不用

  • 另外一方面是因爲自己沒理解好

平時多去思考,這樣在遇到一些性能問題時也好排查。

還有一點就是咱們在分析某些具體類或方法時,不要花太多時間一些細枝末節的邊界條件上,這樣很得不償失,倒不是說這麼邊界條件不重要,程序的bug每每就是邊界條件沒考慮周全致使的。

只是說咱們能夠在理解了這個類或方法的整體思路後,再來分析這些邊界條件。

若是一開始就分析,那真是丈二和尚——摸不着頭腦了,隨着對它工做原理的加深,纔有可能理解這些邊界條件的場景。

相關文章
相關標籤/搜索