HashMap是基於哈希表的Map接口實現,提供了所有可選的映射操作,並允許使用null值和null建,不同步且不保證映射順序。下面記錄一下研究HashMap實現原理。
HashMap內部存儲
在HashMap內部,通過維護一個 瞬時變量數組table (又稱:桶) 來存儲所有的鍵值對關系,桶 是個Entry對象數組,桶 的大小可以按需調整大小,長度必須是2的次冪。如下代碼:
/** * 一個空的entry數組,桶 的默認值 */ static final Entry<?,?>[] EMPTY_TABLE = {}; /** * 桶,按需調整大小,但必須是2的次冪 */ transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
初始容量與負載因子
HashMap有兩個參數影響性能,初始容量和負載因子。容量是哈希表中 桶 的數量,初始容量只是哈希表在創建時的容量,負載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中條目數超出了負載因子與當前容量的乘積時,則要對該Hash表進行rehash操作(即重建內部數據結構),重建時以當前容量的兩倍數目新建。可以通過構造器設置初始容量與負載因子,默認初始容量是16個條目,最大容量是2^30次方個條目,默認負載因子是0.75
桶 就像一個存水的水桶,它默認的初始存水容量是16個單位的水,默認在灌水灌到16*0.75時,在下次添加數據時會先擴充容量,擴充到32單位。0.75就是負載因子,初始容量與負載因子可以通過創建水桶的時候進行設置。水桶最大的容量是2的30次方個單位的水。當初始容量設置的數量大於最大容量時,以最大容量為准。當擴展時如果大於等於最大容量時則直接返回。
如下為HashMap的部分源碼,定義了默認初始容量、負載因子及其他一些常量:
/** * 默認初始化容量,必須為2的次冪The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * 最大容量,如果通過構造函數參數中傳遞初始化容量大於該最大容量了,也會使用該容量為初始化容量
* 必須是2的次冪且小於等於2的30次方
*/ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 默認的負載因子,可以通過構造函數指定 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 一個空的數組表,當 桶沒有初始化的時候 */ static final Entry<?,?>[] EMPTY_TABLE = {}; /** * 桶 , 存儲所有的鍵值對條目,可以按需調整大小,長度大小必須為2的次冪 */ transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; /** * Map中鍵值對的數量,在每次新增或刪除的時候都會對size進行+1或者-1操作. */ transient int size; /** * 負載值,需要調整大小的臨界值,為:(capacity * load factor).在每次調整大小後會使用新的容量計算一下 * @serial */ // If table == EMPTY_TABLE then this is the initial capacity at which the // table will be created when inflated. int threshold; /** * 負載因子,如果構造函數中沒有指定,則采用默認的負載因子, * * @serial */ final float loadFactor; /** * HashMap結構修改次數,結構修改時改變HashMap中的映射數量或修改其內部結構(例如,* rehash方法,重建內部數據結構),此字段用於在
* HashMap的集合視圖上生成的迭代器都處理成快速失敗的 */ transient int modCount;
初始容量與負載因子性能調整
通常,默認負載因子(0.75)在時間和空間成本上尋求一種折中。負載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數HashMap類的操作中,包括get和put操作,都反映了這一點)。在設置初始容量時應該考慮到映射中所需的條目數及其負載因子,以便最大限度的減少rehash操作次數。如果初始容量大於最大條目數除以加載因子,則不會發生rehash操作。
如果很多映射關系要存儲在HashMap實例中,則相對於按需執行自動的rehash操作以增大表的容量來說,使用足夠大的初始容量創建它將使得映射關系能更有效的存儲。
如下為重建HashMap數據結構的代碼:
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { // 如果容量已達最大限制,則設置下負載值後直接返回 threshold = Integer.MAX_VALUE; return; } // 創建新的table存儲數據 Entry[] newTable = new Entry[newCapacity]; // 將舊table中的數據轉存到新table中去,這一步會花費比較多的時間 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; // 最後設置下下次調整大小的負載值 threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
HashMap構造方法
第四個構造方法是以已經存在的Map創建一個新的HashMap,稍後再說,前三個構造方法,其實最終調用的都是第三個帶兩個參數的方法,如果沒有傳遞參數則使用默認的數值,代碼如下:
/** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } /** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and the default load factor (0.75). * * @param initialCapacity the initial capacity. * @throws IllegalArgumentException if the initial capacity is negative. */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and load factor. * * @param initialCapacity the initial capacity * @param loadFactor the load factor * @throws IllegalArgumentException if the initial capacity is negative * or the load factor is nonpositive */ 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); this.loadFactor = loadFactor; threshold = initialCapacity; init(); }
由上可以看出,在構造函數中,如果初始容量給的大於最大容量,則直接以最大容量代替。
put方法
接下來就看看HashMap中比較重要的部分
/** * 在此映射中關聯指定值與指定建。如果該映射以前包含了一個該鍵的映射關系,則舊值被替換 * * @param 指定將要關聯的鍵 * @param 指定將要關聯的值 * @return 與key關聯的舊值,如果key沒有任何映射關系,則返回null(返回null還可能表示該映射之前將null與key關聯) */ public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); 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; }
因為新增條目的時候,需要計算hash值,長度不夠時需要調整長度,當計算的存儲位置已有元素的時候需要進行鏈表式的存儲,所以使用HashMap新增操作的效率並不是太高。
get方法
首先看下get方法的源碼:
/** * 返回指定鍵所映射的值;如果對於該鍵來說,此映射不包含任何映射關系,則返回null * 返回null值並不一定表明該映射不包含該鍵的映射,也可能改映射將該鍵顯示的映射為null,可使用containsKey操作來區分這兩種情況 * @see #put(Object, Object) */ public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); 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)))) return e; } return null; }
get方法實現較簡單,以下是幾個步驟:
通過查看get的源碼可以發現,get方法通過key的哈希值與桶的長度計算存儲位置,基本上一下就能定位到要找的元素,即使再遍歷幾個重復哈希值的key,也是很快速的,因為哈希值相對唯一,所以HashMap對於查找性能是非常快的。
自定義對象作為HashMap的鍵
class User { // 身份證號碼 protected int idNumber; public User(int id){ idNumber = id; } } public class TestUser{ public static void main(String[] args) { Map<User, String> map = new HashMap<User, String>(); for (int i=0; i<5; i++) { map.put(new User(i), "姓名: " + i); } System.out.println("User3 的姓名:" + map.get(new User(3))); } }
輸出: User3 的姓名:null
如上代碼,通過自定義的User類實例作為HashMap的對象時,在打印的時候是無法找到User3的姓名的,因為User類自動繼承基類Object,所以這裡會自動使用Object的hashCode方法生成哈希值,而它默認是使用對象的地址計算哈希值的。因此new User(3)生成的第一個實例的哈希值與生成的第二個實例的哈希值是不一樣的。但是如果只需要簡單的覆蓋hashCode方法,也是無法正常運作的,除非同時覆蓋equals方法,它也是Object的一部分。HashMap使用equals()判斷當前的鍵是否與表中存在的鍵相同,可以參考上面的get或put方法。
正確equals()方法必須滿足下列5個條件:---參考《Java編程思想》—489頁
再次強調:默認的Object.equals()只是比較對象的地址,所以一個new User(3)並不等於另一個new User(3)。因此,如果要使用自己的類作為HashMap的鍵,必須同時重載hashCode()和equals().
如下代碼可以正常運作:
class User { // 身份證號碼 protected int idNumber; public User(int id){ idNumber = id; } @Override public int hashCode() { return idNumber; } @Override public boolean equals(Object obj) { return obj instanceof User && (idNumber==((User)obj).idNumber); } } public class TestUser{ public static void main(String[] args) { Map<User, String> map = new HashMap<User, String>(); for (int i=0; i<5; i++) { map.put(new User(i), "姓名: " + i); } System.out.println("User3 的姓名:" + map.get(new User(3))); } } 輸出: User3 的姓名:姓名: 3
上面只是簡單的在hashCode中返回了idNumber作為唯一的判別,用戶也可以根據自己的業務實現自己的方法。在equals方法中,instanceof會悄悄的檢查對象是否為null,如果instanceof左邊的參數為null,則會返回false,如果equals()的參數不為null且類型正確,則基於每個對象中的實際的idNumber進行比較。從輸出可以看出,現在的方式是正確的。
參考:
《Java編程思想》
JDK API幫助文檔
JDK源碼