【源碼分析】HashMap的原理及常見面試題

參考文獻:

前言

 HashMap在Java面試中考察頻率很高,涉及了哈希表、鏈表、紅黑樹、多線程等知識點。

 本文主要內容是HashMap的原理及常見面試題,主要是基於jdk 1.7的源碼,並穿插總結說明了和jdk1.8的主要區別;原理篇幅較大請耐心細看,常見面試題來源於本人面試經歷和網絡,面試題總結了常見面試題、HasMap的要點、ConCurrentHashMap的要點。

 本文內容來自於 個人對HashMap源碼的理解、參考文獻中相關網絡博客的要點總結及個人理解,由於本人能力有限,可能會有理解錯誤之處,望不吝指教;如有侵權,通知則刪!

1 HashMap數據結構


  HashMap的主幹是一個Entry數組;Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。

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

  Entry是HashMap中的一個靜態內部類,代碼如下,jdk1.8就是改了個類名(改爲Node):

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存儲指向下一個Entry的指針(引用),單鏈表結構
        int hash;//hash(key),不是hashcode(key);存儲在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由數組+鏈表(java 1.8是數組+鏈表+紅黑樹)組成;HashMap的主幹是一個Entry數組,每一個Entry有四個屬性(key,value,hash,next);鏈表則是主要爲了解決哈希衝突而存在的;

 如果定位到的數組位置不含鏈表(當前entry的next指向null),那麼對於查找,添加操作的時間複雜度爲O(1);如果定位到的數組包含鏈表,添加查找操作時間複雜度爲O(n);從性能考慮,HashMap中的鏈表長度越短,性能纔會越好。

2 主要參數

參數 含義
capacity table的容量大小,默認爲 16; 可由用戶在構造器設置,但調整後最終capacity 一定是2 的次冪;最大值是230
size Entry(鍵值對)個數;
threshold size 的臨界值,當 size >= threshold 就必須進行擴容;
loadFactor 裝載因子,默認值0.75,table 能夠使用的比例;threshold = capacity * loadFactor; 可由用戶在構造器設置;
  • loadFactor裝載因子是個常量,所以loadFactor在HashMap實例化後固定不變,要麼是0.75,要麼是用戶輸入值;
//capacity默認值16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//capacity最大值2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//loadFactor默認值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//主幹數組
transient Entry[] table;
//entry個數
transient int size;
//size 的臨界值,當size >= threshold 就必須進行擴容;
int threshold;
//裝載因子,table 能夠使用的比例;threshold = capacity * loadFactor;可由用戶在構造器設置;因爲是個常量,所以loadFactor在HashMap實例化後固定不變;
final float loadFactor;
//HashMap結構變化(put或remove)數,保證HashMap在序列化或迭代時數據一致性;當一個線程在對HashMap進行序列化或迭代時,如果modCount變化了,說明其他線程修改了HashMap結構,會拋出一個ConcurrentModificationException異常;
transient int modCount;

3 源碼分析

3.1 構造函數

 HashMap有4個構造器,其他構造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數,會使用默認值;initialCapacity默認爲16,loadFactory默認爲0.75;我們看下其中一個:

public HashMap(int initialCapacity, float loadFactor) {
     //此處對用戶設置容量initialCapacity進行校驗
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //此處對loadFactor進行校驗
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        threshold = initialCapacity;//值爲默認capcity或用戶輸入capcity,後面確定capcity爲2的次冪會修改threshold值爲threshold = capacity * loadFactor;
        init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現
    }

 對傳輸參數進行校驗,然後初始化loadFactor和threshold;從上面這段代碼我們可以看出,在常規構造器中,沒有爲數組table分配內存空間(入參爲指定Map的構造器例外),而是在執行put操作的時候才真正分配內存;

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

 這個構造器應該是大家使用最多的構造器,它將默認capcity和loadFactor傳入另外一個構造器;

