Java-HashMap原理

1、HashMap的數據結構

數據結構中有數組和鏈表來實現對數據的存儲,但這兩者基本上是兩個極端。

數組

數組存儲區間是連續的,佔用內存嚴重,故空間複雜的很大。但數組的二分查找時間複雜度小,爲O(1);數組的特點是:尋址容易,插入和刪除困難;

鏈表

鏈表存儲區間離散,佔用內存比較寬鬆,故空間複雜度很小,但時間複雜度很大,達O(N)。鏈表的特點是:尋址困難,插入和刪除容易。

哈希表

那麼我們能不能綜合兩者的特性,做出一種尋址容易,插入刪除也容易的數據結構?答案是肯定的,這就是我們要提起的哈希表。哈希表((Hash
table)既滿足了數據的查找方便,同時不佔用太多的內容空間,使用也十分方便。

哈希表有多種不同的實現方法,我接下來解釋的是最常用的一種方法—— 拉鍊法,我們可以理解爲“鏈表的數組” ,如圖:
這裏寫圖片描述
這裏寫圖片描述
從上圖我們可以發現哈希表是由數組+鏈表組成的,一個長度爲16的數組中,每個元素存儲的是一個鏈表的頭結點。那麼這些元素是按照什麼樣的規則存儲到數組中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存儲在數組下標爲12的位置。

  HashMap其實也是一個線性的數組實現的,所以可以理解爲其存儲數據的容器就是一個線性數組。這可能讓我們很不解,一個線性的數組怎麼實現按鍵值對來存取數據呢?這裏HashMap有做一些處理。

  首先HashMap裏面實現一個靜態內部類Entry,其重要的屬性有 key , value, next,從屬性key,value我們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎bean,我們上面說到HashMap的基礎就是一個線性數組,這個數組就是Entry[],Map裏面的內容都保存在Entry[]裏面。

   /**
     * The table, resized as necessary. Length MUST Always be a power of two.
    */
   transient Entry[] table;
 static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        final int hash;  
        Entry<K,V> next;  
..........  
}  

上面的Entry就是數組中的元素,它持有一個指向下一個元素的引用,這就構成了鏈表。

Entry是HashMap的內部類 包含四個值(next,key,value,hash),其中next是一個指向
Entry的指針,key相當於上面節點的值
value對應要保存的值,hash值由key產生,hashmap中要找到某個元素,需要根據hash值來求得對應數組中的位置,然後在由key來在鏈表中找Entry的位置。HashMap中的一切操作都是以Entry爲基礎進行的。HashMap的重點在於如何處理Entry。因此HashMap中的操作大部分都是調用Entry中的方法。可以說HashMap類本身只是提供了一個數組,和對Entry類中方法的一些封裝。

HashMap的原理圖是:
這裏寫圖片描述
Hashmap實際上是一個數組和鏈表的結合體:
這裏寫圖片描述
當我們往hashmap中put元素的時候,先根據key的hash值得到這個元素在數組中的位置(即下標),然後就可以把這個元素放到對應的位置中了。如果這個元素所在的位子上已經存放有其他元素了,那麼在同一個位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從hashmap中get元素時,首先計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。從這裏我們可以想象得到,如果每個位置上的鏈表只有一個元素,那麼hashmap的get效率將是最高的,但是理想總是美好的,現實總是有困難需要我們去克服,哈哈~

2、hash算法

Hash,一般翻譯做“散列”,也有直接音譯爲”哈希”的,就是把任意長度的輸入(又叫做預映射,pre-image),通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,而不可能從散列值來唯一的確定輸入值。

說的通俗一點,Hash 算法的意義在於提供了一種快速存取數據的方 法,它用一種算法建立鍵值與真實值之間的對應關係,(每一個真實值只 能有一個鍵值,但是一個鍵值可以對應多個真實值),這樣可以快速在數組等裏面存取數據。

我們可以看到在hashmap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過hashmap的數據結構是數組和鏈表的結合,所以我們當然希望這個hashmap裏面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。

所以我們首先想到的就是把hashcode對數組長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。但是,“模”運算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中時這樣做的,

 static int indexFor(int h, int length) {  
       return h & (length-1);  
   }  

首先算得key得hashcode值,然後跟數組的長度-1做一次“與”運算(&)。看上去很簡單,其實比較有玄機。比如數組的長度是2的4次方,那麼hashcode就會和2的4次方-1做“與”運算。很多人都有這個疑問,爲什麼hashmap的數組初始化大小都是2的次方大小時,hashmap的效率最高,我以2的4次方舉例,來解釋一下爲什麼數組大小爲2的冪時hashmap訪問的性能最高。

