HashMap和 Hashtable源碼學(xué)習(xí)和面試總結(jié)

如果說Java的HashMap是數(shù)組+鏈表,那么JDK 8之后就是數(shù)組+鏈表+紅黑樹組成了HashMap。

在之前談過,如果hash算法不好,會使得hash表蛻化為順序查找,即使負(fù)載因子和hash算法優(yōu)化再多,也無法避免出現(xiàn)鏈表過長的情景(這個概論雖然很低),于是在JDK1.8中,對HashMap做了優(yōu)化,引入紅黑樹。具體原理就是當(dāng)hash表中每個桶附帶的鏈表長度默認(rèn)超過8時,鏈表就轉(zhuǎn)換為紅黑樹結(jié)構(gòu),提高HashMap的性能,因為紅黑樹的增刪改是O(logn),而不是O(n)。

紅黑樹的具體原理和實現(xiàn)以后再總結(jié)。

主要看put方法實現(xiàn)

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

封裝了一個final方法,里面用到一個常量,具體用處看源碼:

static final int TREEIFY_THRESHOLD = 8;

下面是具體源代碼注釋:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0) // 首先判斷hash表是否是空的,如果空,則resize擴(kuò)容
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) // 通過key計算得到hash表下標(biāo),如果下標(biāo)處為null,就新建鏈表頭結(jié)點,在方法最后插入即可
            tab[i] = newNode(hash, key, value, null);
        else { // 如果下標(biāo)處已經(jīng)存在節(jié)點,則進(jìn)入到這里
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) // 先看hash表該處的頭結(jié)點是否和key一樣(hashcode和equals比較),一樣就更新
                e = p;
            else if (p instanceof TreeNode) // hash表頭結(jié)點和key不一樣,則判斷節(jié)點是不是紅黑樹,是紅黑樹就按照紅黑樹處理
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else { // 如果不是紅黑樹,則按照之前的HashMap原理處理
                for (int binCount = 0; ; ++binCount) { // 遍歷鏈表
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st (原jdk注釋) 顯然當(dāng)鏈表長度大于等于7的時候,也就是說大于8的話,就轉(zhuǎn)化為紅黑樹結(jié)構(gòu),針對紅黑樹進(jìn)行插入(logn復(fù)雜度)
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) 
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold) // 如果超過容量,即擴(kuò)容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

resize是新的擴(kuò)容方法,之前談過,擴(kuò)容原理是使用新的(2倍舊長度)的數(shù)組代替,把舊數(shù)組的內(nèi)容放到新數(shù)組,需要重新計算hash和hash表的位置,非常耗時,但是自從 JDK 1.8 對HashMap引入了紅黑樹,它和之前的擴(kuò)容方法相比有了改進(jìn)。

擴(kuò)容方法的改進(jìn)

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 
                     oldCap >= DEFAULT_INITIAL_CAPACITY) // 如果長度沒有超過最大值,則擴(kuò)容為2倍的關(guān)系
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) { // 進(jìn)行新舊元素的轉(zhuǎn)移過程
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode) 
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order(原注釋) 如果不是紅黑樹的情況這里改進(jìn)了,沒有rehash的過程,如下分別記錄鏈表的頭尾
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

因為有這樣一個特點:比如hash表的長度是16,那么15對應(yīng)二進(jìn)制是:

0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15

擴(kuò)容之前有兩個key,分別是k1和k2:

k1的hash:

0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15

k2的hash:

0000 0000, 0000 0000, 0000 0000, 0001 1111 = 15

hash值和15模得到:

k1:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15

k2:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15

擴(kuò)容之后表長對應(yīng)為32,則31二進(jìn)制:

0000 0000, 0000 0000, 0000 0000, 0001 1111 = 31

重新hash之后得到:

k1:0000 0000, 0000 0000, 0000 0000, 0000 1111 = 15

k2:0000 0000, 0000 0000, 0000 0000, 0001 1111 = 31 = 15 + 16

觀察發(fā)現(xiàn):如果擴(kuò)容后新增的位是0,那么rehash索引不變,否則才會改變,并且變?yōu)樵瓉淼乃饕?舊hash表的長度,故我們只需看原h(huán)ash表長新增的bit是1還是0,如果是0,索引不變,如果是1,索引變成原索引+舊表長,根本不用像JDK 7 那樣rehash,省去了重新計算hash值的時間,而且新增的bit是0還是1可以認(rèn)為是隨機(jī)的,因此resize的過程,還能均勻的把之前的沖突節(jié)點分散。