3.2 put方法

 put方法用於往HashMap中添加一個鍵值對;

public V put(K key, V value) {
        //HashMap初始化後添加第一個元素,此時table數組爲空數組{},進行數組填充(爲table分配實際內存空間),入參爲threshold,此時threshold爲用戶輸入capcity
        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中的實際位置index
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {//對於位置爲index的那條衝突鏈
        //如果該對應數據已存在,執行覆蓋操作。用新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++;
        addEntry(hash, key, value, i);//添加一個entry
        return null;
    }

3.3 保證capcity爲2的次冪

private void inflateTable(int toSize) {
				//此時toSize是threshold 
        int capacity = roundUpToPowerOf2(toSize);//保證capacity初值一定是2的次冪
        //此處爲threshold初始化,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];//分配內存
        initHashSeedAsNeeded(capacity);
    }

 此方法爲table分配內存;並且初始化capacity爲2的次冪,初始化threshold爲capacity*loadFactor,值得一提的是loadFactor在構造器中以初始化爲默認值或者用戶輸入值;

private static int roundUpToPowerOf2(int number) {
					//此時number是threshold 
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

 假設之前使用的是key-value兩個參數的構造器,執行到這個方法;此時number是threshold ,要麼是默認capcity16,要麼是用戶輸入capcity;roundUpToPowerOf2中的這段處理使得數組長度capcity一定爲2的次冪;

 Integer.highestOneBit是用來獲取"最高位非0位保留,其他位全爲0"所代表的數值;通過roundUpToPowerOf2(toSize)可以確保capacity爲大於或等於number的第一個二的次冪;比如number=13,則capacity=16;number=16,capacity=16;number=17,capacity=32.

 所以roundUpToPowerOf2(number)方法保證了capcity初值一定是2的次冪;capcity爲大於或等於number的第一個二的次冪;

roundUpToPowerOf2(number)方法加上2倍擴容方式保證了capcity一定是2的次冪

 到現在爲止三個關鍵參數threshold、capacity、loadFactor已初始化完成;table也完成了初始化;

3.4 確定桶下標bucketIndex

(1)當添加的元素key爲空時

HashMap是允許null鍵和null值的;當key爲null時,新添加的元素插入到table[0]或者table[0]的衝突鏈上

(2)當添加的元素key不爲空時
//這個方法對key的hashcode做了進一步行計算來保證返回的hash(key)儘量分佈均勻
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);
    }

 這個方法對key的hashcode做了進一步行計算來保證返回的hash(key)儘量分佈均勻;這個hash(key)在後面會保存到entry.hash中去;

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

 因爲h&(length-1)一定小於length,保證了獲取的index一定在數組下標範圍內;舉個例子,默認容量16,length-1=15,h=18,轉換成二進制計算爲:

 h             1  0  0  1  0 
 length-1    & 0  1  1  1  1
             __________________
 index         0  0  0  1  0    = 2

 最終計算出index=2;有些版本在此處的計算下標會使用模%運算,也能保證index一定在數組下標範圍內;不過對於計算機來說,位運算比模運算更快;

 所以最終桶下標index(bucketIndex)是這麼確定的:

e.hash = h = hash(key);//hash(key)是對key.hashcode()的進一步處理;保證均勻
index = h & (length-1) = h % length;//可證明,當n爲2的次冪時,y&(n-1)=y%n;

所以我們在這知道了capcity爲2的次冪的一個作用:計算桶下標index時,讓模運算轉爲位運算,運算更快;

3.5 添加鍵值對過程

 put方法用於往HashMap中添加一個鍵值對;

public V put(K key, V value) {
        //HashMap初始化後添加第一個元素,此時table數組爲空數組{},進行數組填充(爲table分配實際內存空間),入參爲threshold,此時threshold爲用戶輸入capcity
        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中的實際位置index
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {//對於位置爲index的那條衝突鏈
        //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value
            Object k;
            //驗證e.hash == hash的目的是防止出現key equals爲true但hashcode不等的情況(key的類複寫了equals卻沒複寫hashcode方法)
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//哈希值和key都相等則覆蓋,否則比較下一個結點
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);//插入結點
        return null;
    }