看下圖,左邊兩組是數組長度爲16(2的4次方),右邊兩組是數組長度爲15。兩組的hashcode均爲8和9,但是很明顯,當它們和1110“與”的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到同一個鏈表上,那麼查詢的時候就需要遍歷這個鏈表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度爲15的時候,hashcode的值會與14(1110)進行“與”,那麼最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的機率,減慢了查詢的效率!
這裏寫圖片描述
所以說,當數組長度爲2的n次冪的時候,不同的key算得得index相同的機率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。

說到這裏,我們再回頭看一下hashmap中默認的數組大小是多少,查看源代碼可以得知是16,爲什麼是16,而不是15,也不是20呢,看到上面annegu的解釋之後我們就清楚了吧,顯然是因爲16是2的整數次冪的原因,在小數據量的情況下16比15和20更能減少key之間的碰撞,而加快查詢的效率。

所以,在存儲大容量數據的時候,最好預先指定hashmap的size爲2的整數次冪次方。就算不指定的話,也會以大於且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構造方法中):

且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構造方法中):

   // Find a power of 2 >= initialCapacity  
          int capacity = 1;  
          while (capacity < initialCapacity)   
             capacity <<= 1;  

3、HashMap的重構resize

當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因爲數組的長度是固定的),所以爲了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。

那麼hashmap什麼時候進行擴容呢?

當hashmap中的元素個數超過數組大小乘以loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展爲2*16=32即擴大一倍然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知hashmap中元素的個數,那麼預設元素的個數能夠有效的提高hashmap的性能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設置爲1024。 但是new HashMap(1024)還不是更合適的,因爲0.75*1000 < 1000, 也就是說爲了讓0.75 * size >1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。

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); table = newTable;
  threshold = (int)(newCapacity * loadFactor); 
}

4、key的hashcode()與equals()方法改寫

在第一部分hashmap的數據結構中,annegu就寫了get方法的過程:首先計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。所以,hashcode與equals方法對於找到對應元素是兩個關鍵方法。

HashCode的計算方法是調用的各個對象自己的實現的 hashCode()方法。而這個方法是在Object對象中定義的,所以我們自己定義的類如果要在集合中使用的話,就需要正確的覆寫 hashCode() 方法。

Hashmap的key可以是任何類型的對象,例如User這種對象,爲了保證兩個具有相同屬性的user的hashcode相同,我們就需要改寫hashcode方法,比方把hashcode值的計算與User對象的id關聯起來,那麼只要user對象擁有相同id,那麼他們的hashcode也能保持一致了,這樣就可以找到在hashmap數組中的位置了。如果這個位置上有多個元素,還需要用key的equals方法在對應位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫滴~當然啦,按正常思維邏輯,equals方法一般都會根據實際的業務內容來定義,例如根據user對象的id來判斷兩個user是否相等。

在改寫equals方法的時候,需要滿足以下三點:
(1) 自反性:就是說a.equals(a)必須爲true。
(2) 對稱性:就是說a.equals(b)=true的話,b.equals(a)也必須爲true。
(3) 傳遞性:就是說a.equals(b)=true,並且b.equals(c)=true的話,a.equals(c)也必須爲true。

通過改寫key對象的equals()hashcode()方法,我們可以將任意的業務對象作爲map的key(前提是你確實有這樣的需要)。

假定我們寫了一個類:Person(人),我們判斷一個對象“人”是否指向同一個人,只要知道這個人的身份證號一直就可以了。

先看我們沒有實現 hashCode的情況:

  //身份證類 
  class Code{
    public final int id;//身份證號碼已經確認,不能改變 
    public Code(int i){
       this.id=i; 
    }
    //身份號號碼相同,則身份證相同
    public boolean equals(Object anObject) {
      if (anObject instanceof Code){
         Code other=(Code) anObject; 
         return this.id==other.id;
      }
      return false;
    }
    public String toString() {
      return "身份證:"+id; 
    }
  }

  //人員信息類 
  class Person {
      public Code id;// 身份證 
      public String name;// 姓名 
      public Person(String name, Code id) {   
         this.id=id;
         this.name=name;
      }
      //如果身份證號相同,就表示兩個人是同一個人 
      public boolean equals(Object anObject) {  
         if (anObject instanceof Person){  
           Person other=(Person) anObject;  
           return this.id.equals(other.id);  
         }
        return false; 
      }
     public String toString() {
       return "姓名:"+name+" 身份證:"+id.id+"\n";
     } 
   }
  package com.test;
  import java.util.HashMap;
  public class HashCodeDemo {
    public static void main(String[] args) {  
      HashMap map=new HashMap();
      Person p1=new Person("張三",new Code(123)); 
      Person p2=new Person("李四",new Code(456)); 
      //我們根據身份證來作爲key值存放到Map中 
      map.put(p1.id,p1);
      map.put(p2.id,p2);
      System.out.println("HashMap 中存放的人員信息:\n"+map);
      // 張三,改名爲:張山,但是還是同一個人(身份證id依然爲123)
      Person p3=new Person("張山",new Code(123)); 
      map.put(p3.id,p3);
      System.out.println("張三改名後 HashMap 中存放的人員信 息:\n"+map);
      //查找身份證爲:123 的人員信息
      System.out.println("查找身份證爲:123 的人員信息:"+map.get(new Code(123)));
    } 
  }

