Java集合——HashMap、HashTable以及ConCurrentHashMap異同比較


0. 前言

 

HashMap和HashTable的區別一種比較簡單的回答是:

(1)HashMap是非線程安全的,HashTable是線程安全的。

(2)HashMap的鍵和值都允許有null存在,而HashTable則都不行。

(3)因為線程安全、哈希效率的問題,HashMap效率比HashTable的要高。

但是如果繼續追問:Java中的另一個線程安全的與HashMap功能極其類似的類是什么?

同樣是線程安全,它與HashTable在線程同步上有什么不同?帶着這些問題,開始今天的文章。

本文為原創,相關內容會持續維護,轉載請標明出處:http://blog.csdn.net/seu_calvin/article/details/52653711

 

1.  HashMap概述

 

Java中的數據存儲方式有兩種結構,一種是數組,另一種就是鏈表,前者的特點是連續空間,尋址迅速,但是在增刪元素的時候會有較大幅度的移動,所以數組的特點是查詢速度快,增刪較慢。

而鏈表由於空間不連續,尋址困難,增刪元素只需修改指針,所以鏈表的特點是查詢速度慢、增刪快。

那么有沒有一種數據結構來綜合一下數組和鏈表以便發揮他們各自的優勢?答案就是哈希表。哈希表的存儲結構如下圖所示:

 

從上圖中,我們可以發現哈希表是由數組+鏈表組成的,一個長度為16的數組中,每個元素存儲的是一個鏈表的頭結點,通過功能類似於hash(key.hashCode())%len的操作,獲得要添加的元素所要存放的的數組位置。

HashMap的哈希算法實際操作是通過位運算,比取模運算效率更高,同樣能達到使其分布均勻的目的,后面會介紹。

鍵值對所存放的數據結構其實是HashMap中定義的一個Entity內部類,數組來實現的,屬性有key、value和指向下一個Entity的next。

 

 

2.  HashMap初始化

 

HashMap有兩種常用的構造方法:

第一種是不需要參數的構造方法:

static final int DEFAULT_INITIAL_CAPACITY = 16; //初始數組長度為16  
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量為2的30次方  
//裝載因子用來衡量HashMap滿的程度  
//計算HashMap的實時裝載因子的方法為:size/capacity  
static final float DEFAULT_LOAD_FACTOR = 0.75f; //裝載因子  
  
public HashMap() {    
    this.loadFactor = DEFAULT_LOAD_FACTOR;    
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);  
//默認數組長度為16   
    table = new Entry[DEFAULT_INITIAL_CAPACITY];  
    init();    
}  
 

第二種是需要參數的構造方法:

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);    
  
        // Find a power of 2 >= initialCapacity    
        int capacity = 1;    
        while (capacity < initialCapacity)    
            capacity <<= 1;    
    
        this.loadFactor = loadFactor;    
        threshold = (int)(capacity * loadFactor);    
        table = new Entry[capacity];    
        init();    
}   
 

從源碼可以看出,初始化的數組長度為capacity,capacity的值總是2的N次方,大小比第一個參數稍大或相等。

 

3.  HashMap的put操作

public V put(K key, V value) {    
        if (key == null)    
          return putForNullKey(value);    
        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;    
            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);    
        return null;    
}  
 

3.1  put進的key為null

 

private V putForNullKey(V value) {    
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {    
            if (e.key == null) {    
                V oldValue = e.value;    
                e.value = value;    
                e.recordAccess(this);    
                return oldValue;    
            }    
        }    
        modCount++;    
        addEntry(0, null, value, 0);    
        return null;    
}   
  
void addEntry(int hash, K key, V value, int bucketIndex) {    
    Entry<K,V> e = table[bucketIndex];    
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);    
        if (size++ >= threshold)    
            resize(2 * table.length);    
    } 

從源碼中可以看出,HashMap是允許key為null的,會調用putForNullKey()方法:

 

putForNullKey方法會遍歷以table[0]為鏈表頭的鏈表,如果存在key為null的KV,那么替換其value值並返回舊值。否則調用addEntry方法,這個方法也很簡單,將[null,value]放在table[0]的位置,並將新加入的鍵值對封裝成一個Entity對象,將其next指向原table[0]處的Entity實例。

 

size表示HashMap中存放的所有鍵值對的數量。

threshold = capacity*loadFactor,最后幾行代碼表示當HashMap的size大於threshold時會執行resize操作,將HashMap擴容為原來的2倍。擴容需要重新計算每個元素在數組中的位置,indexFor()方法中的table.length參數也證明了這一點。

但是擴容是一個非常消耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那么預設元素的個數能夠有效的提高HashMap的性能。比如說我們有1000個元素,那么我們就該聲明new HashMap(2048),因為需要考慮默認的0.75的擴容因子和數組數必須是2的N次方。若使用聲明new HashMap(1024)那么put過程中會進行擴容。

 

3.2  put進的key不為null

將上述put方法中的相關代碼復制一下方便查看:

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

從源碼可以看出,第1、2行計算將要put進的鍵值對的數組的位置i。第4行判斷加入的key是否和以table[i]為鏈表頭的鏈表中所有的鍵值對有重復,若重復則替換value並返回舊值,若沒有重復則調用addEntry方法,上面對這個方法的邏輯已經介紹過了。

至此HashMap的put操作已經介紹完畢了。

 

4.  HashMap的get操作

public V get(Object key) {    
   if (key == null)    
       return getForNullKey();    
   int hash = hash(key.hashCode());    
   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.equals(k)))  
                return e.value;    
        }    
    return null;    
}    
  