map.put(key,value)插入的過程:先查找後插入>(簡化版)

  • 先計算哈希值hash(key),確定桶下標index;
    • 如果index處沒有衝突鏈,說明沒衝突,直接插入;
    • 如果index處有衝突鏈,說明有衝突;接下來遍歷這個衝突鏈,查找和待插入結點的key和哈希值都相等的結點;
      • 查找成功,則用新value覆蓋舊value,完成插入;
      • 查找失敗,插入到頭部;

 補充說明:

  • 如果插入的是空鍵,插入到index爲0的衝突鏈上,插入過程和上面一樣;
  • 插入結點過程中,如果遇到size >= threshold的情況,需要擴容;
  • 一般情況(同時複寫/不復寫key類的equals,hashcode方法),key相等(equals),哈希值相等;
  • index = hash % length = hash & (length-1);
  • 查詢或插入過程中,衝突指的是index衝突(即不同的key,相同的index);index衝突一般有兩種情況:①不同的key,相同的hash,相同的index;(has(key)函數導致的衝突)②不同的key,不同的hash,相同的index;(index = hash % length函數導致的衝突);
  • 哈希值計算公式和桶下標計算公式
哈希值計算:e.hash = h = hash(key);//hash(key)是對key.hashcode()的進一步處理,來保證均勻;
桶下標計算:index = h & (lenth-1) = h % length;//可證明,當n爲2的次冪時,y&(n-1)=y%n;
  • java 1.7 插入結點是頭插法,java 1.8 是尾插法,java1.8還有往紅黑樹上插入結點的過程;
  • put和remove都是先查找後操作的過程,查找鍵和哈希值都相等(key.equals(e.key)&&hash==e.hash)的結點;
//往HashMap裏添加結點
void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
        //當size超過臨界閾值threshold,並且即將發生哈希衝突時進行擴容
            resize(2 * table.length);//擴容爲原容量的兩倍
            hash = (null != key) ? hash(key) : 0;//我覺得這行代碼可以不要,因爲擴容前後hash(key)是不變的
            bucketIndex = indexFor(hash, table.length);//table.length更新爲原來2倍,重新計算index
        }
        createEntry(hash, key, value, bucketIndex);
}
//頭插法往當前index衝突鏈插入元素
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];//當前衝突鏈的老頭結點
    table[bucketIndex] = new Entry<>(hash, key, value, e);//新頭結點,next指向老頭結點
    size++;
}

3.6 擴容

3.6.1 擴容相關函數

//擴容
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];//爲新表分配內存,空間消耗
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//將舊錶上的所有結點內容複製到新表上,時間消耗較大
        table = newTable;//更新爲新表
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//更新threshold
    }

 這個方法進行了擴容(數組capcity爲原來的2倍)操作,並且將舊錶上的所有結點內容複製到新表上;有較大的(空間消耗和時間)消耗,所以要儘量減少擴容次數;

//複製表
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
     //for循環中的代碼,逐個遍歷鏈表,重新計算索引位置,將老數組數據複製到新數組中去(數組不存儲實際數據,所以僅僅是拷貝引用而已)
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {//是否重新計算哈希值,爲了提高效率,一般爲fasle
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //新桶下標index要麼不變,要麼加oldCapcity
                int i = indexFor(e.hash, newCapacity);//擴容前後,第一個參數h沒變,第二個參數分別是oldCapacity,newCapacity;
          //將當前entry的next鏈指向新的索引位置,newTable[i]有可能爲空,有可能也是個entry鏈,如果是entry鏈,直接在鏈表頭部插入。
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
}

 這個方法完成了將舊錶上的所有結點內容複製到新表上的過程,建立新表用的是頭插法(java 1.8用的是尾插法);這個過程非常耗時,時間複雜度是O(size);

