HashMap源碼解析之jdk1.7

jdk 1.7HashMap 底層實現是數組+鏈表(爲什麼用鏈表呢?詳情看問題五中)。
存儲結構
在這裏插入圖片描述

哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表,而HashMap的實現原理就是基於此。
幾種數據結構之間的情況對比:
1、數組:採用一段連續的內存空間來存儲數據。對於指定下標的查找,時間複雜度爲O(1),對於給定元素的查找,需要遍歷整個數據,時間複雜度爲O(n)。但對於有序數組的查找,可用二分查找法,時間複雜度爲O(logn),對於一般的插入刪除操作,涉及到數組元素的移動,其平均時間複雜度爲O(n)。對應到集合實現,代表就是ArrayList。
2、二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均複雜度均爲O(logn)。對應的集合類有TreeSet和TreeMap。
3、線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間複雜度爲O(1),而查找操作需要遍歷整個鏈表,複雜度爲O(n)。對應的集合類是LinkedList。
4、哈希表:也叫散列表,用的是數組支持元素下標隨機訪問的特性,將鍵值映射爲數組的下標進行元素的查找。所以哈希表就是數組的一種擴展,將鍵值映射爲元素下標的函數叫做哈希函數,哈希函數運算得到的結果叫做哈希值。哈希函數的設計至關重要,好的哈希函數會儘可能地保證計算簡單和散列地址分佈均勻。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱爲哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。這會涉及到哈希衝突。
  哈希衝突(也叫哈希碰撞):不同的鍵值通過哈希函數運算得到相同的哈希值。哈希衝突的解決方案有多種:開放定址法(發生衝突,繼續尋找下一塊未被佔用的存儲地址)、再散列函數法、鏈地址法。ThreadLocalMap由於其元素個數較少,採用的是開放尋址法,而HashMap採用的是鏈表法來解決哈希衝突,即所有散列值相同的元素都放在相同槽對應的鏈表中(也就是數組+鏈表的方式)
  HashMap是由數組+鏈表構成的,即存放鏈表的數組,數組是HashMap的主體,鏈表則是爲了解決哈希碰撞而存在的,如果定位到的數組不包含鏈表(當前的entry指向爲null),那麼對於查找,刪除等操作,時間複雜度僅爲O(1),如果定位到的數組包含鏈表,對於添加操作,其時間複雜度爲O(n),首先需要遍歷鏈表,存在相同的key則覆蓋value,否則新增;對於查找操作,也是一樣需要遍歷整個鏈表,然後通過key對象的equals方法逐一比對,時間複雜度也爲O(n)。所以,HashMap中鏈表出現的越少,長度越短,性能才越好,這也是HashMap設置閥值即擴容的原因。

一、前期

默認參數配置

/** 初始容量,2^4,默認16 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 最大初始容量,2^30 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 臨界值(HashMap 實際能存儲的大小),公式爲(threshold = capacity * loadFactor) */
int threshold; //默認構造時默認爲DEFAULT_INITIAL_CAPACITY = 16
//負載因子,代表了table的填充度有多少,默認是0.75
final float loadFactor;
//用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
transient int modCount;
//默認裝載因子  
static final float DEFAULT_LOAD_FACTOR = 0.75f;

構造方法
無參最終傳入默認的初始容量,加載因子

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);
        // 設置負載因子,臨界值此時爲容量大小,後面第一次put時由inflateTable(int toSize)方法計算設置
        this.loadFactor = loadFactor;
        //初始閾值爲初始容量,與JDK1.8中不同,JDK1.8中調用了tableSizeFor(initialCapacity)得到大於等於初始容量的一個最小的2的指數級別數,比如初始容量爲12,那麼threshold爲16,;如果初始容量爲5,那麼初始容量爲8
				threshold = initialCapacity;
        init();//空實現
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);
        putAllForCreate(m);
    }

計算hash值 - jdk 1.8有變動

final int hash(Object k) {
        int h = hashSeed;//默認爲0
        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);
    }

initHashSeedAsNeeded()方法,

final boolean initHashSeedAsNeeded(int capacity) {
    //當我們初始化的時候hashSeed爲0,0!=0 這時爲false.
        boolean currentAltHashing = hashSeed != 0;
        //isBooted()這個方法裏面返回了一個boolean值,我們看下面的代碼
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
        return switching;
    }

