Java基础知识强化之集合框架笔记79:HashMap的实现原理
1. HashMap的实现原理之 HashMap数据结构:
HashMap是对数据结构中哈希表(Hash Table)的实现, Hash表又叫散列表。Hash表是根据关键码Key来访问其对应的值Value的数据结构。
它通过一个映射函数把关键码Key映射到Hash表中一个位置来访问该位置的值Value,从而加快查找的速度。这个映射函数叫做Hash函数,存放记录的数组叫做Hash表。
在Java中,HashMap的内部实现结合了链表和数组的优势,链接节点的数据结构是Entry<k,v>,每个Entry对象的内部又含有指向下一个Entry类型对象的引用,如以下代码所示:
1 static class Entry<K,V> implements Map.Entry<K,V> { 2 final K key; 3 V value; 4 Entry<K,V> next; //Entry类型内部有一个自己类型的引用,指向下一个Entry 5 final int hash; 6 ... 7 }
哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法--- 拉链法,我们可以理解为"链表的数组" ,如图:
2. HashMap的实现原理之 HashMap的存取实现:
既然是线性数组,为什么能随机存取?这里HashMap用了一个小算法,大致是这样实现:
1 // 存储时: 2 int hash = key.hashCode(); // 这个hashCode方法这里不详述,只要理解每个key的hash是一个固定的int值 3 int index = hash % Entry[].length; 4 Entry[index] = value; 5 6 // 取值时: 7 int hash = key.hashCode(); 8 int index = hash % Entry[].length; 9 return Entry[index];
(1)put
这里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同一index下使用头插法(每次插入数据,从链头部插入)。
到这里为止,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[](对应index的链表,每个元素都是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; }
(3)null key 的存取
null key总是存放在Entry[]数组的第一个元素。
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; } private V getForNullKey() { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }
(4)确定数组的index:hashcode % table.length取模
HashMap存取时,都需要计算当前key应该对应Entry[]数组哪个元素,即计算数组下标;算法如下:
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { return h & (length-1); }
public HashMap(int initialCapacity, float 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(); }
注意table初始大小并不是构造函数中的initialCapacity!!
而是 >= initialCapacity的2的n次幂!!!!
3. HashMap的实现原理之 解决hash冲突的办法:
- 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
- 再哈希法
- 链地址法
- 建立一个公共溢出区
Java中hashmap的解决办法就是采用的链地址法。
4. HashMap的实现原理之 哈希表rehash过程(扩容机制):
当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。
HashMap 类中包含3个和扩容相关的常量:
DEFAULT_INITIAL_CAPACITY 是初始容量,默认是 2^4 = 16;
MAXIMUM_CAPACITY是最大容量,默认是 2^30;
DEFAULT_LOAD_FACTOR是增长因子,当占用率超过这个值时,就会触发扩容操作。
DEFAULT_INITIAL_CAPACITY是table数组的容量,DEFAULT_LOAD_FACTOR则是为了最大程度避免哈希冲突,提高HashMap效率而设置的一个影响因子,将DEFAULT_LOAD_FACTOR乘以DEFAULT_INITIAL_CAPACITY就得到了一个阈值threshold,当HashMap的容量达到threshold时就需要进行扩容,这个时候就要进行ReHash操作了,可以看到下面addEntry函数的实现,当size达到threshold时会调用resize()函数进行扩容。
HashMap的默认扩容机制,是存储的key超过容量的75%时,容量翻番。其实,这些和有序无序没关系。
比如:当前大小是16,当占用超过16*0.75=12时,就把容量扩充到16*2=32。
resize()方法的源码如下:
1 /** 2 * Rehashes the contents of this map into a new array with a 3 * larger capacity. This method is called automatically when the 4 * number of keys in this map reaches its threshold. 5 * 6 * If current capacity is MAXIMUM_CAPACITY, this method does not 7 * resize the map, but sets threshold to Integer.MAX_VALUE. 8 * This has the effect of preventing future calls. 9 * 10 * @param newCapacity the new capacity, MUST be a power of two; 11 * must be greater than current capacity unless current 12 * capacity is MAXIMUM_CAPACITY (in which case value 13 * is irrelevant). 14 */ 15 void resize(int newCapacity) { 16 Entry[] oldTable = table; 17 int oldCapacity = oldTable.length; 18 if (oldCapacity == MAXIMUM_CAPACITY) { 19 threshold = Integer.MAX_VALUE; 20 return; 21 } 22 Entry[] newTable = new Entry[newCapacity]; 23 transfer(newTable); 24 table = newTable; 25 threshold = (int)(newCapacity * loadFactor); 26 } 27 28 29 30 /** 31 * Transfers all entries from current table to newTable. 32 */ 33 void transfer(Entry[] newTable) { 34 Entry[] src = table; 35 int newCapacity = newTable.length; 36 for (int j = 0; j < src.length; j++) { 37 Entry<K,V> e = src[j]; 38 if (e != null) { 39 src[j] = null; 40 do { 41 Entry<K,V> next = e.next; 42 //重新计算index 43 int i = indexFor(e.hash, newCapacity); 44 e.next = newTable[i]; 45 newTable[i] = e; 46 e = next; 47 } while (e != null); 48 } 49 } 50 }
在扩容的过程中需要进行ReHash操作,而这是非常耗时的,在实际中应该尽量避免。