JDK1.7和JDK1.8中hashmap的对比
JDK1.7中如果出现大量的key冲突之后,对长链表遍历找一个key-value对,性能是O(n),如果是直接根据array[index]获取到某个元素,性能是O(1)
JDK 1.8以后,优化了一下,如果一个链表的长度超过了8,就会自动将链表转换为红黑树,查找的性能,是O(logn),这个性能是比O(n)要高的
链表的遍历性能,时间复杂度是O(n),红黑树是O(logn),所以如果出现了大量的hash冲突以后,红黑树的性能比链表高得多,几倍到几十倍。
JDK 1.8以后,hashmap的数据结构是,数组 + 链表 + 红黑树
一些关键成员变量的分析
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
应该是数组的默认的初始大小,是16,这个跟ArrayList是不一样的,初始的默认大小是10
static final float DEFAULT_LOAD_FACTOR = 0.75f;
这个数组的大小,一般会自己手动指定一下,就跟你用ArrayList一样,你需要去预估一下你的这个数据结构里会放多少key-value对,指定的大一些,避免频繁的扩容。
这个参数,默认的负载因子,如果你在数组里的元素的个数达到了数组大小(16) * 负载因子(0.75f),默认是达到12个元素,就会进行数组的扩容
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
这是一个很关键的内部类,他其实是代表了一个key-value对,里面包含了key的hash值,key,value,还有就是可以有一个next的指针,指向下一个Node,也就是指向单向链表中的下一个节点
通过这个next指针,就可以形成一个链表
transient Node<K,V>[] table;
这个是什么东东?Node<K, V>[],这个数组就是所谓的map里的核心数据结构的数组,数组的元素就可以看到是Node类型的,天然就可以挂成一个链表,单向链表,Node里面只有一个next指针
transient int size;
这个size代表的是就是当前hashmap中有多少个key-value对,如果这个数量达到了指定大小 * 负载因子,那么就会进行数组的扩容
int threshold;
这个值,其实就是说capacity(就是默认的数组的大小),就是说capacity * loadFactory,就是threshold,如果size达到了threshold,那么就会进行数组的扩容。扩容又涉及到了rehash算法,下文会解释~!
final float loadFactor;,默认就是负载因子,默认的值是0.75f,你也可以自己指定,如果你指定的越大,一般就越是拖慢扩容的速度,一般不要修改
hash()
在hashmap里面有优化过后的hash算法,高性能的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
h = key.hashCode():这个就是直接获取了key的hash值,通过的是hashCode()方法
将他的高16位和低16位进行一个异或运算,就可以保证说,在hash值的低16位里面,可以同时保留他的高16位和低16位的特征,保证降低hash冲突的概率
put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
假设,hashmap是空的,数组大小就是默认的16,负载因子就是默认的12
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
刚开始table数组是空的,所以会分配一个默认大小的一个数组,数组大小是16,负载因子是0.75,threshold是12
他的hash寻址的算法:(n - 1) & hash, 并不是说用hash值对数组大小取模,取模就可以将任意一个hash值定位到数组的一个index那儿去,取模的操作性能不是太高
位运算,性能很高,&与操作,来实现取模的效果
他这个hash寻址算法优化的前提是:他后面每次扩容,数组的大小就是2的n次方,只要保证数组的大小是2的n次方,就可以保证说,(n - 1) & hash,可以保证就是hash % 数组.length取模的一样的效果。
因为他不想用取模,取模的性能相对较低,这个是他的一个提升性能的优化点。
hash冲突问题:
(1)就是说,假设某两个key的hash值一样的,两个key不同,hash值一样, 这个概率其实很低很低,除非是什么呢?就是说你自己乱写了hashCode()方法,你自己人为的制造了两个不同的key,但是hash值一样
(2)两个key的hash值不一样,但是通过寻址算法,定位到了数组的同一个key上去,此时就会出现典型的hash冲突,默认情况下,会用单向链表来处理
if ((p = tab[i = (n - 1) & hash]) == null)
这个分支,他的意思是说tab[i],i就是hash定位到的数组index,tab[i]如果为空,也就是hash定位到的这个位置是空的,之前没有任何人在这里,此时直接是放一个Node在数组的这个位置即可
else
如果进入else,就说明通过hash定位到的数组位置,是已经有了Node了。
相同的key在进行value的覆盖。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
如果人家的key是不一样的,hash值不一样,那么就进行链表/红黑树处理。
else if (p instanceof TreeNode):这个分支,如果这个位置已经是一颗红黑树的话,会红黑树处理。
else
直到进入这个else分支,才是说,key不一样,出现了hash冲突,然后此时还不是红黑树的数据结构,还是链表的数据结构,在这里,就会通过链表来处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
这串东西的意思,就是说如果当前链表的长度(binCount),大于等于TREEIFY_THRESHOLD - 1的话,如果链表的长度大于等于8的话,链表的总长度达到8的话,那么此时就需要将这个链表转换为一个红黑树的数据结构
红黑树优化处理
如果说出现大量的hash冲突之后,假设某给位置挂的一个链表特别的长,就很恶心了,如果链表长度太长的话,会导致有一些get()操作的时间复杂度就是O(n),正常来说,table[i]数组索引直接定位的方式的话,O(1)。
JDK 1.8以后人家优化了这块东西,会判断,如果链表的长度达到8的时候,那么就会将链表转换为红黑树,如果用红黑树的话,get()操作,即使对一个很大的红黑树进行二叉查找,那么时间复杂度会变成O(logn),性能会比链表的O(n)得到大幅度的提升。
当链表的长度超过8的时候,链表就先是变成双向链表,然后是变成红黑树。
假设现在某个地方已经是一颗红黑树了。如果此时在那个地方再次出现一个hash冲突的话,怎么办呢?此时就应该是在红黑树里插入一个节点了。
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
这行代码里面,会调用TreeNode.putTreeVal()方法,基于红黑树的规则,保持平衡的前提下,插入一个节点在红黑树里面。
扩容机制&&rehash()
hashmap底层是基于数组来实现的核心的数据结构,如果是用数组的话,就天然会有一个问题,就跟ArrayList一样,就是数组如果满了,就必须要扩容,hashmap所以也是有扩容的问题存在的
数组2倍扩容,重新寻址(rehash),hash & n - 1,判断二进制结果中是否多出一个bit的1,如果没多,那么就是原来的index,如果多了出来,那么就是index + oldCap,通过这个方式,就避免了rehash的时候,用每个hash对新数组.length取模,取模性能不高,位运算的性能比较高
JDK 1.8以后,为了提升rehash这个过程的性能,不是说简单的用key的hash值对新数组.length取模,取模给大家讲过,性能较低,而是用hash&length - 1进行寻址
JDK 1.8,扩容一定是2的倍数,从16到32到64到128。可以保证说,每次扩容之后,你的每个hash值要么是停留在原来的那个index的地方,要么是变成了原来的index(5) + oldCap(16) = 21
通过这个方式的话,可以有效的将原来冲突在一个位置的多个key,给分散到新数组的不同的位置去
if (++size > threshold)
resize();
意思是说,每次你如果是put了一个新的key-value对之后,人家就会size++,每次都会比较一下size和threshold(数组的长度 * 负载因子),resize()方法就是在扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
newCap = oldCap << 1,就是乘以2,新数组的大小是老数组的2倍
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
新的数组是老数组的大小的两倍
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
如果e.next是null的话,这个位置的元素不是链表,也不是红黑树
那么此时就是用e.hash & newCap(新数组的大小) - 1,进行与运算,直接定位到新数组的某个位置,然后直接就放在新数组里了
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
如果这个位置是一个红黑树的话,此时会调用split()方法,人家肯定会去里面遍历这颗红黑树,然后将里面每个节点都进行重新hash寻址,找到新数组的某个位置
else { // preserve order
进入这个else分支的话,证明是链表
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
遍历链表,然后将里面每个节点都进行重新hash寻址,找到新数组的某个位置