目录
HashMap
我们知道 , hashMap是双列集合 , 存储的是键值对 , 键不能重复,值可以重复 , 是Map接口下面的实现类 , 底层采用哈希表+链表+红黑树实现 ,另外哈希表默认长度16,负载因子0.75, 扩容2倍,链表长度为8自动转为红黑树等等 , 接着来一一解释它们
我们先来了解 hashMap 的底层到底是怎样去存储元素的, 如下图

如图所示, 当我们去 put(key, value) 一个键值对的时候, hashMap会先根据 key 去计算一个hash值, 然后通过计算得到的hash值来决定我们put 这个元素在数组中的索引index
这个计算表达式为 : index = (length-1) & hash ,后面会提到
通过上图可以看到, a 元素是一个键值对,通过 一系列计算得出 在哈希表索引为 1(index ==1), 而b 计算出的索引 为 2 , 但是这时我们加入了一个新元素c(键为"通话",如果此时两个元素键相同的话,那么这里会用新value替换旧value,但键只有一个),这个元素计算出的hash值与我们a 元素的相同(哈希碰撞:不同的数据计算出的哈希值相同)索引都是1,这时我们则将c元素通过尾插法连接到a元素后面 ,如果这时又来了一个d元素,碰巧又产生了哈希碰撞,然后继续往我们的链表上继续相连 , 但是我们知道,如果这样一直连下去,链表越来越长, 那么我们查找元素算法耗费的时间也会越长 , 所以当链表的长度达到一定阈值的时候 , 我们将它转化为一颗红黑树, 这样可以降低算法的时间复杂度(O(logn))
红黑树是一颗自平衡的二叉搜索树(这里不过多介绍,后续数据结构里会提到)
看到这里, 相信你对hashMap储存元素的特点也有了一定的了解,接着我们来看源码是怎样去做的
先来看这几个参数的作用:
//默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转换红黑树的阈值,8
static final int TREEIFY_THRESHOLD = 8;
//红黑树元素减少到了6个后,就退化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树的另一条件,数组长度需要达到64
static final int MIN_TREEIFY_CAPACITY = 64;
//存放Node结点的数组
transient Node<K,V>[] table;
//存放键值对
transient Set<Map.Entry<K,V>> entrySet;
//数组中的元素个数
transient int size;
//数组扩容阈值
int threshold;
//加载因子
final float loadFactor;
单链表节点类如下 :
//单链表结点类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //hash值
final K key;
V value;
Node<K,V> next; //next域指向下一个元素
//构造
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
put(k,v)方法如下 :
public V put(K key, V value) {
// 通过key计算hash值后调用putVal()方法
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;
//如果table为空则触发扩容: 通过resize()方法
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//先通过(n-1)&hash 计算得到的下标 得到在数组中的位置
//如果为null 则直接构造一个新结点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 此位置如果不为 null 则分这几种情况
Node<K,V> e; K k;
// 如果传入的hash值与此位置hash值相同,并且key也相同
// 那么此时我们采用值覆盖的做法,用新value覆盖旧value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果这时此位置已经转换为了一颗红黑树
//我们将它添加到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 普通链表形态,采用尾插法将元素连接
else {
for (int binCount = 0; ; ++binCount) {
// 如果结点后为null,则构建新结点连接
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;
}
}
++modCount;
//数组元素超过阈值,调用resize()扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
看完 put(k,v) 方法后, 我们便看见了扩容的问题,那么扩容机制是怎样的呢?
这里我们先来看计算hash值方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们看到 , hashMap的键是可以取空值的
我们在上面提到的计算索引值 : index = (length-1) & hash , 如果发生了扩容,那么意味着length的值发生了改变,那么原先的index肯定是不能继续使用了,这时我们需要计算新的index将原来数组的元素分配到新数组中去
有了以上这些基础,我们再来回答下列问题
为什么负载因子默认0.75呢?
这其实是时间与空间的权衡 , 如果设置成0.5, 那么空间利用率太小,造成空间浪费, 而如果设置成0.8,或者0.9 ,那么发生哈希碰撞的可能将会大大增加,影响效率
为什么初始容量设置为16呢 ?
源码中是这样的 : static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
我们发现, 它并没有直接写16,而是通过位运算的方式得出16,再联系上面的式子index = (length-1) & hash, 我们先随便计算一个hash值 ,通过hashCode()方法得出
"通话"的二进制hash码 : 1 0001 1111 1111 0000 0011 length-1 =15 15的二进制码为1111
这时我们做 & 运算,可以发现最后得到的二进制码 就跟计算得出的hash后四位一样,这时我们发现这样一个规律,当容量为2的倍数时, length -1 的值的二进制所有位都是 1 ,那么这时计算出的index便可由 计算出的hash码决定 ,所以只要 hashCode本身分布比较均匀的话 , 那么发生哈希碰撞的可能就会大大减少
那为啥不是8 或者 36呢?
因为 8 未免太小 , 扩容也需要将所有元素重排, 36太大,也可能空间浪费 , 所以权衡之下, 16作为一个比较标准的初始容量就保留了下来
hashMap 如何保证键不重复?
hashCode() 与 equals() , 我们知道, 我们是通过 key(键),来计算哈希值的, 但是如果产生了哈希碰撞 , 那么此时便需要通过 equals() 来判断这两个键是否相等 , 所以我们一般在重写equals()时一般也重写hashCode(), 确保产生的哈希值合理
关于equals()与hashCode()可参考:认识和了解Object类_xx12321q的博客-CSDN博客
本文详细解析了HashMap的底层实现,包括为何负载因子设为0.75、初始容量选16的原因,以及如何通过哈希值和equals()保证键唯一性。还深入探讨了扩容机制及hash计算方法,揭示了其在时间和空间效率上的平衡策略。
803

被折叠的 条评论
为什么被折叠?