3.6.2 擴容後桶下標index計算

int i = indexFor(e.hash, newCapacity);

 先看transfer方法中的這行代碼,它是用來計算每個結點在新表上的桶下標index;不論是否重新計算哈希值,計算新舊index傳入的第一個參數都是相同的,均爲hash(key);第二個參數,擴容前後傳入的分別是oldCapcity、newCpacity,newCpacity=2oldCapcity;計算桶下標的公式是index = h & (length-1);現設h=hash(key) = 21 (or 5) , oldLength=table.length=oldCapcity=16 , newLength=table.length=newCapcity=32;現在看看是如何計算的:

 擴容前後桶下標index的變化:對於一個結點的一個key,它的哈希值hash(key)的二進制第k位:新index的計算: - 如果第k位爲0:newIndex = oldIndex; - 如果第k位爲1:newIndex = oldIndex + oldCapcity;

 我們再觀察,當table的容量是2的次冪時,計算index時,length-1的低位均爲1,又因爲哈希值hash(key)之前經過一些處理本來就比較均勻,所以就能保證index也比較均勻;這是capcity爲2的次冪的第二個好處;

3.6.3 擴容後衝突鏈的變化

 先說一個結論:不同衝突鏈(index)上結點的哈希值hash(key)一定不同,同一條衝突鏈上的不同節點哈希值不一定相同。

 同一條衝突鏈上可能有的結點hash(key)的第k(5)位是0,有的是1。在擴容時,會把舊table上的所有節點複製到新table上,採用頭插法生成新表的衝突鏈。k爲0的結點擴容後index不變,在新表相同的index上插入;k爲1的結點擴容後index加16,在新表新ndex上插入。這樣一條衝突鏈就裂變成兩條鏈了。如上圖所示,一條鏈變成了兩條鏈。(上圖是java1.8尾插法)
 同一條衝突鏈上也有可能所有的結點的第k位都是0,這樣一條鏈在在擴容後只生成一條鏈並且index不變。

 同一條衝突鏈上也有可能所有的結點的第k位都是1,這樣一條鏈在在擴容後只生成一條鏈但index加16。

 由於java1.7中HashMap衝突鏈插入結點使用的是頭插法,新鏈結點順序相對於舊鏈逆置。如果是java1.8 改爲使用尾插法,所以新鏈結點順序不變。
 擴容後某條衝突鏈的變化有三種可能:

  • 一條鏈,index不變;
  • 一條鏈,index加16;
  • 兩條鏈,一條index不變,一條index加16;

 從這兒也說明了:HashMap使用put輸入多個元素然後遍歷輸出,元素輸入輸出次序可能會發生變化(包括jdk1.8)

3.7 capcity爲2的次冪的好處

 capcity爲2的次冪的好處主要體現在計算桶下標index時,我們回顧一下是怎麼計算key的哈希值hash(key)和桶下標index的:

哈希值計算:e.hash = h = hash(key);//hash(key)是對key.hashcode()的進一步處理,來保證均勻;
桶下標計算:index = h & (lenth-1) = h % length;//可證明,當n爲2的次冪時,y&(n-1)=y%n;

 我們都知道,計算機進行位運算比模運算要快得多;在計算index時,我們可以用位運算h & (len-1)替代模運算h % len以提高運算效率,但是這兩個結果相等的前提是length(=capcity)爲2的次冪;所以capcity爲2的次冪來保證模運算轉換爲位運算,這樣計算更快,這是第一個好處。

 我們觀察,當table的容量是2的次冪時,計算index時,length-1的低位均爲1,又因爲哈希值hash(key)之前經過一些處理本來就比較均勻,所以就能保證index也比較均勻;這是capcity爲2的次冪的第二個好處;

 我們看到,當table的容量是2的次冪時,length-1爲0...01…1形式;上面的&運算,h的高位不會對結果產生影響,所以我們只關注低位;因爲legth-1低位全部爲1,所以計算結果index的高位部分爲0,低位部分和h的低位部分一樣。因此index爲21對應的h低位只有一種組合,從而減少了index的衝突。當table的容量不是2的次冪時,length-1就不是0...01…1形式了,假設爲0000111101;無論h的低位起第二位是0還是1,index的結果都是21.因此index爲21對應的h低位有兩種組合,產生了index衝突。雖然說h不同的高位相同的低位部分,會得到相同index值而產生衝突,但這種衝突出現的概率後者是前者的兩倍。減少index衝突是capcity爲2的次冪的第三個好處;

