深入理解Java中的HashMap的實現原理

孫群發表於2015-06-22

HashMap繼承自抽象類AbstractMap,抽象類AbstractMap實現了Map介面。關係圖如下所示:


Java中的Map<key, value>介面允許我們將一個物件作為key,也就是可以用一個物件作為key去查詢另一個物件。
在我們探討HashMap的實現原理之前,我們先自己實現了一個SimpleMap類,該類繼承自AbstractMap類。具體實現如下:

import java.util.*;


public class SimpleMap<K,V> extends AbstractMap<K,V> {
	//keys儲存所有的鍵
	private List<K> keys = new ArrayList<K>();
	//values儲存所有的值
	private List<V> values = new ArrayList<V>();
	
	
	/**
	 * 該方法獲取Map中所有的鍵值對
	 */
	@Override
	public Set entrySet() {
		Set<Map.Entry<K, V>> set = new SimpleSet<Map.Entry<K,V>>();
		
		//keys的size和values的size應該一直是一樣大的
		Iterator<K> keyIterator = keys.iterator();
		Iterator<V> valueIterator = values.iterator();
		while(keyIterator.hasNext() && valueIterator.hasNext()){
			K key = keyIterator.next();
			V value = valueIterator.next();
			SimpleEntry<K,V> entry = new SimpleEntry<K,V>(key, value);
			set.add(entry);
		}
		
		return set;
	}

	@Override
	public V put(K key, V value) {
		V oldValue = null;
		int index = this.keys.indexOf(key);
		if(index >= 0){
			//keys中已經存在鍵key,更新key對應的value
			oldValue = this.values.get(index);
			this.values.set(index, value);
		}else{
			//keys中不存在鍵key,將key和value作為鍵值對新增進去
			this.keys.add(key);
			this.values.add(value);
		}
		return oldValue;
	}
	
	@Override
	public V get(Object key) {
		V value = null;
		int index = this.keys.indexOf(key);
		if(index >= 0){
			value = this.values.get(index);
		}
		return value;
	}

	@Override
	public V remove(Object key) {
		V oldValue = null;
		int index = this.keys.indexOf(key);
		if(index >= 0){
			oldValue = this.values.get(index);
			this.keys.remove(index);
			this.values.remove(index);
		}
		return oldValue;
	}

	@Override
	public void clear() {
		this.keys.clear();
		this.values.clear();
	}
	
	@Override
	public Set keySet() {
		Set<K> set = new SimpleSet<K>();
		Iterator<K> keyIterator = this.keys.iterator();
		while(keyIterator.hasNext()){
			set.add(keyIterator.next());
		}
		return set;
	}

	@Override
	public int size() {
		return this.keys.size();
	}

	@Override
	public boolean containsValue(Object value) {
		return this.values.contains(value);
	}

	@Override
	public boolean containsKey(Object key) {
		return this.keys.contains(key);
	}

	@Override
	public Collection values() {
		return this.values();
	}

}

當子類繼承自AbstractMap類時,我們只需要實現AbstractMap類中的entrySet方法和put方法即可,entrySet方法是用來返回該Map所有鍵值對的一個Set,put方法是實現將一個鍵值對放入到該Map中。
大家可以看到,我們上面的程式碼不僅除了實現entrySet和put方法外,我們還重寫了get、remove、clear、keySet、values等諸多方法。其實我們只要重寫entrySet和put方法,該類就可以正確執行,那我們為什麼還要重寫剩餘的那些方法呢?AbstractMap這個方法做了很多處理操作,Map中的很多方法在AbstractMap都實現了,而且很多方法都依賴於entrySet方法,舉個例子,Map介面中的values方法是讓我們返回該Map中所有的值的Collection。我們可以看一下AbstractMap中對values方法的實現:
public Collection<V> values() {
        if (values == null) {
            values = new AbstractCollection<V>() {
                public Iterator<V> iterator() {
                    return new Iterator<V>() {
                        private Iterator<Entry<K,V>> i = entrySet().iterator();

                        public boolean hasNext() {
                            return i.hasNext();
                        }

                        public V next() {
                            return i.next().getValue();
                        }

                        public void remove() {
                            i.remove();
                        }
                    };
                }

                public int size() {
                    return AbstractMap.this.size();
                }

                public boolean isEmpty() {
                    return AbstractMap.this.isEmpty();
                }

                public void clear() {
                    AbstractMap.this.clear();
                }

                public boolean contains(Object v) {
                    return AbstractMap.this.containsValue(v);
                }
            };
        }
        return values;
    }