運行結果爲:

HashMap 中存放的人員信息: 
HashMap 中存放的人員信息:
 { 身份證:456=姓名:李四 身份證:456,  
   身份證:123=姓名:張三 身份證:123
 }
張三改名後 HashMap 中存放的人員信息:
 { 身份證:123=姓名:張山 身份證:123, 
   身份證:456=姓名:李四 身份證:456, 
   身份證:123=姓名:張三 身份證:123 
 }
查找身份證爲:123 的人員信息:null

上面的例子的演示的是,我們在一個HashMap中存放了一些人員的信息。並以這些人員的身份證最爲人員的“鍵”。

注意:此處之所以能夠成功put兩個Key(id)同爲“123”的人(實際上HashMap只能存在唯一的Key,Key重複的話,Value將被新值覆蓋),是因爲Code類還沒有重寫Hashcode()函數!!!

而例子的輸出結果表示,我們所做的更新和查找操作都失敗了。

失敗的原因就是我們的身份證類Code沒有覆寫hashCode()方法。
這個時候,當查找一樣的身份證號碼的鍵值對的時候,使用的是默認的對象的內存地址來進行定位(Object類定義的hashcode()方法默認返回的是對象的內存地址)。
這樣,後面的所有的身份證號對象new Code(123) 產生的hashCode()值都是不一樣的。所以導致操作失敗。

下面,我們給 Code類加上 hashCode()方法,然後再運行一下程序看看:

//身份證類
class Code{
  public final int id;//身份證號碼已經確認,不能改變 
  public Code(int i){
     this.id=i; 
  }
  //身份號號碼相同,則身份證相同
  public boolean equals(Object anObject) {
     if (anObject instanceof Code){
       Code other=(Code) anObject;
       return this.id==other.id;
     }
     return false; 
  }
  public String toString() {
     return "身份證:"+id; 
  }
  //覆寫hashCode方法,並使用身份證號作爲hash值
  public int hashCode(){
      return id; 
  }
}

//人員信息類 
class Person {
  public Code id;// 身份證 
  public String name;// 姓名 
  public Person(String name, Code id) {  
     this.id=id;
     this.name=name; 
  }
  //如果身份證號相同,就表示兩個人是同一個人 
  public boolean equals(Object anObject) {  
    if (anObject instanceof Person){  
        Person other=(Person) anObject; 
        return this.id.equals(other.id); 
     }
     return false; 
  } 
  public String toString() {
     return "姓名:"+name+" 身份證:"+id.id+"\n";
  }  
}
 package com.test;
  import java.util.HashMap;
  public class HashCodeDemo {
    public static void main(String[] args) {  
      HashMap map=new HashMap();
      Person p1=new Person("張三",new Code(123)); 
      Person p2=new Person("李四",new Code(456)); 
      //我們根據身份證來作爲key值存放到Map中 
      map.put(p1.id,p1);
      map.put(p2.id,p2);
      System.out.println("HashMap 中存放的人員信息:\n"+map);
      // 張三,改名爲:張山,但是還是同一個人(身份證id依然爲123)
      Person p3=new Person("張山",new Code(123)); 
      map.put(p3.id,p3);
      System.out.println("張三改名後 HashMap 中存放的人員信 息:\n"+map);
      //查找身份證爲:123 的人員信息
      System.out.println("查找身份證爲:123 的人員信息:"+map.get(new Code(123)));
    } 
  }

運行結果爲:

HashMap 中存放的人員信息: 
HashMap 中存放的人員信息:
 { 身份證:456=姓名:李四 身份證:456,  
   身份證:123=姓名:張三 身份證:123
 }
張三改名後 HashMap 中存放的人員信息:
 { 身份證:456=姓名:李四 身份證:456, 
   身份證:123=姓名:張三 身份證:123 
 }
查找身份證爲:123 的人員信息:姓名:張山 身份證:123

這個時候,我們發現。我們想要做的更新和查找操作都成功了。