默認返回false,但是測試時發現vm啓動後賦值爲true,所以在上面initHashSeedAsNeeded()方法中,主要看capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD,然後決定是否需要重新賦值hashSeed。否則默認爲0。

private static volatile boolean booted = false;
    public static boolean isBooted() {
        return booted;
    }

關於變量Holder.ALTERNATIVE_HASHING_THRESHOLD,發現其值就是threshold。所以主要看該等式是否成立:capacity >= threshold

static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;//這裏爲2147483647,它是HashMap的屬性,初始化的時候就已賦值
    //Holder這個類是HashMap的子類,
     private static class Holder {
     //這裏定義了我們需要的常量,但是它沒賦值,我們看看它是怎麼賦值的?
        static final int ALTERNATIVE_HASHING_THRESHOLD;

        static {
            String altThreshold = java.security.AccessController.doPrivileged(
                new sun.security.action.GetPropertyAction(
                    "jdk.map.althashing.threshold"));

            int threshold;
            try {
                threshold = (null != altThreshold)
                        ? Integer.parseInt(altThreshold)
                        : ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;

                // disable alternative hashing if -1
                if (threshold == -1) {
                    threshold = Integer.MAX_VALUE;
                }

                if (threshold < 0) {
                    throw new IllegalArgumentException("value must be positive integer.");
                }
            } catch(IllegalArgumentException failed) {
                throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
            }

            ALTERNATIVE_HASHING_THRESHOLD = threshold;
        }
    }

二、增/改

public V put(K key, V value) {  
    // 如果table引用指向成員變量EMPTY_TABLE,那麼初始化HashMap(設置容量、臨界值,新的Entry數組引用)
    // 默認容量16,threshold = loadfactor
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    
    // 若“key爲null”,則將該鍵值對添加到table[0]處,遍歷該鏈表,如果有key爲null,則將value替換。沒有就創建新Entry對象放在鏈表表頭
    // 所以table[0]的位置上,永遠最多存儲1個Entry對象,形成不了鏈表。key爲null的Entry存在這裏 
    if (key == null)  
        return putForNullKey(value);
    
    // 若“key不爲null”,則計算該key的哈希值
    int hash = hash(key);  
    // 搜索指定hash值在對應table中的索引
    int i = indexFor(hash, table.length);  
    // 循環遍歷table數組上存在的Entry對象的鏈表,判斷該位置上hash是否已存在
    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))) {  
            // 如果這個key對應的鍵值對已經存在,就用新的value代替老的value,並返回老的value!
            V oldValue = e.value;
            e.value = value;  
            e.recordAccess(this);  
            return oldValue;
        }  
    }  
    // 修改次數+1
    modCount++;
    // table數組中沒有key對應的鍵值對,就將key-value添加到table[i]處 
    addEntry(hash, key, value, i);  
    return null;  
}

懶加載,新增的時候初始化hash map

	//初始化HashMap
  private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize - 找到一個 >= toSize的2^x次方數,即是2的次冪增長的
        int capacity = roundUpToPowerOf2(toSize); // 實現了增長爲2的冪運算. 實現也比較簡單

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);//初始化hashSeed變量
    }

找到一個 >= number的2^x次方數,即是2的次冪

 private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; 
        // 方法Integer.highestOneBit((number - 1) << 1) 即2^x <= ((number-1)<<1);如number = 7, (6<<1) =6*2^1=12 ,需要(2^3 = 8) >= 6,返回2^3
        //即該方法會返回一個大於等於(number-1)的2^x數,而Integer.highestOneBit(number) 返回的是小於等於number的2^x數。造成這個原因的是<<1將number的值變大
        //爲什麼要number-1?如果number是2^x的值,那麼返回的是number。如number=8,Integer.highestOneBit(number << 1)返回16,不合需要做的含義
        //,Integer.highestOneBit((number - 1) << 1)返回的是8
    }

插入鍵爲null的值

    private V putForNullKey(V value) {
        //可以看到鍵爲null的值永遠被放在哈希表的第一個桶中,即永遠放在table[0]中,再次入值時,只會覆蓋原來的,不會形成鏈表
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //一旦找到鍵爲null,替換舊值
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果第一個桶中爲null或沒有節點的鍵爲null的,插入新節點
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

新增一個Entry

void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果尺寸已將超過了閾值並且桶中索引處不爲null
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //擴容2倍
            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) {
        Entry<K,V> e = table[bucketIndex];
        //將該節點作爲頭節點,此時如果hash衝突,則頭插法,next指向原來的頭
        table[bucketIndex] = new Entry<>(hash, key, value, e); 
        //尺寸+1,這個值在獲取、刪除時都有用到
        size++;
    }

