面試總結hashmap


考點:

1.hashing的概念

2.HashMap中解決碰撞的方法

3.equals()和hashCode()的應用,以及它們在HashMap中的重要性

4.不可變對象的好處

5.HashMap多線程的條件競爭

6.重新調整HashMap的大小

 

常見面試問題:

1.“你知道HashMap的工作原理嗎?” “你知道HashMap的get()方法的工作原理嗎?”

HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓后找到bucket位置來儲存Entry對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然后返回值對象。HashMap使用鏈表來解決碰撞問題,當發生碰撞了,對象將會儲存在鏈表的下一個節點中。 HashMap在每個鏈表節點中儲存鍵值對對象。

這里關鍵點在於指出,HashMap是在bucket中儲存鍵對象和值對象,作為Map.Entry。這一點有助於理解獲取對象的邏輯。

2.我們能否讓HashMap同步?

HashMap可以通過下面的語句進行同步:
Map m = Collections.synchronizeMap(hashMap);

3.什么是HashSet?

HashSet實現了Set接口,它不允許集合中有重復的值,當我們提到HashSet時,第一件事情就是在將對象存儲在HashSet之前,要先確保對象重寫equals()和hashCode()方法,這樣才能比較對象的值是否相等,以確保set中沒有儲存相等的對象。如果我們沒有重寫這兩個方法,將會使用這個方法的默認實現。

public boolean add(Object o)方法用來在Set中添加元素,當元素值重復時則會立即返回false,如果成功添加的話會返回true。

4.“你用過HashMap嗎?” “什么是HashMap?你為什么用到它?”

HashMap實現了Map接口,Map接口對鍵值對進行映射。Map中不允許重復的鍵。Map接口有兩個基本的實現,HashMap和TreeMap。TreeMap保存了對象的排列次序,而HashMap則不能。HashMap存儲的是鍵值對,允許鍵和值為null。HashMap是非synchronized的,但collection框架提供方法能保證HashMap synchronized,這樣多個線程同時訪問HashMap時,能保證只有一個線程更改Map。

public Object put(Object Key,Object value)方法用來將元素添加到map中。

5.HashSet與HashMap的區別?

 6.關於HashMap中的碰撞探測(collision detection)以及碰撞的解決方法?

當兩個對象的hashcode相同會發生什么?

因為hashcode相同,所以它們的bucket位置相同,‘碰撞’會發生。因為HashMap使用鏈表存儲對象,這個Entry(包含有鍵值對的Map.Entry對象)會存儲在鏈表中。

7.如果兩個鍵的hashcode相同,你如何獲取值對象?

當我們調用get()方法,HashMap會使用鍵對象的hashcode找到bucket位置,找到bucket位置之后,會調用keys.equals()方法去找到鏈表中正確的節點,最終找到要找的值對象。

注意:面試官會問因為你並沒有值對象去比較,你是如何確定確定找到值對象的?除非面試者直到HashMap在鏈表中存儲的是鍵值對,否則他們不可能回答出這一題。

一些優秀的開發者會指出使用不可變的、聲明作final的對象,並且采用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。不可變性使得能夠緩存不同鍵的hashcode,這將提高整個獲取對象的速度,使用String,Interger這樣的wrapper類作為鍵是非常好的選擇。

8.如果HashMap的大小超過了負載因子(load factor)定義的容量,怎么辦?

除非你真正知道HashMap的工作原理,否則你將回答不出這道題。默認的負載因子大小為0.75,也就是說,當一個map填滿了75%的bucket時候,和其它集合類(如ArrayList等)一樣,將會創建原來HashMap大小的兩倍的bucket數組,來重新調整map的大小,並將原來的對象放入新的bucket數組中。這個過程叫作rehashing,因為它調用hash方法找到新的bucket位置。

9.你了解重新調整HashMap大小存在什么問題嗎?

當多線程的情況下,可能產生條件競爭。

當重新調整HashMap大小的時候,確實存在條件競爭,因為如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那么就死循環了。這個時候,你可以質問面試官,為什么這么奇怪,要在多線程的環境下使用HashMap呢?:)-------未理解

注意,jdk1.8閾值是8,面試時被問到過!

10.為什么String, Interger這樣的wrapper類適合作為鍵?