(1)對於 Map部分的使用和實現,主要就是需要注意存放“鍵值對”中的對象的 equals()方法和 hashCode()方法的覆寫。
(2)如果需要使用到排序的話,那麼還需要實現Comparable接口中的compareTo()方法。
(3)我們需要注意 Map 中的“鍵”是不能重複的,而是否重複的判斷,是通過調用“鍵”對象的 equals()方法來決定的。
(4)而在HashMap中查找和存取“鍵值對”是同時使用 hashCode()方法和 equals()方法來決定的。

5、HashMap的存取實現

既然是線性數組,爲什麼能隨機存取?這裏HashMap用了一個小算法,大致是這樣實現:

// 存儲時:
int hash = key.hashCode(); // 這個hashCode方法這裏不詳述,只要理解每個key的hash是一個固定的int值
int index = hash % Entry[].length;
Entry[index] = value;

// 取值時:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];

1) put

疑問:如果兩個key通過hash%Entry[].length得到的index相同,會不會有覆蓋的危險?

  這裏HashMap裏面用到鏈式數據結構的一個概念。上面我們提到過Entry類裏面有一個next屬性,作用是指向下一個Entry。打個比方, 第一個鍵值對A進來,通過計算其key的hash得到的index=0,記做:Entry[0] = A。一會後又進來一個鍵值對B,通過計算其index也等於0,現在怎麼辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,index也等於0,那麼C.next = B,Entry[0] = C;這樣我們發現index=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性鏈接在一起。所以疑問不用擔心。也就是說數組中存儲的是最後插入的元素。到這裏爲止,HashMap的大致實現,我們應該已經清楚了。

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value); //null總是放在數組的第一個鏈表中
        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;
            //如果key在鏈表中已存在,則替換爲新value
            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;

    }


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); //參數e, 是Entry.next
    //如果size超過threshold,則擴充table大小。再散列
    if (size++ >= threshold)
            resize(2 * table.length);
}

當然HashMap裏面也包含一些優化方面的實現,這裏也說一下。比如:Entry[]的長度一定後,隨着map裏面數據的越來越長,這樣同一個index的鏈就會很長,會不會影響性能?HashMap裏面設置一個因子,隨着map的size越來越大,Entry[]會以一定的規則加長長度。

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

6、解決hash衝突的辦法

開放定址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)
再哈希法
鏈地址法
建立一個公共溢出區 

Java中hashmap的解決辦法就是採用的鏈地址法。

7、ConcurrentHashMap

ConcurrentHashMap是Java5中新增加的一個線程安全的Map集合,可以用來替代HashTable。ConcurrentHashMap相比於HashTable,使用了多個鎖代替HashTable中的單個鎖,也就是鎖分離技術(Lock Stripping),使得其是線程安全並且高效的HashMap,在併發編程中經常可見它的使用。

在開始分析它的高併發實現機制前,先講講廢話,看看它是如何被引入jdk的。

(1) 線程不安全的HashMap

HashMap線程不安全,它的線程不安全主要發生在put等對HashEntry有直接寫操作的地方。
這裏寫圖片描述
在多線程環境下,使用Hashmap進行put操作會引起死循環,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap。

(2) 效率低下的HashTable

Hashtable線程安全,但是效率低下。
這裏寫圖片描述
從Hashtable示例的源碼可以看出,Hashtable是用synchronized關鍵字來保證線程安全的,由於synchronized的機制是在同一時刻只能有一個線程操作,其他的線程阻塞或者輪詢等待,在線程競爭激烈的情況下,這種方式的效率會非常的低下。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,並且也不能使用get方法來獲取元素,所以競爭越激烈效率越低。

(3) 高效且安全的ConcurrentHashMap

Hashtable低效主要是因爲所有訪問Hashtable的線程都爭奪一把鎖。如果容器有很多把鎖,每一把鎖控制容器中的一部分數據,那麼當多個線程訪問容器裏的不同部分的數據時,線程之前就不會存在鎖的競爭,這樣就可以有效的提高併發的訪問效率。這也正是ConcurrentHashMap使用的分段鎖技術。將ConcurrentHashMap容器的數據分段存儲,每一段數據分配一個Segment(鎖),當線程佔用其中一個Segment時,其他線程可正常訪問其他段數據。

(4) ConcurrentHashMap結構分析

這裏寫圖片描述
從類圖可以看出:ConcurrentHashMap由Segment和HashEntry組成。

(1) Segment是可重入鎖,它在ConcurrentHashMap中扮演分離鎖的角色;

(2) HashEntry主要存儲鍵值對;

CurrentHashMap包含一個Segment數組,每個Segment包含一個HashEntry數組並且守護它,當修改HashEntry數組數據時,需要先獲取它對應的Segment鎖;而HashEntry數組採用開鏈法處理衝突,所以它的每個HashEntry元素又是鏈表結構的元素。

由此可以得出ConcurrentHashMap的結構圖如下:
這裏寫圖片描述

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