三、查

獲取key的的value值

    public V get(Object key) {
    		//如果Key值爲空,則獲取對應的值,這裏也可以看到,HashMap允許null的key,其內部針對null的key有特殊的邏輯(詳細看插入時的操作)
        if (key == null) 
            return getForNullKey();  
        Entry<K,V> entry = getEntry(key);//獲取實體  

        return null == entry ? null : entry.getValue();//判斷是否爲空,不爲空,則獲取對應的值  
    }

獲取key爲null的值

    private V getForNullKey() {  
    	//如果元素個數爲0,則直接返回null;說明沒有值插入或者已刪除完
        if (size == 0) {
            return null;  
        }  
        //key爲null的元素存儲在table的第0個位置;這個循環最多做一次操作,因爲插入時值存儲最新key爲null的value
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {  
            if (e.key == null)//判斷是否爲null  
                return e.value;//返回其值  
        }  
        return null;  
    } 

獲取鍵值爲key的元素

    final Entry<K,V> getEntry(Object key) {  
        if (size == 0) {//元素個數爲0  
            return null;//直接返回null  
        }  

        int hash = (key == null) ? 0 : hash(key);//獲取key的Hash值
        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))))//判斷Hash值和對應的key,合適則返回值  
                return e;  
        }  
        return null;  
    } 

關於e.hash == hash在代碼中是否加入問題?詳情看

四、刪

public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value); //判斷
    }
    
final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) { //map中無值,直接返回null
            return null;
        }
        
        //計算hash值
        int hash = (key == null) ? 0 : hash(key);
        //得到桶索引
        int i = indexFor(hash, table.length);
        //記錄待刪除節點的前一個節點
        Entry<K,V> prev = table[i];
        //待刪除節點
        Entry<K,V> e = prev;

        //遍歷
        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--; //數目減1
                //在鏈表中的操作
                if (prev == e) // 如果恰好是prev,則將下一結點作爲頭結點
                    table[i] = next;
                else
                    prev.next = next; //否則指向刪除結點的子結點
                e.recordRemoval(this);
                return e;
            }
            //不匹配,繼續遍歷
            prev = e;
            e = next;
        }
				// 不存在,返回null
        return e;
    }

五、擴容

1.7的擴容是插入之前之前判斷,而1.8是插入之後再判斷是否需要擴容,不過都是擴容2倍 resize(2 * table.length)。
擴容到新容量 - 擴容容易出現併發問題,線程不安全

 void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //如果舊容量已經達到了最大,將閾值設置爲最大值,與1.8相同
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        
        //創建新哈希表
        Entry[] newTable = new Entry[newCapacity];
        //將原數組中的元素遷移到擴容後的數組中 
        //死循環就是在這個方法中產生的
        transfer(newTable, initHashSeedAsNeeded(newCapacity)); // boolean initHashSeedAsNeeded()判斷是否重新獲取隨機的hashSeed,這個值在hash()中用到
        table = newTable;
        //更新閾值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

如果hashSeed變了,那麼rehash爲true,否則爲false

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍歷舊錶
        for (Entry<K,V> e : table) {
            //當桶不爲空
            while(null != e) { //循環遍歷鏈表中的entry
                Entry<K,V> next = e.next;
                //如果hashSeed變了,需要重新計算hash值
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //得到新表中的索引,i的值有可能不同,所以可能和原來的存儲位置不同
                int i = indexFor(e.hash, newCapacity);
                //將新節點作爲頭節點添加到桶中
                //採用鏈頭插入法將e插入i位置,最後得到的鏈表相對於原table正好是頭尾相反的(新值總是插在最前面)
                e.next = newTable[i];
                newTable[i] = e;
                //下一輪循環
                e = next;
            }
        }
    }

boolean initHashSeedAsNeeded()判斷是否重新獲取隨機的hashSeed,這個值在hash()中用到。

五、問題

1、爲什麼增刪改查時,判斷時e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))),爲什麼加入e.hash == hash??
有人覺得上面在定位到數組位置之後然後遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前對象不一致,這種情況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null。

