数据结构 JDK1.7之前HashMap采用的数据结构为数组+链表,使用这种结构是为了解决哈希冲突(拉链法)。JDK1.8之后还引入了红黑树,当链表长度达到一个阈值时该链表就会转化为红黑树。
数组元素和链表结点 JDK1.7之前为Entry,JDK1.8为Node,两者只是名称不同。
Entry(或Node)中存放着键值对、键的哈希值,并且指向下一个结点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 static class Entry <K ,V > implements Map .Entry <K ,V > { final K key; V value; Entry<K,V> next; int hash; Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey () { return key; } public final V getValue () { return value; } public final V setValue (V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals (Object o) { if (!(o instanceof Map.Entry)) return false ; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true ; } return false ; } public final int hashCode () { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } public final String toString () { return getKey() + "=" + getValue(); } void recordAccess (HashMap<K,V> m) { } void recordRemoval (HashMap<K,V> m) { } }
红黑树结点 HashMap中红黑树结点采用TreeNode类来实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 static final class TreeNode <K ,V > extends LinkedHashMap .Entry <K ,V > { TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super (hash, key, val, next); } final TreeNode<K,V> root () { for (TreeNode<K,V> r = this , p;;) { if ((p = r.parent) == null ) return r; r = p; } }
重要字段 容量(capacity) 容量指的是HashMap中数组的长度,它的值必须是2的N次幂。初始容量为16,最大不可超过2的30次方。
1 2 3 4 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 ;static final int MAXIMUM_CAPACITY = 1 << 30 ;
加载因子(factor) 加载因子决定了HashMap在自动增加时能达到多大载量,它与容量一起决定每次扩容的阈值。
1 2 3 4 final float loadFactor;static final float DEFAULT_LOAD_FACTOR = 0.75f ;
加载因子越大,到达扩容阈值时哈希表填满的元素越多,空间利用率越高,但是哈希冲突也更多,使得链表长度更长,HashMap的查找效率变低。反之查找效率变高,但是空间利用率变低了。如下图:
扩容阈值(threshold) 当哈希表所存放的元素数量≥扩容阈值时,就会触发哈希表的resize操作进行扩容。扩容阈值等于容量乘以加载因子。
红黑树相关 1 2 3 4 5 6 7 8 static final int TREEIFY_THRESHOLD = 8 ; static final int UNTREEIFY_THRESHOLD = 6 ;static final int MIN_TREEIFY_CAPACITY = 64 ;
JDK1.8与JDK1.7的差异
构造方法 HashMap提供了四个构造方法,需要注意的是,这四个方法中都没有进行哈希表的初始化 ,哈希表的初始化工作实际是在第一次put元素时才会进行。这里仅贴出可自定义初始容量和加载因子的构造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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; this .threshold = tableSizeFor(initialCapacity); }
tableSizeFor()方法类似于JDK1.7中inflateTable()里的roundUpToPowerOf2(toSize),会根据传入的cap值返回大于cap的最小2的n次幂。源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 static final int tableSizeFor (int cap) { int n = cap - 1 ; n |= n >>> 1 ; n |= n >>> 2 ; n |= n >>> 4 ; n |= n >>> 8 ; n |= n >>> 16 ; return (n < 0 ) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1 ; }
注意此处的扩容阈值threshold并不是真正要用的阈值,最终的阈值在putVal方法中计算。这里将扩容阈值计算好,后面直接可以替换初始容量。
添加元素 对比:
JDK1.8与1.7最主要的差别就是每次向HashMap中添加元素都要额外考虑红黑树的情况。
计算hash值 先调用Key对象本身的hashCode()方法计算出hash值,再进行hash扰动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 static final int hash (int h) { h ^= k.hashCode(); h ^= (h >>> 20 ) ^ (h >>> 12 ); return h ^ (h >>> 7 ) ^ (h >>> 4 ); } static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
插入元素 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this , tab, hash, key, value); else { for (int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
扩容机制 上面我们提到了resize()方法有两个作用:一是初始化哈希表,二是扩容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0 ; if (oldCap > 0 ) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) newCap = oldThr; else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int )(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0 ) { float ft = (float )newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float )MAXIMUM_CAPACITY ? (int )ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null ) { for (int j = 0 ; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null ) { oldTab[j] = null ; if (e.next == null ) newTab[e.hash & (newCap - 1 )] = e; else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this , newTab, j, oldCap); else { Node<K,V> loHead = null , loTail = null ; Node<K,V> hiHead = null , hiTail = null ; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0 ) { if (loTail == null ) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null ) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null ); if (loTail != null ) { loTail.next = null ; newTab[j] = loHead; } if (hiTail != null ) { hiTail.next = null ; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
JDK1.8扩容时,数据存储位置重新计算的方式如图所示:
数组位置转换示意:
获取数据 get()函数的原理与put()基本相同,先获取扰动后的hash值,然后计算数组下标,接着依次从数组、红黑树、链表中读取数据,然后返回数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 map.get(key); public V get (Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode (int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1 ) & hash]) != null ) { if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null ) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null ); } } return null ; }
重点及细节Q&A HashMap如何解决哈希冲突? HashMap采用拉链法解决哈希冲突,配合哈希扰动以及扩容机制,使得数据在数组中分布得更加均匀,数据结构上采用数组、链表和红黑树优化哈希冲突。
为什么哈希数组的长度必须为2的N次幂? 为什么采用哈希码&数组长度(h&(n - 1))的方式计算存储数组下标? 这两个问题是类似的。如果在设计时要提升HashMap性能,那么我们就需要减少哈希冲突,让数据优先存满数组,再依次加入链表中。那么可以采用哈希码除以数组长度再取余的方式计算存储数组下标,也就是 index = hash % n,但是这种方式计算下标效率太低(每次计算都要大量除法操作再取余)。
后来设计师想到可以采用与运算,若一个每一位都为1的二进制数(数组长度-1)与另一个数(哈希码)相与,和取余操作得到的结果是相同的,但是位运算效率自然更高。要保证这个二进制数始终每位为1,那就必须使数组长度始终为2的N次幂(再减一)。
为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化
为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键 由于默认的hashCode()方法(Object中)是根据地址值进行计算的,若两个Key值相等,但是地址不同的话,使用默认的hashCode()方法会出现相同Key不同hash值的情况。除此之外,默认的equals()方法也是直接比较对象地址,所以也需要重写。而String和Integer中都重写了hashCode()和equals()方法,保证了hash值的准确定,并且都为final类,保证了key的不可更改性。
因此,若是用自定义类作为HashMap的键的话,必须重写hashCoe()和equals()方法,并且最好用final修饰。
参考 JDK1.8 - HashMap.java
JDK1.7 - HashMap.java
Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?