1.hashmap的数据结构分析(JDK1.8)
首先,hashmap中用到了两种数据结构,也是最基础的两种,数组和链表。
由于数组和链表各有其优缺点,于是产生了一种将两者的优点结合的数据结构,哈希表。哈希表既方便数据的快速查找,同时不会占用太多的空间,是一种非常优秀的数据结构。
如图所示:
从图中我们可以看到有一个entry[]数组,由于数组是内存中连续的一段存储空间,所以可以快速的查找数据,所以在hashmap中有一个entry[]数组,用作数据的“索引”,数组的每一个元素中存储的都是链表的头节点,也就是说entry[]数组的每一个元素都代表了一段链表,每段链表的结点都有三个数据,key,value和next,其中next指向的是下一个节点的地址,在jdk1.8中,当链表的结点数超过八之后,会将其转化为红黑树存储。
红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
它虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的: 它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。
2.HashMap类源码分析
-
初始化静态常量分析
下面是来自jdk1.8的源码:
从上图可以看到,HashMap类继承了AbstractMap类,同时实现了Map,Cloneable和Serializable接口。AbstractMap也实现了Map接口,而在AbstractMap类中定义了一些哈希表的基本操作方法,如下图所示:
下面来看一下HashMap类中的初始化参数:
默认初始化容量,大小为16,也就是entry[]数组的默认长度
数组的最大容量
加载因子
当数组元素后面的链表长度超过8时,则转化为红黑树存储
在扩容时即:红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
最小树形化阀值:当表的容量大于64时才允许将链表树形化,主要用于resize()的扩容判断条件 -
静态内部类
在HashMap中还定义了很多静态类,如Node节点类,操作链表的节点,KeySet类,返回一个Key值的集合,类似的还有EntrySet类,返回所有的键值对等等 -
构造器
如图:
分别是空参构造器,指定初始容量和加载因子,指定初始容量,带参Map构造器 -
常用方法分析
get方法
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) { //(n-1)& hash操作得到了数组的下标,也就是槽位
if (first.hash == hash && // always check first node,总是检查第一个结点是否命中
((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;
}
简单来说就是遍历查找,以获得key对应的value
put()方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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; //将表的引用赋给tab,并判断是否为空,如果为空则调用reseize()方法初始化哈希表
if ((p = tab[i = (n - 1) & hash]) == null) //计算key对应的槽位,如果该槽位无节点直接插入,否则进行下一步的操作
tab[i] = newNode(hash, key, value, null);
else { //进入此处时说明该槽位已有结点,遍历槽位下的所有结点,进行比较,此时p = tab[i = (n - 1) & hash]
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) //如果存在key值和hash值相同的结点则将该节点的引用赋给e
e = p;
else if (p instanceof TreeNode) //如果该槽位已经树化为红黑树,则进行红黑树的插入操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { //进入此处的条件为该槽位没有转化为红黑树并且链表长度大于1,此时需要遍历该链表下的所有结点,这里无限循环的退出条件有两个:1.下一结点为空,此时如果结点数超过8(TREEIFY_THRESHOLD - 1)时,则树化该槽位,退出循环,2.如果发现有key值相同的结点将该节点的值赋给e,退出循环。只要满足这两个条件的任一条件就可以退出循环
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) //如果存在相同的key值,将该结点的位置赋给e,退出循环
break;
p = e;
}
}
if (e != null) { // existing mapping for key 如果e不为null,说明在上面的操作中有对e进行赋值的操作,而只有存在相同的key时才对e赋值,说明存在相同的key,此时需要保存旧的value,并把旧的value覆盖,返回旧的value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; //判断是否有多个线程操作该map,主要用于迭代器的快速失败机制
if (++size > threshold) //如果Table表的大小超过阈值,则进行扩容操作
resize();
afterNodeInsertion(evict);
return null;
}
总结:
- 如果定位到的数组位置没有元素 就新建结点直接插入
- 如果定位到的数组位置有元素就和要插入的 key 比较,如果key相同就直接覆盖旧的value(实际上是先将结点的引用保存在e中,在最后才进行覆盖操作),如果该数组位置的元素是一个treeNode表示这个槽位已经转化为红黑树,进行红黑树的插入操作,否则进入下一步操作
- 如果 key 不相同,并且没有转化为红黑树,就遍历该数组所在位置下的链表,利用无限循环,退出这个无限循环的条件有两个,第一个条件是下一结点为null,直接break(如果此时链表长度大于8则进行链表的树化),第二个条件是存在相同的key,将key相同的结点引用赋值给e,退出循环
- 退出上面的循环后如果e不为null,代表存在相同的 key,将旧的value保存后覆盖,返回旧的value
- 判断数组的长度是否超过默认的12,如果超过则调用扩容方法
resize()方法
resize方法有两个作用:
1.初始化Table表,使用默认容量16创建一个新的Table表
2.对哈希表进行扩容,会遍历表中的所有元素重新计算hash值后移动元素到新的表中
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; // 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;
if (oldTab != null) { //将旧表中的元素转移到新表中,并将旧表所有的元素引用置为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 利用尾插法转移链表上的结点,大致操作为将链表上所有的元素分为两串,
//一串是不需要移动位置的结点,另一串是需要移动位置的结点,
//判断是否需要移动的条件就是if ((e.hash & oldCap) == 0)
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;
}
-
hashmap的扩容机制
hashmap的扩容机制是先插入再扩容,判断是否需要进行扩容的条件是当哈希表的长度即数组的长度超过了12(load factory * capacity)时,需要将进行扩容,但是执行resize()(注意再上文中提到了resize方法有两个作用)方法的情况却不止一种。
为什么说进行扩容操作的情况不止一种呢,这是因为该方法在hashmap 中主要有两处应用,第一处一是在put方法中使用,还有一处是在树化方法(treeifyBin()方法)中使用,在put方法中的使用不需要多说,在树化方法中的使用和开始提到的MIN_TREEIFY_CAPACITY = 64参数有关,这是因为在将链表树化之前需要先判断一下哈希表的长度是否超过了MIN_TREEIFY_CAPACITY = 64,如果未超过则进行扩容而不是树化,只要在超过了MIN_TREEIFY_CAPACITY时才会进行树化。也就是说在hashmap中,链表真正需要的树化的条件是链表长度大于TREEIFY_THRESHOLD(默认为8)并且哈希表的长度大于MIN_TREEIFY_CAPACITY(默认为为64) ,这是因为在树化时需要判断哈希表是否是因为长度太小而引起了链表的树化,如果长度太小则首先进行扩容而不是树化,否则会引起频繁的树化(说明此时哈希冲突过于严重),而且频繁地将链表树化也会影响hashmap的效率。换句话说就是当链表长度过长时,hashmap首先的想到的是扩容而不是树化。
需要注意的一点是哈希表的长度(size)和容量(capacity)是有区别的,希望大家不要混淆。
所以需要执行resize()方法的情况主要有三种:
①表为空时需要调用resize方法初始化表
②当表的长度大于threshold(默认为12)时会调用resize方法
③在树化时,如果表的长度小于MIN_TREEIFY_CAPACITY(最小树化阈值),也需要进行扩容 -
hashmap如何解决哈希冲突
①链地址法:也就是利用哈希表来存储键值对
②在初始化hashmap的时候设计者就要求哈希表的容量是2次幂,使得哈希表有较高的散列度
具体与操作tab[ i ] = n-1 & hash有关,当n为二次幂时,i的分布是均匀的。
③扩容机制,上文已经有具体说明,这里不再赘述
最后,本文仅代表个人理解,如有理解不当的地方,欢迎指正。