大家可以看到,程式碼不少,基本的思路是先通過entrySet生成包含所有鍵值對的Set,然後通過迭代獲取其中的value值。其中生成包含所有鍵值對的Set肯定需要開銷,所以我們在自己的實現裡面重寫了values方法,就一句話,return this.values,直接返回我們的values欄位。所以我們重寫大部分方法的目的都是讓方法的實現更快更簡潔。

大家還需要注意一下,我們在重寫entrySet方法時,需要返回一個包含當前Map所有鍵值對的Set。首先鍵值對時一種型別,所有的鍵值對類都要實現Map.Entry<K,V>這個介面。其次,由於entrySet要讓我們返回一個Set,這裡我們沒有使用Java中已有的Set型別(比如HashSet、TreeSet),有兩方面的原因:
1. Java中HashSet這個類內部其實用HashMap實現的,本部落格的目的就是要研究HashMap,所以我們不用此類;
2. Java中Set的實現也不是很麻煩,自己實現一下AbstractSet,加深一下對Set的理解。

以下是我們自己實現的鍵值對類SimpleEntry,實現了Map.Entry<K,V>介面,程式碼如下:

import java.util.Map;

//Map中儲存的鍵值對,鍵值對需要實現Map.Entry這個介面
public class SimpleEntry<K,V> implements Map.Entry<K, V>{
	
	private K key = null;//鍵
	
	private V value = null;//值
	
	public SimpleEntry(K k, V v){
		this.key = k;
		this.value = v;
	}

	@Override
	public K getKey() {
		return this.key;
	}

	@Override
	public V getValue() {
		return this.value;
	}

	@Override
	public V setValue(V v) {
		V oldValue = this.value;
		this.value = v;
		return oldValue;
	}
	
}

以下是我們自己實現的集合類SimpleSet,繼承自抽象類AbstractSet<K,V>,程式碼如下:

import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Iterator;

public class SimpleSet<E> extends AbstractSet<E> {
	
	private ArrayList<E> list = new ArrayList<E>();

	@Override
	public Iterator<E> iterator() {
		return this.list.iterator();
	}

	@Override
	public int size() {
		return this.list.size();
	}

	@Override
	public boolean contains(Object o) {
		return this.list.contains(o);
	}

	@Override
	public boolean add(E e) {
		boolean isChanged = false;
		if(!this.list.contains(e)){
			this.list.add(e);
			isChanged = true;
		}
		return isChanged;
	}

	@Override
	public boolean remove(Object o) {
		return this.list.remove(o);
	}

	@Override
	public void clear() {
		this.list.clear();
	}

}

我們測試下我們寫的SimpleMap這個類,測試包括兩部分,一部分是測試我們寫的SimpleMap是不是正確,第二部分測試效能如何,測試程式碼如下:

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;


public class Test {

	public static void main(String[] args) {
		//測試SimpleMap的正確性
		SimpleMap<String, String> map = new SimpleMap<String, String>();
		map.put("iSpring", "27");
		System.out.println(map);
		System.out.println(map.get("iSpring"));
		System.out.println("-----------------------------");
		
		map.put("iSpring", "28");
		System.out.println(map);
		System.out.println(map.get("iSpring"));
		System.out.println("-----------------------------");
		
		map.remove("iSpring");
		System.out.println(map);
		System.out.println(map.get("iSpring"));
		System.out.println("-----------------------------");
		
		//測試效能如何
		testPerformance(map);
	}
	