capcity爲2的次冪的好處:計算桶下標index時,

  • 模運算轉位運算,更快;
  • index分佈均勻;
  • 減少index衝突;

3.8 查詢結點

3.8.1 get方法

map.ge(key)方法用於通過key查詢value;

//用於通過key查詢value;
public V get(Object key) {
     //如果key爲null,則直接去index爲0的衝突鏈處去遍歷即可
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
 }
//用於通過key查詢Entry對象
final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        //先計算key的哈希值
        int hash = (key == null) ? 0 : hash(key);
        //先確定index,再在指定衝突鏈遍歷查詢;遍歷過程和put方法類似
        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;
    }

3.8.2 get按鍵查詢的過程

map.get(key)查找的過程:先確定index,後遍歷衝突鏈,找鍵和哈希值都相等的結點(簡化版)

  • 先計算哈希值hash(key),確定桶下標index;
  • 如果index處沒有衝突鏈,查詢失敗,返回null;
  • 如果index處有衝突鏈,接下來遍歷這個衝突鏈,比較當前結點key和輸入key以及它們的哈希值;
    • 如果哈希值和key都相等,則查找成功,返回entry.value;
    • 否則比較下一個結點,直到遍歷找到了都相等的結點則查找成功;或直到當前結點e==null,則查找失敗;

3.8.3 containsValue按值查詢

    public boolean containsValue(Object value) {
        if (value == null)//如果是空值
            return containsNullValue();

        Entry[] tab = table;
        for (int i = 0; i < tab.length ; i++)//遍歷每一條鏈
            for (Entry e = tab[i] ; e != null ; e = e.next)
                if (value.equals(e.value))
                    return true;
        return false;
    }

map.containsValue(value)按值查詢的過程很簡單,從0號衝突鏈開始,遍歷每一條衝突鏈;

3.9 remove刪除結點

		//刪除指定key的結點並返回其value
    public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

		//刪除指定key的結點
    final Entry<K,V> removeEntryForKey(Object key) {
        int hash = (key == null) ? 0 : hash(key);//計算哈希值
        int i = indexFor(hash, table.length);//計算桶下標index
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;
        while (e != null) {//遍歷衝突鏈
            Entry<K,V> next = e.next;
            Object k;
            //查找key和hash都相等的結點
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                //查找成功
                modCount++;//hashmap結構發生變化
                size--;
                if (prev == e)//第一個結點就是要刪除的結點
                    table[i] = next;
                else
                    prev.next = next;//刪除結點
                e.recordRemoval(this);
                return e;
            }
            prev = e;//先緩存前驅
            e = next;
        }
        return e;
    }

4 插入鍵值對和按鍵查詢過程舉例

HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K1", "V3");
map.put("K4", "V4");
map.put("null", "V5");
System.out.println(map.get(K6));//null
System.out.println(map.get(K2));//V2
System.out.println(map.get(k7));//null
System.out.println(map.get(null));//V5