故JDK 8對HashMap的優(yōu)化是非常到位的。


如下是之前整理的舊hash的實現(xiàn)機(jī)制和原理,并和jdk古老的Hashtable做了比較。

整理jdk 1.8之前的HashMap實現(xiàn):

  • Java集合概述
  • HashMap介紹
  • HashMap源碼學(xué)習(xí)
  • 關(guān)于HashMap的幾個經(jīng)典問題
  • Hashtable介紹和源碼學(xué)習(xí)
  • HashMap 和 Hashtable比較

先上圖

集合

Set和List接口是Collection接口的子接口,分別代表無序集合和有序集合,Queue是Java提供的隊列實現(xiàn)。

Map

Map用于保存具有key-value映射關(guān)系的數(shù)據(jù)。

Java 中有四種常見的Map實現(xiàn)——HashMap,TreeMap,Hashtable和LinkedHashMap。

  • HashMap就是一張hash表,鍵和值都沒有排序。
  • TreeMap以紅黑樹結(jié)構(gòu)為基礎(chǔ),鍵值可以設(shè)置按某種順序排列。
  • LinkedHashMap保存了插入時的順序。
  • Hashtable是同步的(而HashMap是不同步的)。所以如果在線程安全的環(huán)境下應(yīng)該多使用HashMap,而不是Hashtable,因為Hashtable對同步有額外的開銷,不過JDK 5之后的版本可以使用conncurrentHashMap代替Hashtable。

本文重點總結(jié)HashMap,HashMap是基于哈希表實現(xiàn)的,每一個元素是一個key-value對,其內(nèi)部通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。

HashMap是非線程安全的,只用于單線程環(huán)境下,多線程環(huán)境下可以采用concurrent并發(fā)包下的concurrentHashMap。

HashMap 實現(xiàn)了Serializable接口,因此它支持序列化。

HashMap還實現(xiàn)了Cloneable接口,故能被克隆。

關(guān)于HashMap的用法,這里就不再贅述了,只說原理和一些注意點。

HashMap的存儲結(jié)構(gòu)

HashMap的存儲結(jié)構(gòu)

紫色部分即代表哈希表本身(其實是一個數(shù)組),數(shù)組的每個元素都是一個單鏈表的頭節(jié)點,鏈表是用來解決hash地址沖突的,如果不同的key映射到了數(shù)組的同一位置處,就將其放入單鏈表中保存。

HashMap有四個構(gòu)造方法,方法中有兩個很重要的參數(shù):初始容量和加載因子

這兩個參數(shù)是影響HashMap性能的重要參數(shù),其中容量表示哈希表中槽的數(shù)量(即哈希數(shù)組的長度),初始容量是創(chuàng)建哈希表時的容量(默認(rèn)為16),加載因子是哈希表當(dāng)前key的數(shù)量和容量的比值,當(dāng)哈希表中的條目數(shù)超出了加載因子與當(dāng)前容量的乘積時,則要對該哈希表提前進(jìn)行 resize 操作(即擴(kuò)容)。如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數(shù)據(jù)將過于稀疏(很多空間還沒用,就開始擴(kuò)容了),嚴(yán)重浪費。

JDK開發(fā)者規(guī)定的默認(rèn)加載因子為0.75,因為這是一個比較理想的值。另外,無論指定初始容量為多少,構(gòu)造方法都會將實際容量設(shè)為不小于指定容量的2的冪次方,且最大值不能超過2的30次方。

重點分析HashMap中用的最多的兩個方法put和get的源碼

// 獲取key對應(yīng)的value
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    // 獲取key的hash值
    int hash = hash(key.hashCode());
    // 在“該hash值對應(yīng)的鏈表”上查找“鍵值等于key”的元素
    for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
        Object k;
        // 判斷key是否相同
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    // 沒找到則返回null
    return null;
}