2、爲什麼容量大小設置2^x次冪形式?
計算存放位置的公式爲h & (length-1),如果length的值是2^x,如length爲32,二進制爲0010 0000,length - 1二進制爲0001 1111,此時如果和有序hash進行與運算,總是有序且均勻獲取到下標。從而減少碰撞,即減少hash衝突且值能均勻分佈。如果不是2^x,那麼hash衝突變大,鏈表變長,性能就會下降。
該計算方式等價h & (length-1) == h%length

link:https://blog.csdn.net/eaphyy/article/details/84386313

3、爲什麼底層用鏈表?
鏈地址法處理hash衝突,形成鏈表,且單向鏈表的速度高於數組。對於hash相同的key,取模獲取的數組下標index肯定也相同,所以此時用鏈表存儲不同的value。當get(key)時,即使獲取的數組下標index相同,比較hash值是否相同而獲取value。

4、爲什麼說HashMap線程不安全?
在擴容時多線程會出現死鏈。
兩個線程A,B同時對HashMap進行resize()操作,在執行transfer方法的while循環時,若此時當前槽上的元素爲a–>b–>null
  1.線程A執行到 Entry<K,V> next = e.next;時發生阻塞,此時e=a,next=b
  2.線程B完整的執行了整段代碼,此時新表newTable元素爲b–>a–>null
  3.線程A繼續執行後面的代碼,執行完一個循環之後,newTable變爲了a<–>b,造成while(e!=null) 一直死循環,CPU飆升

5、多線程下擴容可能會出現數據丟失
同樣在resize的transfer方法上
  1.當前線程遷移過程中,其他線程新增的元素有可能落在已經遍歷過的哈希槽上;在遍歷完成之後,table數組引用指向了newTable,這時新增的元素就會丟失,被無情的垃圾回收。
  2.如果多個線程同時執行resize,每個線程又都會new Entry[newCapacity],此時這是線程內的局部變量,線程之前是不可見的。遷移完成後,resize的線程會給table線程共享變量,從而覆蓋其他線程的操作,因此在被覆蓋的new table上插入的數據會被丟棄掉。

6、爲什麼hash衝突時,新數據放在鏈表頭部?
頭部快,如果放在其他地方,還需要檢索,鏈表過長,性能底下

7、多個key爲null的情況,hashmap怎麼處理?
對於key爲null的值,默認放在table[0]位置。新增時先判斷原先是否有值,沒有則新建一個entry對象;有則判斷key是否爲null,不爲null,則覆蓋value值。

link:link:https://www.cnblogs.com/liyus/p/9916562.html

六、總結

1、HashMap是基於哈希表的 Map 接口的實現。此實現提供所有可選的映射操作,並允許使用 null 值和 null 鍵。(除了非同步和允許使用 null 之外,HashMap 類與 Hashtable 大致相同。)此類不保證映射的順序,特別是它不保證該順序恆久不變(發生擴容時,元素位置會重新分配)

2、對key進行hash計算,並int i = hashKey % table.length計算存在數組中的下標(下標範圍[0, table.length -1])

3、JDK1.7和JDk1.8
對JDK1.7和JDk1.8中HashMap的相同與不同點做出總結。
首先是相同點:

  1. 默認初始容量都是16,默認加載因子都是0.75。容量必須是2的指數倍數
  2. 擴容時都將容量增加1倍,原來的2倍
  3. 根據hash值得到桶的索引方法一樣,都是i=hash&(cap-1)
  4. 初始時表爲空,都是懶加載,在插入第一個鍵值對時初始化
  5. 鍵爲null的hash值爲0,都會放在哈希表的第一個桶中

接下來是不同點,主要是思想上的不同,不再糾結與實現的不同:

  1. 最爲重要的一點是,底層結構不一樣,1.7是數組+鏈表,1.8則是數組+鏈表+紅黑樹結構
  2. 主要區別是插入鍵值對的put方法的區別。1.8中會將節點插入到鏈表尾部,而1.7中會將節點作爲鏈表的新的頭節點
  3. JDk1.8中一個鍵的hash是保持不變的,JDK1.7時resize()時有可能改變鍵的hash值
  4. rehash時1.8會保持原鏈表的順序,而1.7會顛倒鏈表的順序
  5. JDK1.8是通過hash&cap==0將鏈表分散,而JDK1.7是通過更新hashSeed來修改hash值達到分散的目的

link:
https://blog.csdn.net/qq_19431333/article/details/61614414
https://blog.csdn.net/xiaokang123456kao/article/details/77503784

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