map.put(K,V)插入鍵值對:

  • 新建一個 HashMap,默認大小爲 8;
  • 插入 <K1,V1> 鍵值對,先計算 K1 的 哈希值 爲 177,桶下標爲 177%8=1,直接插入;
  • 插入 <K2,V2> 鍵值對,先計算 K2 的 哈希值 爲 185,桶下標爲 185%8=1,遍歷查找失敗,頭插法插入;
  • 插入 <K1,V3> 鍵值對,先計算 K1 的 哈希值 爲 177,桶下標爲 177%8=1,遍歷查找成功,覆蓋;
  • 插入 <K4,V4> 鍵值對,先計算 K4 的 哈希值 爲 40,桶下標爲 40%8=0,直接插入;
  • 插入 <null,V5> 鍵值對,空鍵哈希值爲0,頭插法插入;

 get(key)按鍵查詢value:

  • map.get(K6),先計算 K6 的 哈希值 爲 36,桶下標爲 36%8=4;index爲4處是空鏈,查詢失敗;
  • map.get(K2),先計算 K2 的 哈希值 爲 185,桶下標爲 185%8=1;遍歷查詢index爲1的衝突鏈,第一個結點就查找成功;
  • map.get(K7),先計算 K7 的 哈希值 爲 25,桶下標爲 25%8=1;遍歷查詢index爲1的衝突鏈,直到e==null也沒有找到key和哈希值都相等的結點;查詢失敗;
  • map.get(null),鍵爲空,直接去index爲0的衝突鏈查詢,第一個結點就查找成功;

 這是map最後的結構圖:

5 jdk 1.8的修改

變化 jdk 1.7 jdk 1.8 why
數據結構 數組+單鏈表 數組+單鏈表+紅黑樹 小於8,使用單鏈表,查詢成本高,插入成本低;大於等於8,使用紅黑樹,查詢成本低,插入成本高;(紅黑樹插入需要旋轉,慢)
單鏈表插入結點方式 頭插法 尾插法 頭插法缺點:在多線程情況下,擴容時,可能會產生循環鏈表,從而導致死循環;頭插法優點:最近put可能一會就要get;
put時內部操作順序 先擴容後插入 先插入後擴容
擴容後新index的計算 newIndex = hash & (newCapcity-1) newIndex = oldIndex + oldCapciyor newIndex = oldIndex 1.8直接使用1.7的計算規律,更快
哈希運算次數 更快
  • jdk 1.8 Hashmap結構:

6 HashMap常見面試題

(1) HashMap數據結構?
  • 1.7 數組+單鏈表;
  • 1.8 數組+單鏈表+紅黑樹(8);
(2) HashMap 的工作原理?

1.HashMap由數組+單鏈表構成,可以看成一個哈希表;HashMap主幹是一個Entry數組,用於存儲每個鏈表的頭結點的引用;

2.map.put(key,value)插入的過程:先查找後插入(簡化版)

  • 先計算哈希值hash(key),確定桶下標index;
    • 如果index處沒有衝突鏈,說明沒衝突,直接插入;
    • 如果index處有衝突鏈,說明有衝突;接下來遍歷這個衝突鏈,查找和待插入結點的key和哈希值都相等的結點;
      • 查找成功,則用新value覆蓋舊value,完成插入;
      • 查找失敗,插入到頭部;

3.map.get(key)查找的過程:先確定index,後遍歷衝突鏈,找鍵和哈希值都相等if(key.equals(e.key)&&hash==e.hash)的結點(簡化版)

  • 先計算哈希值hash(key),確定桶下標index;
  • 如果index處沒有衝突鏈,查詢失敗,返回null;
  • 如果index處有衝突鏈,接下來遍歷這個衝突鏈,比較當前結點key和輸入key以及它們的哈希值;
    • 如果哈希值和key都相等,則查找成功,返回entry.value;
    • 否則比較下一個結點,直到遍歷找到了都相等的結點則查找成功;或直到當前結點e==null,則查找失敗;
(3) HashMap 是否允許null鍵或者null值,如何處理?
  • HashMap允許空鍵和空值;
  • 空鍵的key-value直接插入到0號鏈上;
