一. get()方法
public V get(Object key) {
Node<K,V> e; //定义临时节点用于返回结果
//先调用hash(key)方法获得key的hash值,再调用getNode方法找到Node节点
//如果Node节点不存在返回null,存在的话返回Node节点的value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//使用扰动函数使得hash值分布更加均匀
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//根据hash和key查询Node节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//tab[(n - 1) & hash]可以找到key对应在hash桶中的位置
//如果hash桶不为空并且key对应hash桶的位置上存在节点,进入遍历
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果第一个节点的hash值和key值和当前查询的key一致,直接返回第一个节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//第一个节点的hash值或者key值和查询的key不一致,并且存在下一个节点,执行下面方法
if ((e = first.next) != null) {
if (first instanceof TreeNode) //如果当前节点在红黑树中
//调用getTreeNode方法获得红黑树中对应的节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { //如果当前节点不在红黑树中,也就是链表中,进入循环
//如果循环中某个节点的hash值和key值一致,返回这个节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null); //当不存在下一个节点的时候结束循环
}
}
return null; //如果hash桶为空或者key对应hash桶的位置上不存在节点,返回null
}
获取流程:
- 计算需要查询的key的hash值
- 判断hash桶是不是空以及该hash值对应在hash桶上是否存在节点,不存在直接返回null
- 对比头节点的hash值和key是否和需要查询的key一致,如果一致直接返回头节点
- 判断头节点在红黑树中还是链表中
- 如果在红黑树中,则在红黑树中查找该节点
- 如果在链表中,则遍历链表查询该节点
下面来分析几个小细节:
1.先比较hash再比较key
我们发现判断HashMap中节点是否是需要查询的key的时候使用的判断条件是
e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))
会先比较一下hash值是否一致再比较key是否相等,这么做是也是为了查询效率。
因为通常key都会重写equals方法,通过equals方法比较的效率明显是比hash值比较要慢的。
所以再equals判断之前先比较一次hash值可以提前规避很多不符合的节点,效率上会高一些。
2.先比较头节点,再遍历链表(红黑树)
我们发现当取到key对应hash桶中的节点的时候,会先比较一下头节点是否是我们需要查询的节点,如果不符,再去遍历链表(红黑树)。
因为在hash算法足够分散的情况下,很多hash桶的每一个桶中都只会存在一个节点,所以先比较第一个节点再去遍历也能带来效率上的提升。
二. remove()方法
public V remove(Object key) {
Node<K,V> e; //定义临时节点
//先调用hash(key)方法获得key的hash值
//调用removeNode方法删除Node节点
//如果Node节点不存在返回null,存在的话返回Node节点的value
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
//根据hash和key,删除Node节点
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//当table不为空,并且hash对应的桶不为空时
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//桶中的头节点就是我们要删除的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p; //用node记录要删除的头节点
else if ((e = p.next) != null) { //头节点不是要删除的节点,并且头节点之后还有节点
if (p instanceof TreeNode) //头节点为树节点,则进入树查找要删除的节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else { //头节点为链表节点
do { //遍历链表
//hash值相等,并且key地址相等或者equals
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e; //node记录要删除的节点
break;
}
p = e; //p保存当前遍历到的节点
} while ((e = e.next) != null);
}
}
//我们要找的节点不为空
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
//在树中删除节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) //我们要删除的是头节点
tab[index] = node.next; //删除标题元素
else //不是头节点,将当前节点指向删除节点的下一个节点
p.next = node.next; //删除多元素链表节点
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
三. HashMap常见问题
-
为什么需要负载因子系数?
存在负载因子的原因还在于减轻哈希冲突,例如,默认情况下初始存储桶为16,或者通过等待直到完整的16个元素被扩展,某些存储桶中可能有多个元素。因此,加载因子默认为0.75,也就是说,第13个元素的HashMap大小16(阈值= 0.75 * 16 = 12)将扩展为32 -
降低负载因子系数的作用?
在构造函数中,设置较小的负载系数,例如0.5甚至0.25。
如果存在一个长期存在的Map,并且密钥不是固定的,则可以适当地增加初始大小,减小负载因子,减少冲突的可能性,并减少寻址时间。交换时间也值得。 -
是否在初始化时定义容量?
通过以上源代码分析,每次扩展都需要重新创建存储桶数组,链表,数据转换等,因此扩展成本仍然很高。如果可以准确地设置或估计初始容量(即使较大),则有时值得交换时间。
四. HashMap1.7和1.8改动
-
加入红黑树
在JDK1.7中,当键的哈希值相同时,将形成哈希表。当链表上的节点数越来越多时,HashMap的查询效率降低,查询性能从O(1)变为O(n)。HashMap中的缺陷。
JDK1.8中的HashMap添加了一个红黑树来弥补这一缺点。当链接列表上的节点数超过8个时,HashMap会将链接列表转换为红黑树结构。红黑树结构的引入将原来降低的查询性能从O(n)改进为O(lgn)。 -
为什么会选择8作为链表转红黑树的阈值?
在负载因子默认为0.75时,单个hsah槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,当槽内元素等于7时不进行转换,当元素数量大于等于8时转换为红黑树,槽内元素小于等于6时转换回链表. -
干扰哈希方法
JDK 1.7中的扰动函数将干扰密钥四次,但在JDK1.8中仅扰动一次,并且密钥的hashCode值进行XOR运算。
根据前一篇文章的分析,我们知道HashMap使用hash&(length-1)来获取节点的索引位置,该位置是哈希值的最后几位,因此只有低数位在操作中,高阶数字被忽略,但是哈希方法中的XOR操作使hashCode的每个位都参与获取索引位置的操作,因此哈希函数映射更均匀,并降低了哈希冲突的可能性。
JDK 1.7 和1.8HashMap扩容的区别
- JDK 1.7在扩容前会判断hash桶是否为空,如果为空会提前创建;JDK 1.8会在扩容时检查hash桶是否为空
- JDK 1.8在链表转移的时候引入了低位链表和高位链表进行转移,效率更高
- 在JDK 1.7中HashMap使用头插法在扩容期间执行元素传输,在多线程情况下,两个节点有可能形成一个闭环链表,这将导致在扩容期间造成链表的死循环。
但是,在JDK 1.8中,HashMap使用尾插法来执行元素传输,每个节点的原始顺序不会被颠倒,从而避免出现死循环的情况,但1.8中的HashMap仍然是线程不安全的,因为在扩容中,HashMap首先指向新的节点数组,然后在进行元素传输,因此在多线程情况下,get方法可能获取不到值。
线程安全的map?
Hashtable是线程安全的,它的每个方法中都加入了Synchronize方法。当需要多线程操作的时候可以使用线程安全的ConcurrentHashMap。ConcurrentHashMap虽然也是线程安全的,但是它的效率比Hashtable要高好多倍。因为ConcurrentHashMap使用了分段锁,并不对整个数据进行锁定。
也可使用Collections.synchronizedMap();返回一个线程安全的map集合。
参考链接:
链接: HashMap源码分析,基于1.8对比1.7
链接: JDK1.8 HashMap源代码分析