JDK10源码分析之HashMap
HashMap在工作中大量使用,但是具体原理和实现是如何的呢?技术细节是什么?带着很多疑问,我们来看下JDK10源码吧。
1、数据结构
采用Node<K,V>[]数组,其中,Node<K,V>这个类实现Map.Entry<K,V>,是一个链表结构的对象,并且在一定条件下,会将链表结构变为红黑树。所以,JDK10采用的是数组+链表+红黑树的数据结构。贴上Node的源码
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
2、静态变量(默认值)
- DEFAULT_INITIAL_CAPACITY= 1 << 4:初始化数组默认长度。1左移4位,为16。
- MAXIMUM_CAPACITY = 1 << 30:初始化默认容量大小,2的30次方。
- DEFAULT_LOAD_FACTOR = 0.75f:负载因子,用于和数组长度相乘,当数组长度大于得到的值后,会进行数组的扩容,扩容倍数是2^n。
- TREEIFY_THRESHOLD = 8:链表长度达到该值后,会进行数据结构转换,变成红黑树,优化速率。
- UNTREEIFY_THRESHOLD = 6:红黑树的数量小于6时,在resize中,会转换成链表。
3、构造函数
/** * Constructs an empty {@code HashMap} 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; this.threshold = tableSizeFor(initialCapacity); } /** * Constructs an empty {@code HashMap} 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 {@code HashMap} with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * Constructs a new {@code HashMap} with the same mappings as the * specified {@code Map}. The {@code HashMap} is created with * default load factor (0.75) and an initial capacity sufficient to * hold the mappings in the specified {@code Map}. * * @param m the map whose mappings are to be placed in this map * @throws NullPointerException if the specified map is null */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
四个构造函数,这里不细说,主要说明一下一个方法。
1、tableSizeFor(initialCapacity)
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; }
这个方法返回一个2^n的值,用于初始化数组的大小,可以看到,入参的数值不是实际的数组长度,是经过计算得来的大于该值的第一个2^n值,并且,计算后大于2^30时,直接返回2^30。来说明下这个算法的原理,为什么会返回2^n。至于返回2^n有什么用,后面会有说明。
为什么会得到2^n,举个例子。比如13。13的2进制是0000 1101,上面运算相当于以下算式。
0000 1101 右移一位 0000 0110 ,取或0000 1111 一直运算下去,最后+1,确实是2^n。
下面,由于是取或,我们现在只关心二进制最高位的1,后面不管是1或0,都先不看,我们来看以下运算。
000... 1 ... 右移一位与原值取或后,得到 000... 11 ...
000... 11 ... 右移两位与原值取或后,得到 000... 11 11 ...
000... 1111 ... 右移四位与原值取或后,得到 000... 1111 1111 ...
以此下去,在32位范围内的值,通过这样移动后,相当于用最高位的1,将之后的所有值,都补为1,得到一个2^n-1的值。最后+1自然是2^n。
4、主要方法
- put(K key, V value)
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; //计算key的hash值,落在数组的哪一个区间,如果不存在则新建Node元素 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //数组存在的情况下,判断key是否已有,如果存在,则返回该值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果p是红黑树,则直接加入红黑树中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //如果不是红黑树,则遍历链表 for (int binCount = 0; ; ++binCount) { //如果p的next(链表中的下一个值)为空,则直接追加在该值后面 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //如果该链表存完之后,长度大于8,则转换为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //如果next不为空,则比较该链表节点时候就是存入的key,如果是,直接返回 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //如果存在相同的key,则直接返回该值。 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //数组中元素个数如果大于数组容量*负载因子,则触发数组resize操作。 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
HashMap,hash是散列算法,所以HashMap中,主要也用了散列的原理。就是将数据通过hash的散列算法计算其分布情况,存入map中。上面是put的代码,可以看出主要的流程是:初始化一个Node数组,长度为2^n,计算key值落在数组的位置,如果该位置没有Node元素,则用该key建立一个Node插入,如果存在hash碰撞,即不同key计算后的值落在了同一位置,则将该值存到Node链表中。其余具体细节,在上面源码中已经标注。
- hash(key)
计算put的hash入参,源码如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
可以看到,用到了key的hashCode方法,这个不细说,主要是计算key的散列值。主要讲一下后面为什么要和h右移16后相异或。实际上,是为了让这个hashCode的二进制值的1分布更散列一些。因为后面的运算需要,需要这样做(为什么后面的运算需要让1分散,这个我们下面会讲)。下面我们来看,为什么这样运算后,会增加1的散列性。可以看到,16位以内的二进制hashCode和它右移16位后取异或得到的值是一样的。我们举例时,用8位二进制和它右移4位取异或来举例,
比如 1101 1000 0001 0101,
右移8位为 0000 0000 1101 1000,
取异或后 1101 1000 1100 1101,可以看到1的分布更均匀了一些。
举个极端点的例子 1000 0000 0000 0000
右移8为 0000 0000 1000 0000
取异或后 1000 0000 1000 0000,可以明显看到,1多了一个。所以这样运算是有一定效果的,使hash碰撞的几率要低了一些。
3. resize()
该方法在数组初始化,数组扩容,转换红黑树(treeifyBin中,if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize();)中会触发。主要用于数组长度的扩展2倍,和数据的重新分布。源码如下
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) { //如果原数组存在,且大于2^30,则设置数组长度为0x7fffffff if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //如果原数组存在,则将其长度扩展为2倍。 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults 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; //如果原数组不为空,则取出数组中的元素,进行hash位置的重新计算,可以看到,重新计算耗时较多,所以尽量用多大数组就初始化多大最好。 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 { // preserve order 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; }
4. p = tab[i = (n - 1) & hash]
计算key的hash落在数组的哪个位置,它决定了数组长度为什么是2^n。主要是(n-1) & hash,这里就会用到上面hash()方法中,让1散列的作用。这个方法也决定了,为什么数组长度为2^n,下面我们具体解释一下。由于初始化中,n的值是resize方法返回的,resize中用到的就是tableSizeFor方法返回的2^n的值。如16,下面我举例说明,如数组长度是16:则n-1为15,二进制是 0000 1111与hash取与时,由于0与1/0都为0,所以我们只看后四位1111和hash的后四位。可以看到,与1111取与,可以得到0-15的值,这时,保证了hash能实现落在数组的所有下标。假想一下,如果数组长度为15或其他非二进制值,15-1=14,14的二进制为1110,由于最后一位是0,和任何二进制取与,最后一位都是0,则hash只会落到数组下标为0,2,4,6,8,10,12,14的偶数下标,这样数据分布会更集中,加重每个下标Node的负担,且数组中很多下标无法利用。源码作者正是利用了2^n-1,得到二进制最后全为1,并且与hash相与后,能让hash分布覆盖数组所有下标上的特性。之前hash()方法通过HashCode与HashCode右移16位取异或,让1分布更加均匀,也是为了让hash在数组中的分布更加均匀,从而避免某个下标Node元素过多,效率下降,且过多元素会触发resize耗费时间的缺点,当然,可以看到极端情况下,hash()计算的值并不能解决hash碰撞问题,但是为了HashMap的性能设计者没有考虑该极端情况,也是通过16位hashCode右移8位来举例说明。
如: 1000 1000 0000 0000和1000 1100 0000 0000,如果不移位取异或,这两个hash值与1111取与,都是分布在同一位置,分布情况不良好。
右移8位: 1000 1000 1000 1000和1000 1100 1000 1100,可以看到两个值与1111取与分布在数组的两个下标。
极端情况:1000 0000 0000 0000和1100 0000 0000 0000,该值又移8为取异或后,并不能解决hash碰撞。