// 獲取“key為null”的元素的值,HashMap將“key為null”的元素存儲在table[0]位置,但不一定是該鏈表的第一個位置!
private V getForNullKey() {
    for (Entry<K, V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}

首先,如果key為null,則直接從哈希表的第一個位置table[0]對應(yīng)的鏈表上查找。記住,key為null的鍵值對永遠(yuǎn)都放在以table[0]為頭結(jié)點的鏈表中,當(dāng)然不一定是存放在頭結(jié)點table[0]中。如果key不為null,則先求的key的hash值,根據(jù)hash值找到在table中的索引,在該索引對應(yīng)的單鏈表中查找是否有鍵值對的key與目標(biāo)key相等,有就返回對應(yīng)的value,沒有則返回null。

// 將“key-value”添加到HashMap中
public V put(K key, V value) {
    // 若“key為null”,則將該鍵值對添加到table[0]中。
    if (key == null)
        return putForNullKey(value);
    // 若“key不為null”,則計算該key的哈希值,然后將其添加到該哈希值對應(yīng)的鏈表中。
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    for (Entry<K, V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 若“該key”對應(yīng)的鍵值對已經(jīng)存在,則用新的value取代舊的value。然后退出!
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    // 若“該key”對應(yīng)的鍵值對不存在,則將“key-value”添加到table中
    modCount++;
    // 將key-value添加到table[i]處
    addEntry(hash, key, value, i);
    return null;
}

如果key為null,則將其添加到table[0]對應(yīng)的鏈表中,如果key不為null,則同樣先求出key的hash值,根據(jù)hash值得出在table中的索引,而后遍歷對應(yīng)的單鏈表,如果單鏈表中存在與目標(biāo)key相等的鍵值對,則將新的value覆蓋舊的value,且將舊的value返回,如果找不到與目標(biāo)key相等的鍵值對,或者該單鏈表為空,則將該鍵值對插入到單鏈表的頭結(jié)點位置(每次新插入的節(jié)點都是放在頭結(jié)點的位置),該操作是有addEntry方法實現(xiàn)的,它的源碼如下:

// 新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 保存“bucketIndex”位置的值到“e”中
    Entry<K, V> e = table[bucketIndex];
    // 設(shè)置“bucketIndex”位置的元素為“新Entry”,
    // 設(shè)置“e”為“新Entry的下一個節(jié)點”
    table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
    // 若HashMap的實際大小 不小于 “閾值”,則調(diào)整HashMap的大小
    if (size++ >= threshold)
        resize(2 * table.length);
}

注意這里倒數(shù)第三行的構(gòu)造方法,將key-value鍵值對賦給table[bucketIndex],并將其next指向元素e,這便將key-value放到了頭結(jié)點中,并將之前的頭結(jié)點接在了它的后面。該方法也說明,每次put鍵值對的時候,總是將新的該鍵值對放在table[bucketIndex]處(即頭結(jié)點處)。兩外注意最后兩行代碼,每次加入鍵值對時,都要判斷當(dāng)前已用的槽的數(shù)目是否大于等于閥值(容量*加載因子),如果大于等于,則進(jìn)行擴(kuò)容,將容量擴(kuò)為原來容量的2倍。

重點來分析下求hash值和索引值的方法,這兩個方法便是HashMap設(shè)計的最為核心的部分,二者結(jié)合能保證哈希表中的元素盡可能均勻地散列。

由hash值找到對應(yīng)索引的方法如下

static int indexFor(int h, int length) {
    return h & (length-1);
}

因為容量初始還是設(shè)定都會轉(zhuǎn)化為2的冪次。故可以使用高效的位與運算替代模運算。下面會解釋原因。

計算hash值的方法如下

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

JDK 的 HashMap 使用了一個 hash 方法對hash值使用位的操作,使hash值的計算效率很高。為什么這樣做?主要是因為如果直接使用hashcode值,那么這是一個int值(8個16進(jìn)制數(shù),共32位),int值的范圍正負(fù)21億多,但是hash表沒有那么長,一般比如初始16,自然散列地址需要對hash表長度取模運算,得到的余數(shù)才是地址下標(biāo)。假設(shè)某個key的hashcode是0AAA0000,hash數(shù)組長默認(rèn)16,如果不經(jīng)過hash函數(shù)處理,該鍵值對會被存放在hash數(shù)組中下標(biāo)為0處,因為0AAA0000 & (16-1) = 0。過了一會兒又存儲另外一個鍵值對,其key的hashcode是0BBB0000,得到數(shù)組下標(biāo)依然是0,這就說明這是個實現(xiàn)得很差的hash算法,因為hashcode的1位全集中在前16位了,導(dǎo)致算出來的數(shù)組下標(biāo)一直是0。于是明明key相差很大的鍵值對,卻存放在了同一個鏈表里,導(dǎo)致以后查詢起來比較慢(蛻化為了順序查找)。故JDK的設(shè)計者使用hash函數(shù)的若干次的移位、異或操作,把hashcode的“1位”變得“松散”,非常巧妙。

下面是幾個常見的面試題

說下HashMap的 擴(kuò)容機(jī)制?

前面說了,hashmap的構(gòu)造器里指明了兩個對于理解HashMap比較重要的兩個參數(shù) int initialCapacity,float loadFactor,這兩個參數(shù)會影響HashMap效率,HashMap底層采用的散列數(shù)組實現(xiàn),利用initialCapacity這個參數(shù)我們可以設(shè)置這個數(shù)組的大小,也就是散列桶的數(shù)量,但是如果需要Map的數(shù)據(jù)過多,在不斷的add之后,這些桶可能都會被占滿,這是有兩種策略,一種是不改變Capacity,因為即使桶占滿了,我們還是可以利用每個桶附帶的鏈表增加元素。但是這有個缺點,此時HaspMap就退化成為了LinkedList,使get和put方法的時間開銷上升,這是就要采用另一種方法:增加Hash桶的數(shù)量,這樣get和put的時間開銷又回退到近于常數(shù)復(fù)雜度上。Hashmap就是采用的該方法。

關(guān)于擴(kuò)容。看HashMap的擴(kuò)容方法,resize方法,它的源碼如下:

// 重新調(diào)整HashMap的大小,newCapacity是調(diào)整后的單位
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 新建一個HashMap,將“舊HashMap”的全部元素添加到“新HashMap”中,
    // 然后,將“新HashMap”賦值給“舊HashMap”。
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int) (newCapacity * loadFactor);
}

