在日常开发工作中,HashMap经常被使用到,作为一个有探索精神的程序员肯定得搞清楚其基本原理的吧。
说到HashMap必须的说一下哈希算法,散列算法(Hashing)是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法。有了这个哈希算法,我们就可以将一个输入key值输出为一个int索引值了。这个索引值有什么用呢?这就的从HashMap的组成结构说起了
1、HashMap的数据结构(JDK1.8)
jdk1.8中HashMap的数据结构是由数组+链表+红黑树组成
有了这个HashMap的数据结构,我们就可以通过以下代码往里面添加数据了
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
从从添加数据过程可以看出首先会通过hash算法获取一个hash值,这个hash值实际上还并不是我们的index,比如我们上图中的table的index最大只能为7,而这里获取的hash值有可能是比7大的数值,在HashMap中通过(n - 1) & hash
(这里的n是table的长度)来确保最后的index不会超过table长度。
上图中table数组长度有限,当加入的数据过多时总会出现有些key值生成的hash值相同的情况,也就是说不同key值可能会对应同样的index,这种情况就叫做哈希冲突
HashMap使用链表和红黑树避免哈希冲突(相同hash值),当链表长度大于TREEIFY_THRESHOLD(默认为8)时,将链表转换为红黑树,当然小于UNTREEIFY_THRESHOLD(默认为6)时,又会转回链表以达到性能均衡。
2、添加以及读取数据
HashMap作为一个数据容器,其主要的作用就是添加和读取数据了,添加数据的方法对应put,而读取数据方法对应get,这里就来分别说说这两个方法
2.1 添加数据
添加数据通过第一节的put方法来实现,实际调用的是如下方法,这里基本就用一文读懂HashMap文章里的对应方法注释了,也还算是清楚了
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table为空或者长度为0,则resize()
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//确定插入table的位置,算法是(n - 1) & hash,在n为2的幂时,相当于取摸操作。
找到key值对应的槽并且是第一个,直接加入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//在table的i位置发生冲突,有两种情况,1、key值是一样的,替换value值,
//2、key值不一样的有两种处理方式:2.1、存储在i位置的链表;2.2、存储在红黑树中
else {
Node<K,V> e; K k;
//第一个node的hash值即为要加入元素的hash
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//2.2
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//2.1
else {
//不是TreeNode,即为链表,遍历链表
for (int binCount = 0; ; ++binCount) {
///链表的尾端也没有找到key值相同的节点,则生成一个新的Node,
//并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树。
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不为空就替换旧的oldValue值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
针对这个方法说几个关键点吧
1、方法最后会将table的size和treshold进行对比,当前者大于后者时会进行扩容操作,扩容实际上就是讲table的容量增大一倍,并且将原来的数据添加新的HashMap中去,这个就不详细展开,感兴趣的可以参考一文读懂HashMap这篇文章,说的比较清楚了
2、这里的putVal方法中添加到链表中的新的Node是添加到链表的尾部的,这个和JDK1.7添加新Node到链表的开始不一样
2.2 获取数据
HashMap通过get方法获取数据
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
实际上还是通过getNode方法来获取数据
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) {
//table数组中根据hash值找到对应的Node则返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果对应的是红黑树,则根据hash和key值在红黑树中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//不是红黑树则对应的是链表Node,根据hash和key值遍历链表查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
实际上getNode还是很简单,依次在数组、红黑树以及链表中通过hash值和key值来搜索对应的Node,实际上我们根据hash值和key值可以完全定位到Node在HashMap中的位置,搜索完成都没有找到的话,就返回null。
关于第二节需要说明的是这里没有针对红黑树的操作展开说明,实际上这个对我们从宏观上理解HashMap的原理影响不大,感兴趣的童鞋可以自己找源码看看
3、HashMap和HashTable的区别
HashMap和HashTable都是针对Map接口的实现,所以它们的功能是差不多的,它们的主要区别在线程安全这一块,先说结论:HashMap线程不全,而HashTable则是线程安全的
HashMap的线程不安全主要表现在一下两点:
1、put的时候导致的多线程数据不一致
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2、resize可能会引起死循环(仅针对JDK 1.7)
这种情况发生在HashMap自动扩容时,当两个个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize()进行扩容,两个线程同时修改一个链表结构会产生一个环形链表(JDK1.7中,会出现resize前后元素顺序倒置的情况),也就是可能出现A.next=B,B.next=A的情况。接下来再想通过get()获取某一个元素,就可能会出现死循环。关于resize可能引起死循环的详细原因可以参考HashMap这篇文章。
而HashTable是线程安的原因其实很简单,也就是通过synchronized关键字实现的,如下
public synchronized V put(K key, V value)
public synchronized V get(Object key)
关于synchronized关键字的详细认识可以参考我的另外一篇博客关于synchronized关键字的认识
另外需要说明的是,由于HashTable为了实现线程安全使用了synchronized关键字,它的执行速度会比HashMap慢,所以在不存在并发的时候优先考虑使用HashMap,而存在并发的情况下为了保证线程安全,建议使用HashTable
4、参考文献
1、一文读懂HashMap
2、HashMap原理深入理解
3、HashMap
4、关于synchronized关键字的认识