一文浅析—从面试题连环炮的角度梳理HashMap
你了解HashMap的底层数据结构吗?
对于HashMap
的底层数据结构在Java7
和Java8
中的实现是不同的,在Java7
中是采用数组+链表的数据结构进行实现,而在Java8
中是采用数组+链表+红黑树的数据结构实现的。
说时迟那时快,刚话说完,从兜里拿出笔和纸,啪地一声放在桌子上画了起来,许久之后,出现了两幅jdk7和jdk8的HashMap的内部结构图:
那你清楚HashMap的数据插入原理吗?
我们来看jdk1.8的put方法
①.判断键值对数组table[i]是否为空或为null,为空就执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容
public V put(K key, V value) {
// 对key的hashCode()做hash
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;
// 步骤①:tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key,value,null);
//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
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;
// 步骤⑥:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
这里我们对value的覆盖做一个详细的解释 看他是怎么替换value的 我们截取一部分代码
作者学这里的时候 网上的博客质量参差不齐 希望我这里能给大家说清楚
// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
在步骤2这里 我们计算了index索引 并且把位置i上的Node节点赋值给了p 这里再次强调 此时的p就是位置i上的Node节点 我们只有看懂了这个p代表什么 才能继续往下
// 步骤③:节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
在步骤3这里 我们发现要放得元素的hash值和已有的这个节点p的hash值是一样的 我们进入了这个if里
将p的值赋给了e 这里要放的和已有的hash值是一样的情况 我们暂时用e进行了保存 接下来的else和else if也就不会进入了 代码直接到了这里
if (e != null){ // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
这里就是做了一个value的覆盖 将oldValue返回 我们自己在调用HashMap的put方法是也会发现 put方法的返回值就是oldValue
这里 在明确一个事情!!!
// 步骤③:节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
没有进入这个if的情况有2种 其一是两者的hash值根本不等 其二是hash值相等 但是equals方法不等
这里还是要再次强调!!!
你知道jdk1.8下什么情况下会扩容吗?
这里有两种情况会发生扩容
第一 超过阈值 就是我们熟悉的超过阈值12
时才会扩容
第二 链表转为红黑树且数组元素小于64时 在jdk1.8
中,默认长度为16
情况下,要么元素一直放在同一下标,链表转为红黑树且数组元素小于64时就会扩容
这第二种情况我们相对来说 不是很熟悉 我们看一下源码
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//链表转为红黑树时,若此时数组长度小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
...//省略
}
}
也就是说我们在去完善一下 当数组的某一个索引位置上的元素以链表的形式存在>8 并且数组长度大于64时 此时索引位置上的所有数据改成红黑树存储 如果数组长度小于64 他是在扩容数组啊
这里可得千万注意 不要跟作者一样 作者当年以为当元素>8就转红黑树了 这里看还是太年轻了 留下了不学无术的泪水
你来聊一下jdk1.7为什么会发生死循环吧
假设:有线程A和线程B,并发访问HashMap中的数据。假设HashMap
的长度为2
(这里只是为了讲解方便假设长度为2),链表的结构图如下所示:
4和8都位于同一条链表上,其中的threshold为1,现在线程A和线程B都要进行put操作,首先线程A进行插入值。
此时,线程A执行到transfer
函数中(transfer
函数是resize扩容方法中调用的另一个方法),当执行(1)
位置的时候,如下所示:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; ---------------------(1)
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} // while
}
}
此时线程A挂起,在此时在线程A的栈中就会存在如下值:
e = 4
next = 8
此时线程B执行put
的操作,并发现在进行put
操作的时候需要扩容,当线程B执行 transfer
函数中的while
循环,即会把原来的table
变成新一table
(线程B自己的栈中),再写入到内存中。
执行的过程如下图所示
特别注意一个关键点
我们看这行代码
e.next = newTable[i];
我们想说明一个这样的一个问题 线程B对8这个节点进行操作的时候 e的next指向了新table
也就是说此时线程B
8.next=4;
也就是上图中8指向了4
我们在往下继续
此时线程A有获取到cpu
的执行时间,接着执行(但是线程A中的数据仍是旧表数据),即从transfer
代码(1)处接着执行,当前线程A的 e = 4, next = 8
, 上面已经描述,执行的的过程若下图所示:
当操作完成,执行查找时,会陷入死循环!
这里我们插一句题外话
为啥线程A的数据仍然是旧表的数据啊?这块应该跟我们的JVM有关系
在Java虚拟机栈当中 每个线程在创建的时候都会创建一个虚拟机栈 栈中保存了我们的基本数据类型和引用类型还有局部变量 上述e和next就作为局部变量保在了每一个线程所私有的栈当中 所以线程A的数据仍然是旧值(只有堆和方法区的数据会被所有线程所共享)
那jdk1.8换了尾插法就是线程安全的了吗?
此时 一口老血吐了出来 并发出了(老子不干了)这样的小声哔哔
回答是 :不是
即是是解决了死循环的问题 那HashMap在1.8依然是线程不安全的 我们看下他的原因
如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会开始操作。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
总结一下
HashMap是线程不安全的,其主要体现:
#1.在jdk1.7中,在多线程环境下,扩容时会造成环形链表。
#2.在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。
你来说一下怎么计算元素的索引位置吧(Hash函数怎么计算啊)
Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算
高位运算:
给大家科普 这里用到了异或 就是^东西 这个东西的规则就是 两个数不一样就是1 一样就是0
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
取模运算
(这里并不是用了取模运算 而是用了&来优化他的速度)
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
关于HashMap以上只可能是冰山一角 由HashMap可以牵扯的东西是在太多了 既可以涉及到底层的数据结构 为啥加入了红黑树(你要了解红黑树 就得先懂二叉树 排序树 平衡树 各种树…)红黑树相比较以前会有那些优化 又可以聊到线程安全问题 这时候syn和lock这些就要来了 一个HashMap引出了太多的问题需要我们去解决 恰恰给了面试官可乘之机 (面试官为就可以有这么令人发指的深度和广度)…
其实 最近越来越感觉学习并不是最难的东西 学习是一个面对困难和挑战的过程
我们要学习的应该不是学习知识本身 而是要在学习中培养解决困难的能力 培养坚持的品格
用这些品质和心性去乐观的面对生活 解决随着年龄增加 残酷真实的社会所带给我们的挑战 去学着尽量热爱生活!!
以上就是我一个人的瞎话 大家看了也就散了
关于他的好兄弟ConcurrentHashMap 应该会有一篇的 看看我的好兄弟pdd-jason能不能先写出来
他写完我给大家在正式的 由浅入深的copy一遍
溜了!