很明顯,是從新建了一個HashMap的底層數(shù)組,長度為原來的兩倍,而后調(diào)用transfer方法,將舊HashMap的全部元素添加到新的HashMap中(要重新計算元素在新的數(shù)組中的索引位置)。

transfer方法的源碼如下:

// 將HashMap中的全部元素都添加到newTable中
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K, V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K, V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

很明顯,擴(kuò)容是一個相當(dāng)耗時的操作,因為它需要重新計算這些元素在新的數(shù)組中的位置并進(jìn)行復(fù)制處理。因此,我們在用HashMap時,最好能提前預(yù)估下HashMap中元素的個數(shù),這樣有助于提高HashMap的性能。

HashMap什么時候需要增加容量呢?

因為效率問題,JDK采用預(yù)處理法,這時前面說的loadFactor就派上了用場,當(dāng)size > initialCapacity * loadFactor,HashMap內(nèi)部resize方法就被調(diào)用,使得重新擴(kuò)充hash桶的數(shù)量,在目前的實現(xiàn)中,是增加一倍,這樣就保證當(dāng)你真正想put新的元素時效率不會明顯下降。所以一般情況下HashMap并不存在鍵值放滿的情況。當(dāng)然并不排除極端情況,比如設(shè)置的JVM內(nèi)存用完了,或者這個HashMap的Capacity已經(jīng)達(dá)到了MAXIMUM_CAPACITY(目前的實現(xiàn)是2^30)。

initialCapacity和loadFactor參數(shù)設(shè)什么樣的值好呢?

initialCapacity的默認(rèn)值是16,有些人可能會想如果內(nèi)存足夠,是不是可以將initialCapacity設(shè)大一些,即使用不了這么大,就可避免擴(kuò)容導(dǎo)致的效率的下降,反正無論initialCapacity大小,我們使用的get和put方法都是常數(shù)復(fù)雜度的。這么說沒什么不對,但是可能會忽略一點,實際的程序可能不僅僅使用get和put方法,也有可能使用迭代器,如initialCapacity容量較大,那么會使迭代器效率降低。所以理想的情況還是在使用HashMap前估計一下數(shù)據(jù)量。

加載因子默認(rèn)值是0.75,是JDK權(quán)衡時間和空間效率之后得到的一個相對優(yōu)良的數(shù)值。如果這個值過大,雖然空間利用率是高了,但是對于HashMap中的一些方法的效率就下降了,包括get和put方法,會導(dǎo)致每個hash桶所附加的鏈表增長,影響存取效率。如果比較小,除了導(dǎo)致空間利用率較低外沒有什么壞處,只要有的是內(nèi)存,畢竟現(xiàn)在大多數(shù)人把時間看的比空間重要。但是實際中還是很少有人會將這個值設(shè)置的低于0.5。

