深入理解HashMap原理
什么是HashMap
是一种靠hash值来进行分配存储的Map结构,Map就是用来储存键值对的集合类
为什么我们需要了解HashMap底层原理
- HashMap是面试时必考的一道面试题
- HashMap也是工作中最常用的集合之一,所以很有必要了解底层原理,进而在出现问题时能快速定位问题
- HashMap里面涉及了很多的知识点,可以全面考察面试者的基本功,想要拿到一个好offer,这是一个迈不过去的坎,接下来我用最通俗易懂的语言带着大家揭开HashMap的神秘面纱
常用的Map集合有哪些
常用的有HashMap、TreeMap、LinkedMap等,这里着重讲解一下HashMap,因为HashMap最常用,面试也是最常问
HashMap底层数据结构
这里向大家介绍的是jdk8的HashMap,因为jdk8对HashMap进行了进一步的优化,当数组长度和链表长度达到阀值时会把链表转化为红黑树,这样就进一步优化了查询的时间复杂度,这里也会涉及更多的知识点,所以面试会很常问
HashMap采用的是数组+链表+红黑树的结构
HashMap源码分析
我们可以思考一下我们应该从哪里入手了解HashMap呢?
首先从HashMap的类头入手
// 继承了AbstractMap,AbstractMap里面实现了一部分Map的基本方法
// 实现了Map接口,里面一些方法需要子类去根据自己的特性去实现
// 实现了Cloneable、Serializable,可以实现克隆和序列化
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
// 默认初始化大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大初始化容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转化为红黑树的阀值(链表长度达到8)
static final int TREEIFY_THRESHOLD = 8;
// 红黑树退化为链表的阀值 (节点个数为6)
static final int UNTREEIFY_THRESHOLD = 6;
// 最小转化为红黑树的数组容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 存放链表的数组
transient Node<K,V>[] table;
// 可以用作获取key、value
transient Set<Map.Entry<K,V>> entrySet;
// 键值映射数
transient int size;
// hashMap被修改的次数,这个参数用于判断快速失败
transient int modCount;
// 键值对数达到多少进行扩容
int threshold;
// 负载因子
final float loadFactor;
}
其实观察到上面的成员属性,这里面回答了特别常问的几道面试题
-
HashMap中的链表转化为红黑树的条件是什么?
链表的长度达到8并且HashMap中数组的长度达到64,这样才会进行红黑树的转化,否则只会进行数组的扩容
-
成员属性table被
transient
修饰说明不参与序列化但是实际上是可以进行序列化的,为什么这么做?HashMap的设计者不想整个数组都被序列化,而是在序列化的时候把其中的内容进行了序列化
接下来我们分析HashMap的构造函数
// HashMap有很多重载的构造函数,但是最全最基本的就是这个
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 如果传入的initialCapacity不是2的整数倍转化为2的整数倍,这个在后面解释
this.threshold = tableSizeFor(initialCapacity);
}
接下来就开始揭开put()方法真实的面纱啦!!
// 最常用的put方法,首先我们需要知道hashMap是通过key的hash值去找到数组中某一个位置的
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 如果key不是空的,通过把key的hashcode()值高16位和低16位进行异或获得结果,为什么这么做后面解释
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// put方法的具体实现方法
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数组还未初始化,进行初始化数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过上一步得到的hash值和数组长度-1进行&操作,得到要存放在数组的具体位置
// 如果数组所在位置为null,直接构建成Node节点存放(这个也就是构建的链表节点)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果数组所在位置有值存在
else {
Node<K,V> e; K k;
// 如果数组中的头节点的key值相同,进行覆盖
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;
}
// 在遍历过程中存在节点key与待插入key相同,则直接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;
}
}
// 被修改次数+1
++modCount;
// 如果当前键值对数大于扩容阀值,则进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
接下来看下数组扩容的过程
// 扩容方法
final Node<K,V>[] resize() {
// 老数组
Node<K,V>[] oldTab = table;
// 老数组的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 老数组的扩容阀值
int oldThr = threshold;
// 用于存放扩容后的新数组的容量和扩容阀值
int newCap, newThr = 0;
// 老数组容量是大于0说明不是初始化数组操作,而是数组内键值对数触发的扩容
if (oldCap > 0) {
// 如果老数组的容量已经大于最大的容量了,扩容阀值直接赋值成最大的Integer值后直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则判断如果把老数组容量左移1位(就是容量*2)为什么采用左移这个操作后面解释
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容阀值也是左移1位
newThr = oldThr << 1; // double threshold
}
// 如果老数组的容量是0,说明正在做数组初始化操作,新容量就是老扩容阀值的值,也就是我们传进去的值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 如果在构造函数未指定值,则采用默认值16
else { // zero initial threshold signifies using defaults
// 新数组容量是默认16
newCap = DEFAULT_INITIAL_CAPACITY;
// 扩容阀值是 负载因子*默认容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果是自定义指定数组大小,但是未指定扩容阀值,则根据上述构建新的扩容阀值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 根据新数组的容量新建数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果数组非null
if (oldTab != null) {
// 循环老数组的每个位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 头节点非null
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果头节点的下一个节点是null
if (e.next == null)
// 直接把节点重新进行hash选位置,赋值
newTab[e.hash & (newCap - 1)] = e;
// 如果当前节点是红黑树节点
else if (e instanceof TreeNode)
// 把红黑树进行拆分,分为上数和下树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 节点是链表节点
else { // preserve order
// 构建高低链
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 把低链放在当前位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 高链放在当前位置+扩容值
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
这里回答下上面分析源码时未具体展开的问题
-
hash值是通过key的hashcode()值高16位和低16位进行异或获得结果
其实是为了减少碰撞,进一步降低hash冲突的几率,右移16位异或可以同时保留高16位于低16位的特征,如果是&或者|都会失去一定的特征,这样更能减少碰撞
-
为什么在传入非2的整数倍会自动变为2的整数倍
因为当数组的长度是2的倍数时,当计算key所在数组的位置时(p = tab[i = (n - 1) & hash)这里当n-1用二进制表示他的尾数都是1
举例:如果数组长度是16,n-1为15二进制表示是:0000 1111
这样在对hash值做&运算时具体的值是由hash值决定的
举例:如果有两个待插入的键值对,A的hash值:0000 1001 B的hash值:0000 1101,这样当hash值对n-1&时,他俩的结果就是不同的,但是如果n-1其中尾数有非1 比如 0000 1001,这样A和B对n-1进行&就会产生相同的结果,就会出现碰撞的情况
为什么采用&,如果在扩容时采用*在key寻找位置的用%的方式可以不?
当然可以,但是为了性能的提升,所以都采用了位运算,这样就能带来性能的提升
-
为什么扩容是采用左移的方式,而不是直接*2
在上一个答案中已经解答因为位运算更快,能提高性能
缺点
上面介绍了HashMap的底层原理,当然我们都知道HashMap是线程不安全的,那么当出现并发的场景我们应该怎么选择呢?下期将给大家揭开ConcurrentHashMap的面纱!
总结
到这我们已经了解了HashMap的底层结构、put方法的具体实现和作者在一些实现中的优化点,也能学习到作者在一些地方的代码思路
其实我知道看源码会很枯燥,很乏味,但是我觉得还是应该坚持下去,这样能做到知其然,知其所以然,这些看源码的能力会潜移默化的提升你的能力,当你做开发时会不由自主的想到作者的一些思路,从而提升你的代码能力,我希望每个开发者都能坚持下来,一起加油!
最后我想用一句话结束这篇文章,努力加油吧,多年以后,你一定会感谢曾经那么努力的自己!
我是爱写代码的何同学,我们下期再见!