(4) HashMap get(key)查詢時比較的是什麼?
  • 表面上看,查詢成功要求key相等;
  • 本質上,查詢成功要求哈希值相等且key equals 返trueif(key.equals(e.key)&&hash==e.hash)
(5) HashMap是否允許有重複數據?
  • 一般情況,put時會覆蓋同key元素,不存在同key結點;
  • 允許存在多個同value結點;
(6) HashMap是有序的麼?其對應的有序Map類是什麼?
  • HashMap是無序的:put輸入順序和遍歷輸出順序可能不一致;
  • LinkedHashMap有序:可以是插入順序或者訪問順序(LRC順序),由accessOrder屬性控制
(7) 爲什麼 HashMap 中 String、Integer 這樣的包裝類適合作爲 key 鍵?
(8) HashMap 中的 key若 Object類型, 則需實現(複寫)哪些方法?這些方法的作用是什麼?
  • hashcode方法:是定位的,存儲位置;
  • equals是定性的,比較兩者是否相等;
(9) HashMap 中的哈希值hash(key)和桶下標index是如何計算的?
  • 計算哈希值h = hash(key):①key.hashcode(); ② 對key.hashcode()進行擾動處理:
    • jdk 1.7 :4次右移,5次異或;
    • jdk 1.8 :1次右移,1次異或;
  • 計算桶下標:
    • jdk 1.7 :擴容前後一樣index = h & (length-1)
    • jdk 1.8 :擴容前和1.7一樣,擴容後newIndex = oldIndex + oldCapciyor newIndex = oldIndex
(10) HashMap數組的容量爲什麼要求是2的次冪(作用、好處)?

 計算桶下標index時,

  • 模運算轉位運算,更快;
  • index分佈均勻;
  • 減少index衝突;
(11) 脫離HashMap說說哈希表的原理?
  • 定義:根據Key直接訪問的數據結構,建立了關鍵字和地址之間的一種映射關係;
  • 哈希碰撞(衝突):不同的key,得到相同的哈希地址;哈希碰撞無法避免,只能減少碰撞;設計好的哈希函數減少碰撞,發生碰撞了要處理碰撞;
  • 哈希函數:Addr = Hash(key);常見的哈希函數:
    • 除留餘數法:Hash(key) = key % len
    • 直接定址法:Hash(key) = a*key+b
    • 平方取中法:取關鍵字平方的中間幾位作爲哈希地址;
    • 摺疊法:將關鍵字分割成位數相同的幾部分,然後取它們的疊加和;
    • 數字分析法:選關鍵字數碼分佈較爲均勻的若干位;
  • 處理衝突的方法:
    • 開放定址法:Hi = (H(key)+di)%m ; H(key)表示發生衝突的哈希函數,m表長,Hi 表示衝突後第I次探測的地址,di表示增量序列,i(0,1,2,…,m-1);增量序列di的取法:
      • 線性探測法:di = 0,1,2,…,m-1;
      • 平方探測法:di = 02,12,-12,22,-22,…,k2,-k2; 其中k<=m/2;
      • 再哈希法:di = H2(key);
      • 僞隨機序列法:di = 僞隨機序列;
    • 拉鍊法:數組+單鏈表,數組元素存儲每個鏈表頭結點的引用;
    • HashMap使用的就是拉鍊法;ThradLocalMap使用的就是開放定址法;
(12) HashTable的主要參數有哪些?它們的作用分別是什麼
參數 含義
capacity table的容量大小,默認爲 16; 可由用戶在構造器設置,但調整後最終capacity 一定是2 的次冪;最大值是230
size Entry(鍵值對)個數;
threshold size 的臨界值,當 size >= threshold 就必須進行擴容;
loadFactor 裝載因子,默認值0.75,table 能夠使用的比例;threshold = capacity * loadFactor; 可由用戶在構造器設置;
(13) 擴容過程 (jdk 1.7)?
  • 在put過程中,如果size >= threshold,就需要擴容(2倍);
  • 擴容調用resize方法,在resize中調用transfer方法將舊數組中的數據複製到新數組中去;
  • 調用transfer方法複製數據時,對每個鏈表進行遍歷,先計算當前結點新桶下標index,將當前結點按頭插法插入到新表中;
  • 最後更新 threshold;