HashMap的key和value都能為null么?如果k能為null,那么它是怎么樣查找值的?

如果key為null,則直接從哈希表的第一個位置table[0]對應(yīng)的鏈表上查找。記住,key為null的鍵值對永遠(yuǎn)都放在以table[0]為頭結(jié)點的鏈表中。

HashMap中put值的時候如果發(fā)生了沖突,是怎么處理的?

JDK使用了鏈地址法,hash表的每個元素又分別鏈接著一個單鏈表,元素為頭結(jié)點,如果不同的key映射到了相同的下標(biāo),那么就使用頭插法,插入到該元素對應(yīng)的鏈表。

HashMap的key是如何散列到hash表的?相比較Hashtable有什么改進(jìn)?

我們一般對哈希表的散列很自然地會想到用hash值對length取模(即除留余數(shù)法),Hashtable就是這樣實現(xiàn)的,這種方法基本能保證元素在哈希表中散列的比較均勻,但取模會用到除法運算,效率很低,且Hashtable直接使用了hashcode值,沒有重新計算。

HashMap中則通過 h&(length-1) 的方法來代替取模,其中h是key的hash值,同樣實現(xiàn)了均勻的散列,但效率要高很多,這也是HashMap對Hashtable的一個改進(jìn)。

接下來,我們分析下為什么哈希表的容量一定要是2的整數(shù)次冪。

首先,length為2的整數(shù)次冪的話,h&(length-1) 在數(shù)學(xué)上就相當(dāng)于對length取模,這樣便保證了散列的均勻,同時也提升了效率;

其次,length為2的整數(shù)次冪的話,則一定為偶數(shù),那么 length-1 一定為奇數(shù),奇數(shù)的二進(jìn)制的最后一位是1,這樣便保證了 h&(length-1) 的最后一位可能為0,也可能為1(這取決于h的值),即與后的結(jié)果可能為偶數(shù),也可能為奇數(shù),這樣便可以保證散列的均勻,而如果length為奇數(shù)的話,很明顯 length-1 為偶數(shù),它的最后一位是0,這樣 h&(length-1) 的最后一位肯定為0,即只能為偶數(shù),這樣導(dǎo)致了任何hash值都只會被散列到數(shù)組的偶數(shù)下標(biāo)位置上,浪費了一半的空間,因此length取2的整數(shù)次冪,是為了使不同hash值發(fā)生碰撞的概率較小,這樣就能使元素在哈希表中均勻地散列。

作為對比,再討論一下Hashtable

Hashtable同樣是基于哈希表實現(xiàn)的,其實類似HashMap,只不過有些區(qū)別,Hashtable同樣每個元素是一個key-value對,其內(nèi)部也是通過單鏈表解決沖突問題,容量不足(超過了閥值)時,同樣會自動增長。

Hashtable比較古老, 是JDK1.0就引入的類,而HashMap 是 1.2 引進(jìn)的 Map 的一個實現(xiàn)。

Hashtable是線程安全的,能用于多線程環(huán)境中。Hashtable同樣也實現(xiàn)了Serializable接口,支持序列化,也實現(xiàn)了Cloneable接口,能被克隆。

Hashtable

Hashtable繼承于Dictionary類,實現(xiàn)了Map接口。Dictionary是聲明了操作"鍵值對"函數(shù)接口的抽象類。 有一點注意,Hashtable除了線程安全之外(其實是直接在方法上增加了synchronized關(guān)鍵字,比較古老,落后,低效的同步方式),還有就是它的key、value都不為null。另外Hashtable 也有 初始容量加載因子

public Hashtable() {
    this(11, 0.75f);
}

默認(rèn)加載因子也是 0.75,Hashtable在不指定容量的情況下的默認(rèn)容量為11,而HashMap為16,Hashtable不要求底層數(shù)組的容量一定要為2的整數(shù)次冪,而HashMap則要求一定為2的整數(shù)次冪。因為Hashtable是直接使用除留余數(shù)法定位地址。且Hashtable計算hash值,直接用key的hashCode()。

