HashMap是我们每个程序员非常熟悉的一个集合,也是高频面试题。了解其原理及底层源代码非常必要
文章目录
一、基础入门
数组和链表
1、数组:数组的内存是连续的内存空间,因为有下标所以查询效率高,但是插入删除慢。并且没有办法快速扩容。
2、链表:不是连续内存,每块内存都有一个引用空间,保留下一个内存的地址。只能通过head元素去拿下一个元素,索引效率低。
那有没有一种方式整合两种数据结构的优势? 散列表
什么是散列表
核心理论:Hash也称散列、哈希,对应的英文都是Hash。基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出。这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值。
Hash的特点
1.从hash值不可以反向推导出原始的数据(不可逆)
2.输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值
3.哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
4.hash算法的冲突概率要小
由于hash的原理是将输入空间的值映射成hash空间内,而hash值的空间远小于输入的空间。
根据抽屉原理,一定会存在不同的输入被映射成相同输出的情况(hash碰撞)
抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果。这一现象就是我们所说的“抽屉原理”。
二、HashMap原理讲解
继承体系
Node数据结构分析
final int hash; //存储Key的hash值,Key经过HashCode算法后经过一个扰动后得到的一个hash值
final K key; //
V value; //
Node<K,V> next; //经过hash碰撞后,链表的下一个节点
底层数据结构
数组(默认长度16)+链表(长度大于8并且所有元素大于64.树化成红黑树)+红黑树
三、HashMap源码分析
HashMap核心属性
DEFAULT_INITIAL_CAPACITY = 1 << 4 //默认数组大小16
MAXIMUM_CAPACITY = 1 << 30 //数组最大长度
DEFAULT_LOAD_FACTOR = 0.75f //默认负载因子
TREEIFY_THRESHOLD = 8 //树化阈值
UNTREEIFY_THRESHOLD = 6 //树降级成为链表的阈值
MIN_TREEIFY_CAPACITY = 64 //整个hash表的元素超过64后,并且某一个链表长度超过8之后才会树化
构造方法分析
public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity) //套娃
public HashMap() //无参构造
public HashMap(Map<? extends K, ? extends V> m) //根据Map构建一个Map
//主要是做了一些校验
public HashMap(int initialCapacity, float loadFactor) {
//参数不合法校验,初始容量不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//参数不合法校验,最大值最大不能超过MAXIMUM_CAPACITY,
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//负载因子值不能小于0,且必须是数值类型
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//table数组初始化必须是2的n次方,tableSizeFor方法会将初始化大小转换成大于等于的2的n次
this.threshold = tableSizeFor(initialCapacity);
}
put方法-putVa
//实际上套娃了putVal
//:false
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
//hash函数,key调用了hashCode算法然后与(h >>> 16)做异或运算
//异或:相同返回0,不同返回1
//作用:让key的hash值的高16位也参与寻址(具体参考下图)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// onlyIfAbsent如果为true,当key存在时则不会插入,可参考putIfAbsent方法,put方法中默认是false进行覆盖
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab:当前散列表
//p: 当前散列表的元素
//n: 标识散列表的长度
//i: 路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果在put前,散列表没有被创建
if ((tab = table) == null || (n = tab.length) == 0)
//实质上new一个散列表,采用初始化延时,懒惰加载,resize会在后面详解
n = (tab = resize()).length;
//这里时最简单的情况,找到的桶位如果是null,直接将key-value放进去就可以
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//else可能是数组,也可能是链表或者红黑树
else {
//e: 一个临时node,当找到一个与当前元素相同的key时会进行替换操作
//k: 一个临时的key,同上
Node<K,V> e; K k;
//表示如果桶位中的该元素与你当前插入的key完全一致,则需要进行替换操作,p赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//表示该元素与你当前的Key不一致,并且已经树化(红黑树可以参考手撕红黑树源码)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//链表的情况,并且链表的头元素与当前key不一致
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
//判断当前元素不存在链表,则将当前元素插入到链表末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//当并且遍历到第7位时没有找到与当前元素一致的元素,在插入就已经是第8个元素了,说明需要树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//这里说明在链表中找到了相同的key,赋值e,所以在下面进行了替换
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//替换操作,如果e不等于null,说明找到了相同元素,上面提到了两个地方给e赋值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//散列表修改次数加一,替换不算,因为替换时已经提前返回oldValue,不会走这里
++modCount;
//如果散列表size大于扩容阈值,触发扩容
if (++size > threshold)
resize();
//这是记录插入顺序LinkedHashMap会用到
afterNodeInsertion(evict);
return null;
}
put流程总结:
1、延迟初始化逻辑,第一次滴哦用putVal时会初始化hashmap对象中的最耗费内存的散列表
2、最简单的一种情况:寻找到的桶位刚好是null,这个时候直接将当前k-v-node放进去就可以
3、桶位元素与你当前插入的key完全一致,则替换
4、链表的桶位元素与我们插入的key不一致。迭代链表到最后一个元素时而且也没找到与要插入
的key一致的node,说明需要加入到当前链表末尾。如果链表长度大于8则会树化
5、如果e不等于null,说明找到了完全一致的数据需要替换。
6、modecount++,散列表结构修改次数,替换Node不算
7、插入新元素size+1,如果自增后的的size大于扩容阈值,则扩容
get方法
final Node<K,V> getNode(int hash, Object key) {
//tab:引用当前hashMap散列表,first:桶位中的头元素,e:临时node元素;n:table数组长度
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//(tab = table) != null && (n = tab.length) > 0 判断table数组不为空,
//(first = tab[(n - 1) & hash]) != null 桶位头元素不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//【1】定位的桶元素即为当前元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//说明当前元素不只一个元素,可能时链表可能是红黑树
if ((e = first.next) != null) {
//【2】桶位进化成了红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//【3】桶位形成链表,遍历链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return
remove方法
//一个参数,根据key去删除
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
//一个参数,根据key去查找,并且value也相同,才删除
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}
//matchValue:如果为true说明key和value需要全部匹配上才能删除
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
//tab: 引用当前hashMap散列表
//p:当前node元素
//n:表示散列表数组长度
//index:表示寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 先判断当前散列表是否为空
// (p = tab[index = (n - 1) & hash]) 寻址判断当前桶位不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//node:表示查找到的节点,e:表示当前元素的下一个元素
Node<K,V> node = null, e; K k; V v;
//【1】当前桶位的元素即为要删除的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//当前桶位下有数组或者链表
else if ((e = p.next) != null) {
//【2】当前桶位已经树化
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//【3】当前桶位已经链化,遍历链表
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//node不等于空说明找到了对应的key,matchValue如果为true去匹配value值
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);
//如果节点是桶位节点,则当前桶位即为要删除的元素,将node的后继节点指向桶位中
else if (node == p)
tab[index] = node.next;
//如果节点链表的一个节点,将当前节点的后继节点连接到前置节点上
else
p.next = node.next;
//结构修改次数加1
++modCount;
//元素数量减一
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
问题分析
HashMap的长度为什么要是2的n次方?
(减少hash碰撞)
如果不是2的次幂的数的话 假设数组长度是一个奇数,那参与最后的&运算的肯定就是偶数,那偶数的话, 它二进制的最后一个低位肯定是0,0做完&运算得到的肯定也是0,那意味着&完后得到的数的最低位一定是0 最低位一定是0的话,那说明一定是一个偶数,换句话说就是:&完得到的数一定是一个偶数, 所以&完获取到的脚标永远是偶数位,那意味着奇数位的脚标永远都没值,有一半的空间是浪费的 奇数说完了,来说一下偶数,假设数组长度是一个偶数,比如6,那参与&运算的就是5 5的二进制 00000000 00000000 00000000 00000101 发现任何一个数&上5,倒数第二低位永远是0, 那就意味着&完以后,最起码肯定得不出2或者3(这点刚开始不好理解,但是好好想一下就能明白) 意味着第二和第三脚标位肯定不会有值 所以不是2的次幂的话,不管是奇数还是偶数,就肯定注定了某些脚标位没值。注定了某些脚标位永远是没有值的。
HashMap为什么最大值为1 <<30?
JAVA规定了该static final 类型的静态变量为int类型,至于为什么不是byte、long等类型,原因是由于考虑到HashMap的性能问题而作的折中处理!
由于int类型限制了该变量的长度为4个字节共32个二进制位,按理说可以向左移动31位即2的31次幂。但是事实上由于二进制数字中最高的一位也就是最左边的一位是符号位,用来表示正负之分(0为正,1为负)。如果把1(000…001)向左移动31位,最高位就是1(100…000)了,此时就变成一个负数,所以1<<31=-2147483648。,所以只能向左移动30位,而不能移动到处在最高位的符号位!
为什么默认是16呢?怎么不是4?不是8
这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。
太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。所以,16就作为一个经验值被采用了。