(14) 擴容後衝突鏈的結構會有什麼變化?

 存在三種可能的變化:

  • 一條鏈,index不變;
  • 一條鏈,index加16;
  • 兩條鏈,一條index不變,一條index加16;
(15) 擴容過程有什麼問題麼?

 從舊數組複製數據到新數組這個過程會遍歷每個鏈表的每個結點,時間複雜度O(size)時間消耗較大;所以當HashMap數據量較大時,擴容會帶來較大的性能損耗;在性能要求很高的地方,這種損失很可能很致命。

(16) jdk 1.8 對HashMap的修改有哪些?

 見上文最後一節;

(17) HashMap的遍歷方式有哪些?
        //foreach map.keySet()方式
        for (String key : map.keySet()){
            System.out.println(key+" - "+map.get(key));
        }
        //foreach map.entrySet()方式
        for (Map.Entry<String,Integer> entry : map.entrySet()){
            System.out.println(entry.getKey()+" - "+entry.getValue());
        }
        //keySet的iterator方式
        Iterator<String> iterator = map.keySet().iterator();
        while (iterator.hasNext()){
            String key=iterator.next();
            System.out.println(key+" - "+map.get(key));
        }
				//entrySet的iterator方式
        Iterator<Map.Entry<String,Integer>> iterator2 = map.entrySet().iterator();
        while (iterator2.hasNext()){
            Map.Entry<String,Integer> entry = iterator2.next();
            System.out.println(entry.getKey()+" - "+entry.getValue());
        }
(18) HashMap & TreeMap & LinkedHashMap 使用場景?

 一般情況下,使用最多的是 HashMap;

  • HashMap:在 Map 中插入、刪除和查詢元素時;
  • TreeMap:該類持有Compartor的引用,在需要排序的情景下使用;
  • LinkedHashMap:在要求輸出輸入的順序相同的情況下使用;
(19) ConcurrentHashMap JDK 1.7 VS JDK 1.8?
  • JDK 1.7 :數據結構是數組+單鏈表;分段鎖Segment 繼承自ReentrantLock保證併發安全,每個Segment 維護若干個桶;鎖粒度是Segment 對象;
  • JDK 1.8 :數據結構是數組+單鏈表+紅黑樹;取消了Segment ,用Node + CAS + Synchronized(CAS失敗後使用)來保證併發安全;鎖粒度減小爲Node對象;
(20) HashTable VS HashMap VS ConcurrentHashMap
指標 HashTable HashMap ConcurrentHashMap
- 遺棄類 常用類 併發工具類
數據結構 數組+單鏈表 數組+單鏈表(+1.8紅黑樹) 1.7數組+Segment+單鏈表;
1.8數組+單鏈表+紅黑樹
線程安全 安全 不安全 安全
效率 最高
鎖粒度 整個Map - 1.7是Segment;
1.8是Node
線程安全實現 synchronized同步方法,Map對象鎖 - 1.7 Reentrant;
1.8 CAS+synchronized(前者失敗)
空鍵空值 都不允許 允許空鍵空值 都不允許
數組容量capcity 默認數組容量爲16 默認值爲16,擴容爲2capcity 同HashMap
哈希值 直接使用key.hashcode hash(key)對key.hashcode進行了右移異或擾動處理 與HashMap類似
(21)HashMap爲什麼不是線程安全的?1.8後resize()不會出現循環鏈表爲何仍然不是線程安全的?

HashMap不是線程安全本質原因是remove,put等方法壓根沒有使用同步手段;1.7resize()可能出現死循環只是線程不安全的最嚴重後果;

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