還要注意:前面說了Hashtable中key和value都不允許為null,而HashMap中key和value都允許為null(key只能有一個為null,而value則可以有多個為null)。但如在Hashtable中有類似put(null,null)的操作,編譯同樣可以通過,因為key和value都是Object類型,但運行時會拋出NullPointerException異常,這是JDK的規(guī)范規(guī)定的。

最后針對擴(kuò)容:Hashtable擴(kuò)容時,將容量變?yōu)樵瓉淼?倍加1,而HashMap擴(kuò)容時,將容量變?yōu)樵瓉淼?倍。

下面是幾個常見的筆試,面試題

Hashtable和HashMap的區(qū)別有哪些?

HashMap和Hashtable都實現(xiàn)了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區(qū)別有:線程安全性,同步(synchronization),以及速度。

理解HashMap是Hashtable的輕量級實現(xiàn)(非線程安全的實現(xiàn),Hashtable是非輕量級,線程安全的),都實現(xiàn)Map接口,主要區(qū)別在于:

  1. 由于HashMap非線程安全,在只有一個線程訪問的情況下,效率要高于Hashtable。
  2. HashMap允許將null作為一個entry的key或者value,而Hashtable不允許。
  3. HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey。因為contains方法容易讓人引起誤解。
  4. Hashtable繼承自陳舊的Dictionary類,而HashMap是Java1.2引進(jìn)的Map 的一個實現(xiàn)。
  5. Hashtable和HashMap擴(kuò)容的方法不一樣,Hashtable中hash數(shù)組默認(rèn)大小11,擴(kuò)容方式是 old*2+1。HashMap中hash數(shù)組的默認(rèn)大小是16,而且一定是2的指數(shù),增加為原來的2倍,沒有加1。
  6. 兩者通過hash值散列到hash表的算法不一樣,HashTbale是古老的除留余數(shù)法,直接使用hashcode,而后者是強(qiáng)制容量為2的冪,重新根據(jù)hashcode計算hash值,在使用hash 位與 (hash表長度 – 1),也等價取模,但更加高效,取得的位置更加分散,偶數(shù),奇數(shù)保證了都會分散到。前者就不能保證。
  7. 另一個區(qū)別是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以當(dāng)有其它線程改變了HashMap的結(jié)構(gòu)(增加或者移除元素),將會拋出ConcurrentModificationException,但迭代器本身的remove()方法移除元素則不會拋出ConcurrentModificationException異常。但這并不是一個一定發(fā)生的行為,要看JVM。這條同樣也是Enumeration和Iterator的區(qū)別。
  • fail-fast和iterator迭代器相關(guān)。如果某個集合對象創(chuàng)建了Iterator或者ListIterator,然后其它的線程試圖“結(jié)構(gòu)上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因為這并沒有從“結(jié)構(gòu)上”更改集合。但是假如已經(jīng)從結(jié)構(gòu)上進(jìn)行了更改,再調(diào)用set()方法,將會拋出IllegalArgumentException異常。
  • 結(jié)構(gòu)上的更改指的是刪除或者插入一個元素,這樣會影響到map的結(jié)構(gòu)。
  • 該條說白了就是在使用迭代器的過程中有其他線程在結(jié)構(gòu)上修改了map,那么將拋出ConcurrentModificationException,這就是所謂fail-fast策略。

為什么HashMap是線程不安全的,實際會如何體現(xiàn)?

第一,如果多個線程同時使用put方法添加元素

假設(shè)正好存在兩個put的key發(fā)生了碰撞(hash值一樣),那么根據(jù)HashMap的實現(xiàn),這兩個key會添加到數(shù)組的同一個位置,這樣最終就會發(fā)生其中一個線程的put的數(shù)據(jù)被覆蓋。

第二,如果多個線程同時檢測到元素個數(shù)超過數(shù)組大小*loadFactor

這樣會發(fā)生多個線程同時對hash數(shù)組進(jìn)行擴(kuò)容,都在重新計算元素位置以及復(fù)制數(shù)據(jù),但是最終只有一個線程擴(kuò)容后的數(shù)組會賦給table,也就是說其他線程的都會丟失,并且各自線程put的數(shù)據(jù)也丟失。且會引起死循環(huán)的錯誤。

具體細(xì)節(jié)上的原因,可以參考:不正當(dāng)使用HashMap導(dǎo)致cpu 100%的問題追究