private V getForNullKey() {    
   for (Entry<K,V> e = table[0]; e != null; e = e.next) {    
   if (e.key == null)    
     return e.value;    
    }    
    return null;    
}  

 

 

如果了解了前面的put操作,那么這里的get操作邏輯就很容易理解了,源碼中的邏輯已經非常非常清晰了。

需要注意的只有當找不到對應value時,返回的是null。或者value本身就是null。這是可以通過containsKey()來具體判斷。

 

了解了上面HashMap的put和get操作原理,可以通過下面這個小例題進行知識鞏固,題目是打印在數組中出現n/2以上的元素,我們便可以使用HashMap的特性來解決。

public class HashMapTest {    
    public static void main(String[] args) {    
        int [] a = {2,1,3,2,0,4,2,1,2,3,1,5,6,2,2,3};    
        Map<Integer, Integer> map = new HashMap<Integer,Integer>();    
        for(int i=0; i<a.length; i++){    
            if(map.containsKey(a[i])){    
                int tmp = map.get(a[i]);    
                tmp+=1;    
                map.put(a[i], tmp);    
            }else{    
                map.put(a[i], 1);    
            }    
        }    
        Set<Integer> set = map.keySet();          
for (Integer s : set) {    
            if(map.get(s)>=a.length/2){    
                System.out.println(s);    
            }    
        }  
    }    
} 

5.  HashMap和HashTable的對比

 

HashTable和HashMap采用相同的存儲機制,二者的實現基本一致,不同的是:

(1)HashMap是非線程安全的,HashTable是線程安全的,內部的方法基本都經過synchronized修飾。

(2)因為同步、哈希性能等原因,性能肯定是HashMap更佳,因此HashTable已被淘汰。

(3) HashMap允許有null值的存在,而在HashTable中put進的鍵值只要有一個null,直接拋出NullPointerException。

(4)HashMap默認初始化數組的大小為16,HashTable為11。前者擴容時乘2,使用位運算取得哈希,效率高於取模。而后者為乘2加1,都是素數和奇數,這樣取模哈希結果更均勻。

這里本來我沒有仔細看兩者的具體哈希算法過程,打算粗略比較一下區別就過的,但是最近師姐面試美團移動開發時被問到了稍微具體一些的算法過程,我也是醉了…不過還是恭喜師姐面試成功,起薪20W,真是羡慕,希望自己一年后找工作也能順順利利的。

言歸正傳,看下兩種集合的hash算法。看源碼也不難理解。

//HashMap的散列函數,這里傳入參數為鍵值對的key  
static final int hash(Object key) {  
    int h;  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}   
//返回hash值的索引,h & (length-1)操作等價於 hash % length操作, 但&操作性能更優  
static int indexFor(int h, int length) {  
    // length must be a non-zero power of 2  
    return h & (length-1);  
}  
  
//HashTable的散列函數直接在put方法里實現了  
int hash = key.hashCode();  
int index = (hash & 0x7FFFFFFF) % tab.length;  

 

 

 

 

6.  HashTable和ConCurrentHashMap的對比

 

先對ConcurrentHashMap進行一些介紹吧,它是線程安全的HashMap的實現。

HashTable里使用的是synchronized關鍵字,這其實是對對象加鎖,鎖住的都是對象整體,當Hashtable的大小增加到一定的時候,性能會急劇下降,因為迭代時需要被鎖定很長的時間。

ConcurrentHashMap算是對上述問題的優化,其構造函數如下,默認傳入的是16,0.75,16。

public ConcurrentHashMap(int paramInt1, float paramFloat, int paramInt2)  {    
    //
    int i = 0;    
    int j = 1;    
    while (j < paramInt2) {    
      ++i;    
      j <<= 1;    
    }    
    this.segmentShift = (32 - i);    
    this.segmentMask = (j - 1);    
    this.segments = Segment.newArray(j);    
    //
    int k = paramInt1 / j;    
    if (k * j < paramInt1)    
      ++k;    
    int l = 1;    
    while (l < k)    
      l <<= 1;    
    
    for (int i1 = 0; i1 < this.segments.length; ++i1)    
      this.segments[i1] = new Segment(l, paramFloat);    
  }    
  
public V put(K paramK, V paramV)  {    
    if (paramV == null)    
      throw new NullPointerException();    
    int i = hash(paramK.hashCode()); //這里的hash函數和HashMap中的不一樣  
    return this.segments[(i >>> this.segmentShift & this.segmentMask)].put(paramK, i, paramV, false);    
}  

 

ConcurrentHashMap引入了分割(Segment),上面代碼中的最后一行其實就可以理解為把一個大的Map拆分成N個小的HashTable,在put方法中,會根據hash(paramK.hashCode())來決定具體存放進哪個Segment,如果查看Segment的put操作,我們會發現內部使用的同步機制是基於lock操作的,這樣就可以對Map的一部分(Segment)進行上鎖,這樣影響的只是將要放入同一個Segment的元素的put操作,保證同步的時候,鎖住的不是整個Map(HashTable就是這么做的),相對於HashTable提高了多線程環境下的性能,因此HashTable已經被淘汰了。

 

7.  HashMap和ConCurrentHashMap的對比

最后對這倆兄弟做個區別總結吧:

(1)經過4.2的分析,我們知道ConcurrentHashMap對整個桶數組進行了分割分段(Segment),然后在每一個分段上都用lock鎖進行保護,相對於HashTable的syn關鍵字鎖的粒度更精細了一些,並發性能更好,而HashMap沒有鎖機制,不是線程安全的。

(2)HashMap的鍵值對允許有null,但是ConCurrentHashMap都不允許。


免責聲明!

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



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