String, Interger這樣的wrapper類作為HashMap的鍵是再適合不過了,而且String最為常用。因為String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因為為了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那么就不能從HashMap中找到你想要的對象。不可變性還有其他的優點如線程安全。如果你可以僅僅通過將某個field聲明成final就能保證hashCode是不變的,那么請這么做吧。因為獲取對象的時候要用到equals()和hashCode()方法,那么鍵對象正確的重寫這兩個方法是非常重要的。如果兩個不相等的對象返回不同的hashcode的話,那么碰撞的幾率就會小些,這樣就能提高HashMap的性能。

11.我們可以使用自定義的對象作為鍵嗎? 

這是前一個問題的延伸。當然你可能使用任何對象作為鍵,只要它遵守了equals()和hashCode()方法的定義規則,並且當對象插入到Map中之后將不會再改變了。如果這個自定義對象時不可變的,那么它已經滿足了作為鍵的條件,因為當它創建之后就已經不能改變了。

12.我們可以使用CocurrentHashMap來代替Hashtable嗎?

這是另外一個很熱門的面試題,因為ConcurrentHashMap越來越多人用了。我們知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因為它僅僅根據同步級別對map的一部分進行上鎖。ConcurrentHashMap當然可以代替HashTable,但是HashTable提供更強的線程安全性。

 13.hashmap的存儲過程?

HashMap內部維護了一個存儲數據的Entry數組,HashMap采用鏈表解決沖突,每一個Entry本質上是一個單向鏈表。當准備添加一個key-value對時,首先通過hash(key)方法計算hash值,然后通過indexFor(hash,length)求該key-value對的存儲位置,計算方法是先用hash&0x7FFFFFFF后,再對length取模,這就保證每一個key-value對都能存入HashMap中,當計算出的位置相同時,由於存入位置是一個鏈表,則把這個key-value對插入鏈表頭。

 HashMap中key和value都允許為null。key為null的鍵值對永遠都放在以table[0]為頭結點的鏈表中。

14.hashMap擴容問題?

擴容是是新建了一個HashMap的底層數組,而后調用transfer方法,將就HashMap的全部元素添加到新的HashMap中(要重新計算元素在新的數組中的索引位置)。 很明顯,擴容是一個相當耗時的操作,因為它需要重新計算這些元素在新的數組中的位置並進行復制處理。因此,我們在用HashMap的時,最好能提前預估下HashMap中元素的個數,這樣有助於提高HashMap的性能。

HashMap共有四個構造方法。構造方法中提到了兩個很重要的參數:初始容量和加載因子。這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中槽的數量(即哈希數組的長度),初始容量是創建哈希表時的容量(從構造函數中可以看出,如果不指明,則默認為16),加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 resize 操作(即擴容)。

默認加載因子為0.75,如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數據將過於稀疏(很多空間還沒用,就開始擴容了),對空間造成嚴重浪費。如果我們在構造方法中不指定,則系統默認加載因子為0.75,這是一個比較理想的值,一般情況下我們是無需修改的。

15.什么是hash,什么是碰撞,什么是equals ?

Hash:是一種信息摘要算法,它還叫做哈希,或者散列。我們平時使用的MD5,SHA1都屬於Hash算法,通過輸入key進行Hash計算,就可以獲取key的HashCode(),比如我們通過校驗MD5來驗證文件的完整性。

對於HashCode,它是一個本地方法,實質就是地址取樣運算

 

碰撞:好的Hash算法可以出計算幾乎出獨一無二的HashCode,如果出現了重復的hashCode,就稱作碰撞;

就算是MD5這樣優秀的算法也會發生碰撞,即兩個不同的key也有可能生成相同的MD5。

 

HashCode,它是一個本地方法,實質就是地址取樣運算;

==是用於比較指針是否在同一個地址;

equals與==是相同的。

16.如何減少碰撞?

使用不可變的、聲明作final的對象,並且采用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。不可變性使得能夠緩存不同鍵的hashcode,這將提高整個獲取對象的速度,使用String,Interger這樣的wrapper類作為鍵是非常好的選擇

17.HashMap的復雜度

HashMap整體上性能都非常不錯,但是不穩定,為O(N/Buckets),N就是以數組中沒有發生碰撞的元素。

18.為什么HashMap是線程不安全的,實際會如何體現?

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

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

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

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

 

19.能否讓HashMap實現線程安全,如何做?

1、直接使用Hashtable,但是當一個線程訪問HashTable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,效率很低,現在基本不會選擇它了。