能否讓HashMap實現(xiàn)線程安全,如何做?

  1. 直接使用Hashtable,但是當(dāng)一個線程訪問Hashtable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當(dāng)一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,效率很低,現(xiàn)在基本不會選擇它了。
  2. HashMap可以通過下面的語句進(jìn)行同步:
Collections.synchronizeMap(hashMap);
  1. 直接使用JDK 5 之后的 ConcurrentHashMap,如果使用Java 5或以上的話,請使用ConcurrentHashMap。

Collections.synchronizeMap(hashMap);又是如何保證了HashMap線程安全?

直接分析源碼吧。

// synchronizedMap方法
public static <K, V> Map<K, V> synchronizedMap(Map<K, V> m) {
    return new SynchronizedMap<>(m);
}

// SynchronizedMap類
private static class SynchronizedMap<K, V>
        implements Map<K, V>, Serializable {
    private static final long serialVersionUID = 1978198479659022715L;

    private final Map<K, V> m;     // Backing Map
    final Object mutex;        // Object on which to synchronize

    SynchronizedMap(Map<K, V> m) {
        this.m = Objects.requireNonNull(m);
        mutex = this;
    }

    SynchronizedMap(Map<K, V> m, Object mutex) {
        this.m = m;
        this.mutex = mutex;
    }

    public int size() {
        synchronized (mutex) {
            return m.size();
        }
    }

    public boolean isEmpty() {
        synchronized (mutex) {
            return m.isEmpty();
        }
    }

    public boolean containsKey(Object key) {
        synchronized (mutex) {
            return m.containsKey(key);
        }
    }

    public boolean containsValue(Object value) {
        synchronized (mutex) {
            return m.containsValue(value);
        }
    }

    public V get(Object key) {
        synchronized (mutex) {
            return m.get(key);
        }
    }

    public V put(K key, V value) {
        synchronized (mutex) {
            return m.put(key, value);
        }
    }

    public V remove(Object key) {
        synchronized (mutex) {
            return m.remove(key);
        }
    }
    // 省略其他方法
}

從源碼中看出 synchronizedMap()方法返回一個SynchronizedMap類的對象,而在SynchronizedMap類中使用了synchronized來保證對Map的操作是線程安全的,故效率其實也不高。

為什么Hashtable的默認(rèn)大小和HashMap不一樣?

前面分析了,Hashtable 的擴(kuò)容方法是乘2再+1,不是簡單的乘2,故Hashtable保證了容量永遠(yuǎn)是奇數(shù),結(jié)合之前分析HashMap的重算hash值的邏輯,就明白了,因為在數(shù)據(jù)分布在等差數(shù)據(jù)集合(如偶數(shù))上時,如果公差與桶容量有公約數(shù) n,則至少有(n-1)/n 數(shù)量的桶是利用不到的,故之前的HashMap會在取模(使用位與運算代替)哈希前先做一次哈希運算,調(diào)整hash值。這里Hashtable比較古老,直接使用了除留余數(shù)法,那么就需要設(shè)置容量起碼不是偶數(shù)(除(近似)質(zhì)數(shù)求余的分散效果好)。而JDK開發(fā)者選了11。

JDK 8對HashMap有了什么改進(jìn)?說說你對紅黑樹的理解?

參考更新的jdk 8對HashMap的的改進(jìn)部分整理,并且還能引申出高級數(shù)據(jù)結(jié)構(gòu)——紅黑樹,這又能引出很多問題……學(xué)無止境啊!

臨時小結(jié):感覺針對Java的HashMap和Hashtable面試,或者理解,到這里就可以了,具體就是多寫代碼實踐。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內(nèi)容

  • 國內(nèi)支付發(fā)達(dá),由支付寶引導(dǎo)的支付流程觀念常常會先入為主,導(dǎo)致接入內(nèi)購時整體觀念錯位。希望借著對比支付寶和內(nèi)購流程來...
    親愛的八路閱讀 3,649評論 4 6
  • 昨天不知道上哪里去找我的擔(dān)保人,覺得說賣保險都說不出口。因為覺得很丟人,滿大街的賣保險的。那都是什么人啊,那都是找...
    cllian119閱讀 160評論 0 0
  • This is a record application for drinking water to help y...
    lalallaaa閱讀 177評論 0 1
  • 估計最近是非常時期啊,天天要面對這個問題…… 昨天晚上和姐姐通電話,提到她家大寶的學(xué)校問題,通完話,...
    陽光灑灑閱讀 168評論 0 0