歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux編程 >> Linux編程 >> 深入分析HaspMap源碼

深入分析HaspMap源碼

日期:2017/3/1 9:10:37   编辑:Linux編程

1.分析HaspMap的構造器

前面分析HashMap的put(K key,V value)源碼的時候發現,其中有兩個特殊的變量:

  • size:該變量保存了該HashMap中所包含的key-value對的數量。
  • threshold:該變量包含了HashMap能容納的key-value對的極限,它的值等於HashMap的容量乘以負載因子(load factor)。

在HashMap的addEntry方法中,當size++>=threshold時。HashMap會自動調用resize方法擴充HashMap的容量。但每擴充一次,HashMap的容量就增大一倍。

HashMap源碼中存在一個就table的數組。這個數組的長度其實就是HashMap的容量。HashMap包含如下幾個構造器:

  • HashMap() 初始容量為16。負載因子為0.75的HashMap。
  • HashMap(int initialCapacity) 構建一個初始容量為 initialCapacity,負載因子為0.75的HashMap
  • HashMap(int initialCapacity,float loadFactor) 構建指定初始容量和負載因子的HashMap

當創建一個HasMap時,系統會自動創建一個table數組來保存HashMap中的Entry。
觀察構造器的源碼:

//以指定初始化容量,負載因子創建HashMap
  public HashMap(int initialCapacity, float loadFactor) {
  //初始容量不能為負數
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //初始容量不能太大
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //負載因子必須大於0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
    //計算出大於initialCapacity的最小的2的n次方值
    int capacity = 1;
    while(capacity<initialCapacity)
      capacity<<=1;
       this.loadFacotor = loadFactor;
       //設置容量極限等於容量乘以負載因子
       threshold = (int)(capacity * loadFactor);
       //初始化table數組
       table = new Entry(capacity);
       init();
    }

注意觀察下面這兩段代碼:

    int capacity = 1;
    while(capacity<initialCapacity)
      capacity<<=1;

找出大於initialCapacity的,最小的2的n次方值,並將其作為HashMap的實際容量。例如給定initialCapacity為10,那麼HashMap的實際容量就是16。通常來說,HaspMap最後的實際容量通常比initialCapacity大一點,除非它剛好是2的n次方,所以我們在創建HaspMap需要指定容量時指定為2的n次方可以減少不必要的開銷。

補充:

<< 把數據向左移動。移除的刪除,右邊用0補齊 相當於乘以2的移動次冪
>> 把數據向右移動。移除的刪除 ,左邊用最高位補齊 相當於除以2的移動次冪

2.HashMap根據key取出value

對於HashMap及其子類而言,它們采用Hash算法來決定集合中元素的存儲位置。當系統開始初始化HashMap時,系統會創建一個長度為Capacity的Entry的數組。這個數組裡可以存儲元素的位置被稱為”桶(bucket)”,每個bucket都有其特定的索引,系統可以根據其索引快速訪問該bucket裡存儲的元素。

一般情況下,bucket裡存儲的是單個Entry,但也有會生成Entry鏈的情況(即兩個key的hash值相同但equals返回false)。當bucket裡面存儲的是單個Entry,此時HaspMap性能最好。當程序需要根據key取出value時,只需要計算出key的hash值,再根據該hash值找出key在table數組中的索引,然後取出該索引處的Entry,最後返回該Entry的value即可。下面我們來看HashMapget(K key)的源碼:

public V get(Object key) {
//如果key是null。調用getForNullKey
    if (key == null)
        return getForNullKey();
        //計算key的hash值
        int hash = hash(key.hashCode());
        //直接取出table數組中的指定索引處的值
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             //搜索下一個Entry
             e = e.next) {
            Object k;
            //如果該Entry的key與被搜索key相同
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

從上面代碼可以看出,當HashMap的每個bucket裡只有一個Entry,HaspMap可以快速從bucket裡取出Entry。在發生”Hash沖突”的情況下,單個bucket裡存儲的不是一個Entry,而是Entry鏈,此時系統只能通過遍歷每個Entry,直到找到搜索的Entry為止。

總結一下:

  • HaspMap在底層把一個KEy-Value當成一個整體Entry對象來處理。
  • HaspMap底層通過一個Entry[]數組來保存所有的key-value對。
  • 對於每一個要存儲的key-value,系統通過Hash算法確定其存儲位置。
  • 當需要取出一個Entry時,也會根據Hash值來找到其在數組中存儲位置

再談負載因子loadFactor:

HaspMap有一個默認的負載因子值0.75。這是時間和空間成本上的一種折衷:

  • 增大負載因子可以減少Hash表(就是那個Entry[]數組)所占用內存空間,但會增大查詢數據的時間開銷,而查詢是最頻繁的操作(HashMap的get()和put()都要查詢)

  • 減少負載因子可以會提高數據查詢的性能,但會增加Hash表所占用的內存空間。

現在我們合理的調整負載因子的值了。如果程序比較關心內存的開銷,適當增加負載因子。如果比較關心時間開銷,則適當減小負載因子,其實大部分情況下保持負載因子默認的0.75即可。

多線程環境下,使用HashMap進行put操作引起的問題分析

在多線程的環境下,多個線程對HashMap數據進行put操作時會導致死循環。而造成這個原因的罪魁禍首就是因為HashMap的自動擴容機制。

我們來觀察HashMap的put過程

public V put(K key, V value) {  
            if (table == EMPTY_TABLE) {  
                inflateTable(threshold);  
            } 
            //當key為null,調用putForNullKey來處理 
            if (key == null)  
                return putForNullKey(value);  
                //根據key的hashCode計算hash值
            int hash = hash(key);  
            //搜索制定hash值在對於table中的索引
            int i = indexFor(hash, table.length);
            //如果i處索引不為null。則通過循環不斷遍歷e元素的下一個元素  
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
                Object k;  
                //找到指定key與需要放入key相等(即兩者hash值相同,equals返回true)
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
                    V oldValue = e.value;  
                    e.value = value;  
                    e.recordAccess(this);  
                    return oldValue;  
                }  
            }  
      //如果i索引處的entry為null。表明此次沒有entry。直接插入在此次即可
            modCount++;  
      //添加key,value到索引i處
            addEntry(hash, key, value, i);  
            return null;  
        }    

現在我們可以看出:當程序試圖將一個key-value對放入HashMap中時,首先根據key的hashCode()返回值決定該Entry的存儲位置,如果兩個key的hash值相同,那麼它們的存儲位置相同。如果這個兩個key的equalus比較返回true。那麼新添加的Entry的value會覆蓋原來的Entry的value,key不會覆蓋。如果這兩個equals返回false,那麼這兩個Entry會形成一個Entry鏈,並且新添加的Entry位於Entry鏈的頭部。

觀察addEntry(hash, key, value, i);源碼:

   void addEntry(int hash, K key, V value, int bucketIndex) {  
         //獲取指定bucketIndex處的Entry
        Entry<K,V> e = table[bucketIndex];
        //將新創建的Entry放入bucketIndex索引處,並讓新Entry指向原來的Entry  
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
       //如果Map中的key-value對的數量超過了極限
       if(size++>=threshold){
       //擴充table對象的長度到2倍
        resize(2*table.length);
        }
    }  

現在我們看罪魁禍首的resize方法的源碼:

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //創建一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //復制舊數據到新數組中:
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

復制舊數據到新數組中源碼:


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);
        }
    }
}

畫了個圖做了個演示。

  • 我假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
  • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以後都沖突在table[1]這裡了。
  • 接下來的三個步驟是Hash表 resize成4,然後所有的<key,value> 重新rehash的過程

多線程下的rehash:

1)假設我們有兩個線程。我用紅色和淺藍色標注了一下。

我們再回頭看一下我們的 transfer代碼中的這個細節:

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);

而我們的線程二執行完成了。於是我們有下面的這個樣子。

注意,因為Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表。我們可以看到鏈表的順序被反轉後。

2)線程一被調度回來執行。

先是執行 newTalbe[i] = e;
然後是e = next,導致了e指向了key(7),
而下一次循環的next = e.next導致了next指向了key(3)

3)一切安好。

線程一接著工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

4)環形鏈接出現。

e.next = newTable[i] 導致 key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

於是,悲劇出現了。next不為null。陷入while死循環。

Copyright © Linux教程網 All Rights Reserved