2、HashMap可以通過下面的語句進行同步:

Collections.synchronizeMap(hashMap);

 

3、直接使用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的操作是線程安全的,故效率其實也不高。

 

 19.為什么HashTable的默認大小和HashMap不一樣?

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

 

20.對key進行Hash計算

在JDK8中,由於使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode並移動到低位就可以了

static final int hash(Object key) {

    int h;

    //計算hashCode,並無符號移動到低位

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

21.幾個常用的哈希碼的算法

1:Object類的hashCode.返回對象的內存地址經過處理后的結構,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。

    public int hashCode() {

        int lockWord = shadow$_monitor_;

        final int lockWordMask = 0xC0000000;  // Top 2 bits.

        final int lockWordStateHash = 0x80000000;  // Top 2 bits are value 2 (kStateHash).

        if ((lockWord & lockWordMask) == lockWordStateHash) {

            return lockWord & ~lockWordMask;

        }

        return System.identityHashCode(this);

    }

2:String類的hashCode.根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串所在的堆空間相同,返回的哈希碼也相同。

@Override public int hashCode() {

        int hash = hashCode;

        if (hash == 0) {

            if (count == 0) {

                return 0;

            }

            final int end = count + offset;

            final char[] chars = value;

            for (int i = offset; i < end; ++i) {

                hash = 31*hash + chars[i];

            }

            hashCode = hash;

        }

        return hash;

    }

3:Integer類,返回的哈希碼就是Integer對象里所包含的那個整數的數值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。

public int hashCode() {

        return value;

}

int,char這樣的基本類型,它們不需要hashCode.

插入包裝類到數組

(1). 如果輸入當前的位置是空的,就插進去

(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到后面,這叫做鏈地址法處理沖突。

失敗的hashCode算法會導致HashMap的性能下降為鏈表,所以想要避免發生碰撞,就要提高hashCode結果的均勻性。當然,在JDK8中,采用了紅黑二叉樹進行了處理,這個我們后面詳細介紹。

什么是Hash攻擊?

 通過請求大量key不同,但是hashCode相同的數據,讓HashMap不斷發生碰撞,硬生生的變成了SingleLinkedList

 0

|

1 -> a ->b -> c -> d(撞!撞!撞!復雜度由O(1)變成了O(N))

|

2 -> null(本應該均勻分布,這里卻是空的)

|

3 -> null

|

4 -> null

這樣put/get性能就從O(1)變成了O(N),CPU負載呈直線上升,形成了放大版DDOS的效果,這種方式就叫做hash攻擊。在Java8中通過使用TreeMap,提升了處理性能,可以一定程度的防御Hash攻擊。

 

擴容

(threshold = capacity * load factor ) < size

它主要有兩個步驟:

 

1. 容量加倍

左移N位,就是2^n,用位運算取代了乘法運算

newCap = oldCap << 1;

newThr = oldThr << 1;

 

2. 遍歷計算Hash

for (int j = 0; j < oldCap; ++j) {

                Node<K,V> e;

                //如果發現當前有Bucket

                if ((e = oldTab[j]) != null) {

                    oldTab[j] = null;

                    //如果這里沒有碰撞

                    if (e.next == null)

                        //重新計算Hash,分配位置

                        newTab[e.hash & (newCap - 1)] = e;

                    //這個見下面的新特性介紹,如果是樹,就填入樹

                    else if (e instanceof TreeNode)

                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                    //如果是鏈表,就保留順序....目前就看懂這點

                    else { // preserve order

                        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;

                        }

                    }

                }

            }

由此可以看出擴容需要遍歷並重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。

如何提升性能?

解決擴容損失:如果知道大致需要的容量,把初始容量設置好以解決擴容損失;

比如我現在有1000個數據,需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作為初始容量。

2048=Collections.roundUpToPowerOfTwo(1333)

 

解決碰撞損失:使用高效的HashCode與loadFactor,這個...由於JDK8的高性能出現,這兒問題也不大了。

 

解決數據結構選擇的錯誤:在大型的數據與搜索中考慮使用別的結構比如TreeMap,這個就是積累了;

JDK8中HashMap的新特性

如果某個桶中的鏈表記錄過大的話(當前是TREEIFY_THRESHOLD = 8),就會把這個鏈動態變成紅黑二叉樹,使查詢最差復雜度由O(N)變成了O(logN)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM