HashMap是高效的查询容器,底层的数据结构是数组 + 链表 + 红黑树。查询可以基于数组下标快速访问,链表用来解决hash冲突的问题,红黑树用于解决频繁的hash冲突导致查询效率低的问题。
数据结构
HashMap的缺点
在HashMap中数组的空间的利用率较低,空间利用率和负载因子有关,默认的负载因子是0.75,实际空间利用率要低于0.75。我们都知道数组是一段连续的存储空间,数据量越大浪费的空间就越多。
寻址流程
hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
算法描述
key的哈希值异或哈希值的高16位,因为我们的Map实际上并没有存特别巨大数量的值,可以理解为低16位和高16位做异或操作。
算法目的
正常我们都会重写key的HashCode方法,尽可能的保证key的哈希值是均匀分布的以减少哈希冲突。但是在HashMap中,寻址是基于hash结果和当前的tableSize - 1进行与运算,得到目标位置。tableSize一般为2的幂次,tableSize - 1的结果为连续的0和连续的1,一般我们使用HashMap大小不会很大,低位的1比较少,和这样的结果进行与操作低位特征不够随机的话,冲突几率很大。
问题是,无论我们的hash算法结果再怎么均匀,实际上最后依旧只用后 logn 位的特征去进行与运算,碰撞依旧是比较严重。如果hash不均匀恰好计算得到的hash值的低位呈现规律性的重复,Hash碰撞就会极其严重,此时就体现出了“扰动函数”的价值。
Hash值右移16位,正好是32位的一半,高16位和低16位异或,混合了原始hash值的高低位的特征,以此来加大低位的随机性。
哈希表元素的数据结构
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
// ... ...
}
描述
每一个结点都有一个hash、key、value和next,结点是构成单链表的数据结构。未满足树化条件前,结点都是上面代码显示的Node数据类型。
哈希表树结点的数据结构
static final class TreeNode extends LinkedHashMap.Entry {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
// ... ...
}
描述
HashMap在1.8之后引入了红黑树的概念,红黑树是二叉搜索树的一种,常规的二叉搜索树只需要满足结点的值大于做孩子的值小于右孩子的值即可。但是红黑树不仅仅是这样,红黑树需要满足以下的性质。
红黑树的性质:(来自算法导论第三版 p174)
传统二叉搜索树的缺点
传统的二叉搜索树最坏的情况下树的高度等于结点的数目,以至于查询数据达到了O(n),最坏的查询情况和链表没啥区别。
AVL树
平衡二叉树是一种平衡了树高的结构,使得任意一个结点的左右子树高差值不超过1。可以解决上述的二叉搜索树的缺点,AVL树可以弥补传统的二叉搜索树的缺点,但是它又引来了新的问题,当数据量大了的时候维护AVL树性质的自旋操作会很影响插入性能。
数据结构小结
红黑树的是许多“平衡搜索树”中的一种,它是一种似平衡的状态,它可以保证在最坏的情况下基本动态集合的操作时间复杂度为O(logn)。
put方法会先进行hash,然后调用putVal方法进行实际处理。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal描述
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 哈希表为空初始化
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 索引位置为空,放入当前结点
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 键相同值替换,后面判断替换值
e = p;
else if (p instanceof TreeNode)
// 树结点,使用红黑树的插入方法
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表
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))))
break;
p = e;
}
}
// 替换原值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// ... ...
get方法会先hash然后getNode寻找值
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode描述
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
// 找hash值对应到数组中的元素下标,判断下标位置是否为空
(first = tab[(n - 1) & hash]) != null) {
// 判断头结点
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 判断是否是树结点
if (first instanceof TreeNode)
return ((TreeNode)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;
}
put方法onlyIfAbsent传的是false
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)
putIfAbsent方法onlyIfAbsent传的是true
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
putVal的onlyIfAbsent参数是干嘛的
// 键相同替换原值的逻辑
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 如果onlyIfAbsent是false则是替换原值的
// onlyIfAbsent是true则是不会替换原值的
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
onlyIfAbsent参数为false,则新值会替换原值并且返回原值。
onlyIfAbsent参数为true,则原值不变并且返回原值。
测试代码
public static void main(String[] args) {
Map map = new HashMap<>();
map.put("haha",123);
System.out.println(map.put("haha",456));
System.out.println(map.putIfAbsent("haha",789));
}
执行结果
123
456
Process finished with exit code 0
条件1:putVal方法
当前count值要大于等于 TREEIFY_THRESHOLD(8) - 1,计数器从0开始计数,到7刚好是8个结点。所以和网上的一些博客描述的都一样,链表结点插入后,链表结点满足8个就要进行树化,但树化并非仅仅是这一个条件。
else {
// 遍历单链表,判断相同,直到找到空值
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 待插入结点已插入
p.next = newNode(hash, key, value, null);
// 树化条件1: 当前count值大于等于 8 - 1,当前待插入结点如果是第八个结点就树化。
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
treeifyBin(tab, hash)方法
表的长度小于MIN_TREEIFY_CAPACITY(64),就不进行树化操作,resize扩容即可。反之,表的长度大于或等于64,才可以进行链表树化的操作。
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
// 表长度小于 MIN_TREEIFY_CAPACITY 64,此时扩容即可不需要进行树化操作。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 表长大于等于64,才需要进行树化操作
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode hd = null, tl = null;
do {
TreeNode p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
equals方法是比较两个对象是否相同的方法,Object基础实现是比较hashCode,也就是对象的地址。
我们自定义类型如果想当作HashMap的键是需要重写equals方法的,否则两个对象的属性值相同,但是却不是同一个对象,地址不相同导致最终结果不相等。如果作为键的对象没有重写equals,这肯定是有问题的。
hashCode方法,是唯一标识一个对象的方法,Object默认实现时返回对象的地址。
HashCode重写时需要注意以下几点:
《Effective Java》中提出了一种简单通用的hashCode算法:
案例
@Override
public int hashCode() {
int hash = 13;
hash = hash * 31 + (name != null ? name.hashCode() : 0);
hash = hash * 31 + (location != null ? location.hashCode() : 0);
return hash;
}
HashMap在1.8有了一些变化,多是查找相关的优化。这种数据结构在日常开发过程中太常用了,原理是一定要摸清楚的。懂得HashMap原理并没有让我在开发技能上有显著的提高,但是在数据结构转换使用上扩展了自身的一些想法。