	public static void testPerformance(Map<String, String> map){
		map.clear();
		
		for(int i = 0; i < 10000; i++){
			String key = "key" + i;
			String value = "value" + i;
			map.put(key, value);
		}
		
		long startTime = System.currentTimeMillis();
		
		for(int i = 0; i < 10000; i++){
			String key = "key" + i;
			map.get(key);
		}
		
		long endTime = System.currentTimeMillis();
		
		long time = endTime - startTime;
		
		System.out.println("遍歷時間:" + time + "毫秒");
	}
	
}

輸出結果如下:
{iSpring=27}
27
-----------------------------
{iSpring=28}
28
-----------------------------
{}
null
-----------------------------
遍歷時間:956毫秒

從結果裡面我們看到輸出結果是正確的,也就是我們寫的SimpleMap基本實現都是對的。我們往Map中插入了10000個鍵值對,我們測試的是從Map中取出這10000條鍵值對的效能開銷,也就是測試Map的遍歷的效能開銷,結果是956毫秒。

沒有對比就不知效能強弱,我們測試下HashMap讀取這10000條鍵值對的時間開銷,測試方法完全一樣,只是我們傳入的是HashMap的例項,測試程式碼如下:

//建立HashMap的例項
		HashMap<String, String> map = new HashMap<String, String>();
		
		//測試效能如何
		testPerformance(map);

測試結果如下:
遍歷時間:32毫秒

我去,不比不知道,一比嚇一跳啊,HashMap比我們自己實現的SimpleMap快的那不是一點半點啊。為什麼我們的SimpleMap效能這麼差?而HashMap的效能如此高呢?我們分別研究。
首先分析SimpleMap效能為什麼這麼差。
我們的SimpleMap是用ArrayList來儲存keys和values的,ArrayList本質是用陣列實現的,我們的SimpleMap的get方法是這樣實現的:

@Override
	public V put(K key, V value) {
		V oldValue = null;
		int index = this.keys.indexOf(key);
		if(index >= 0){
			//keys中已經存在鍵key,更新key對應的value
			oldValue = this.values.get(index);
			this.values.set(index, value);
		}else{
			//keys中不存在鍵key,將key和value作為鍵值對新增進去
			this.keys.add(key);
			this.values.add(value);
		}
		return oldValue;
	}

需要效能開銷的主要是this.keys.indexOf(key)這句程式碼,這句程式碼從ArrayList中查詢指定元素的索引,本質就是從陣列開頭走,往後找,直至陣列的末尾。如下圖所示:


這樣從頭開始查詢,並且每次在遍歷元素的時候,都需要呼叫元素的equals方法,所以從頭開始查詢就會導致呼叫很多次equals方法,這就造成了SimpleMap效率低下。比如我們將全國的車輛放入到SimpleMap中時,我們是依次將車輛放到ArrayList的最後面,依次往後插入值,車牌號就相當於key,車輛就好比是value,所以SimpleMap中有兩個長度很長的ArrayList,分別儲存keys和values,如果要在該SimpleMap中查詢一輛車,車牌是"魯E.DE829",那如果用ArrayList查詢的話就要從全國的的所有車輛中去查詢了,這樣太慢。

那麼HashMap為何效率如此高呢?
HashMap比較聰明,大家可以看看HashMash.java的原始碼,HashMap把裡面的元素分類放置了,還拿上面根據車牌號查詢車輛的例子來說,當把我們把車輛往HashMap裡面放的時候,HashMap將它們分類處理了,首先來一輛車的時候,先看其車牌號,比如車牌號是"魯E.DE829",一看是魯,就知道是山東的車輛,那麼HashMap就開闢了一塊空間,專門放山東的車,就把這輛車放到這塊山東專屬的區間了,下次又要向HashMap放入一輛車牌號為“浙A.GX588",HashMap一看是浙江的車,就將這輛車放入到浙江的專屬區間了,依次類推。說的再通俗點,假設我們有一種很大的桶,該桶就是相應的區間,可以裝下很多車,如下圖所示:

當我們從HashMap中根據車牌號查詢指定的車輛時,比如查詢車牌號為為"魯E.DE829"的車,當呼叫HashMap的get方法時,HashMap一看車牌號是魯,那麼HashMap就去標為魯的那個大桶,也就是山東區間去找這輛車了。這樣就沒有必要從全國的車輛中挨個找這輛車了,這就大大縮短了查詢空間,提高了效率。

我們可以看看HashMap.java中具體的原始碼實現,HashMap中用一個名為table的欄位儲存著一個Entry陣列,table儲存著HashMap裡面的所有鍵值對,每個鍵值對都是一個Entry物件。每個Entry物件都儲存著一個key和value,除此之外每個Entry內部還存著一個next欄位,next也是Entry型別。陣列table的預設長度是DEFAULT_INITIAL_CAPACITY,即初始長度為16,當容器需要更多的空間存取Entry時,它會自動擴容。
以下是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;
    }

在put方法中,,呼叫了物件的hashCode方法,該方法返回一個int型別的值,是個初始的雜湊值,這個值就相當於車牌號,例如"魯E.DE829",HashMap中有個hash方法,該hash方法將我們得到的初始的雜湊值做進一步處理,得到最終的雜湊值,就好比我們將車牌號傳入hash方法,然後返回該存放車輛的大桶,即返回"魯",這樣HashMap就把這輛車放到標有“魯”的大桶裡面了。上面說到的hash方法叫做雜湊函式,專門負責根據傳入的值返回指定的最終雜湊值,具體實現如下:
static int hash(int h) {
        // 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);
    }

可以看出來,HashMap中主要是通過位操作符實現雜湊函式的。這裡簡單說一下雜湊函式,雜湊函式有多種實現方式,比如最簡單的就是取餘法,比如對i%10取餘,然後按照餘數建立不同的區塊或桶。比如有100個數,分別是從1到100,那麼分別對10取餘,那麼就可以把這100個數放到10個桶子裡面了,這就是所謂的雜湊函式。只不過HashMap中的hash函式看起來比較複雜,進行的是位操作,但是其作用與簡單的取餘雜湊法的作用是等價的,就是把元素分類放置。
具體將鍵值對放入到HashMap中的方法是addEntry,程式碼如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

鍵值對都是Map.Entry<K,V>物件,並且Map.Entry具有next欄位,也就是桶裡面的元素都是通過單向連結串列的形式將Map.Entry串連起來的,這樣我們就可以從桶上的第一個元素通過next依次遍歷完桶裡面所有的元素。比如桶中有如下鍵值對:
桶-->e1-->e2-->e3-->e4-->e5-->e6-->e7-->e8-->e9-->...
addEntry程式碼首先取出桶裡面的第一個鍵值對e1,然後將新的鍵值對e置於桶中第一個元素的位置,然後將鍵值對e1放置於新鍵值對e後面,放置完之後,桶中新的鍵值對如下:
桶-->e-->e1-->e2-->e3-->e4-->e5-->e6-->e7-->e8-->e9-->...
這樣就把新的鍵值對放到了桶中了,也就將鍵值對放到HashMap中了。

那麼當我們從HashMap中查詢某個鍵值對時,怎麼查詢呢?原理與我們將鍵值對放入HashMap相似,以下是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;
    }

在get方法中,也是先呼叫了物件的hashCode方法,就相當於車牌號,然後再將該值讓hash函式處理得到最終的雜湊值,也就是桶的索引。然後我們再去這個標有“魯”的桶裡面去找我們的鍵值對,首先先取出桶裡面第一個鍵值對,比對一下是不是我們要找的元素,如果是就直接返回了,如果不是就通過鍵值對的next順藤摸瓜通過單向連結串列繼續找下去,直至找到。  如下圖所示:

下面我們再寫一個Car類,該類有一個欄位String型別的欄位num,並且我們重寫了Car的equals方法,我們認為只要車牌號相等就認為這是同一輛車。程式碼如下所示:
import java.util.HashMap;

public class Car {
	
	private final String num;//車牌號
	
	public Car(String n){
		this.num = n;
	}
	
	public String getNum(){
		return this.num;
	}

	@Override
	public boolean equals(Object obj) {
		if(obj == null){
			return false;
		}
		if(obj instanceof Car){
			Car car = (Car)obj;
			return this.num.equals(car.num);
		}
		return false;
	}
	

	public static void main(String[] args){
		HashMap<Car, String> map = new HashMap<Car, String>();
		String num = "魯E.DE829";
		Car car1 = new Car(num);
		Car car2 = new Car(num);
		System.out.println("Car1 hash code: " + car1.hashCode());
		System.out.println("Car2 hash code: " + car2.hashCode());
		System.out.println("Car1 equals Car2: " + car1.equals(car2));
		map.put(car1, new String("Car1"));
		map.put(car2, new String("Car2"));
		System.out.println("map.size(): " + map.size());
	}

}
我們在main函式中寫了一些測試程式碼,我們建立了一個HashMap,該HashMap的用Car作為鍵,用字串作為值。我們用同一個字串例項化了兩個Car,分別為car1和car2,然後將這兩個car都放入到HashMap中,輸出結果如下:
Car1 hash code: 404267176
Car2 hash code: 2027651571
Car1 equals Car2: true
map.size(): 2

從結果可以看出來,Car1和Car2是相等的,既然二者是相等的,也就是兩者作為鍵來說是相等的鍵,所以HashMap裡面只能放其中一個作為鍵,但是實際結果中map的長度卻是2個,為什麼會這樣呢?關鍵在於Car的hashCode方法,準確的說是Object的hashCode方法,Object的hashCode方法預設情況下返回的是物件記憶體地址,因為記憶體地址是唯一的。

我們沒有重寫Car的hashCode方法,所以car1的hashCode返回的值和car2的hashCode返回的值肯定不同。通過我們前面研究可知,如果是兩個元素相等,那麼這兩個元素應該放到同一個HashMap的桶裡。但是由於我們的car1和car2的hashCode不同,所以HashMap將car1和car2分別放到不同的桶子裡面了,這就出問題了。相等(equals)的兩個元素(car1和car2)如果hashCode返回值不同,那麼這兩個元素就會放到HashMap不同的區間裡面。所以我們寫程式碼的時候要保證相互equals的兩個物件的雜湊值必定要相等,即必須保證hashCode的返回值相等。那如何解決這個問題?我們只需要重寫hashCode方法即可,程式碼如下:
@Override
	public int hashCode() {
		return this.num.hashCode();
	}
重新執行main中的測試程式碼,輸出結果如下:
Car1 hash code: 607836628
Car2 hash code: 607836628
Car1 equals Car2: true
map.size(): 1

之前我們說了,相互equals的物件必須返回相同的雜湊值,相同雜湊值的物件都在一個桶裡面,但是反過來,具有相同雜湊值的物件(也就是在同一個桶裡面的物件)不必相互equals。

總結:
1. HashMap為了提高查詢的效率使用了分塊查詢的原理,物件的hashCode返回的雜湊值進行進一步處理,這樣就有規律的把不同的元素放到了不同的區塊或桶中。下次查詢該物件的時候,還是計算其雜湊值,根據雜湊值確定區塊或桶,然後在這個小範圍內查詢元素,這樣就快多了。
2. 如果重寫了equals方法,那麼必須重寫hashCode方法,保證如果兩個物件相互equals,那麼二者的hashCode的返回值必定相等。
3. 如果兩個物件的hashCode返回值相等,這兩個物件不必是equals的。




相關文章