◣外面已然是阳春三月,相信那本该喧闹、人潮拥挤、车水马龙的日子很快就会复苏◢
可以关注我的微信公众号:xiaobei109208,每周一篇技术分享哦。
BUG分享区
在进入主题之前,先跟大家分享一个我这几天遇到的一个小问题,满脑子都是“小朋友,你是否有很多问号❓....”
Entity.java
我先说下这段代码,很简单,一个List集合作为参数,拿到值之后先做基础判断,然后进行第一次for循环,再创建一个和参数泛型一样的实例,并且接收children的值,到这都没有什么问题,并且断点跟踪也是有值的,但是!!!到下一步for循环的时候报错了........
看着没问题啊,这是为何???大大的脑袋,大大的疑问🤔️
于是,接着看异常~
作为程序员,心态很重要,不要因为一个bug心态就崩了,于是我.....开心的玩了半小时手机。
当我再次看代码的时候,突然脑海里闪过,我靠,数据类型不一样啊!!!!这里是LinedHashMap。
害。。。当场气得我把旁边的娃哈哈喝个底朝天!!!
问题找到了,那肯定就有解决办法了,代码转换(这里我用的是net.sf.json包)
Ok,问题解决~bug不难,但细心很重要~~
正文
进入主题,HashMap应该是非常常用的一种数据结构了,当然也是各大公司面试的热点话题,只要是Java面试,可以说十个公司最少有九个会问HashMap相关的底层实现,还有一个招的是产品(捂嘴笑出猪叫声...)
那今天这篇文章就来说一说HashMap的那些事。
HashMap在jdk1.7和jdk1.8之间从数据结构、数据插入方式等等都做了很大的改动,别着急,等等娓娓道来,放心,不会再娓远了。。。
JDK1.7下的HashMap
在JDK1.7中 HashMap采用的是数据和单向链表的方式存储数据,数组都是通过key-value存储数据,并且在put的时候根据key的hash随机计算出当前值的索引index,也就是我要把这个键值对放在数据的什么位置。
比如我现在put(“周杰伦”,”Jay”),通过hash算法计算出index=1
在put(“易烊千玺”,”Jackson”),通过hash算法得出index = 6
源码效果:
(IDEA这里可能有些同学看不到这种结果值,这样一下就可以了)
但是呢!数组毕竟是有限的,再加上哈希本身既有概率性,可能出现多个key的hash值是相同的,所以就有了hash冲突这一名词。那怎么办呢?不能说我前者刚put进去的值,后者因为和前者hash值相同,你就把前者覆盖吧,那存取不一致,你怎么给我一个“胶带”?
于是就有了链表这个东西,什么是链表?简单看个图,
当出现hash冲突时,就会在当前索引的位置以一种头插法的方式填充值。(这里有一个知识点要记一下,JDK1.7中HashMap是采用头插法)
那什么又是头插法?(一问三不知的表情)
来,看图说话,我先put的是周杰伦,然后再put易烊千玺,结果第二次put的时候出现了hash冲突,于是就出现图中的情况。是不是言简意赅?
看一下源码案例:
这里特意找了两个hash值是一样的key,在put的时候,很明显由于hash冲突的原因,在当前索引下以链表的形式并以头插法的方式存放数据。
有个知识点小拓展,无关技术,为什么这个方法的作者要在jdk1.7的时候使用头插法?
据说是因为作者觉得后来插入的值被查询的可能性更大一些,这样做可以稍微的提高一点效率。
:但是这种方式在并发情况下去存在Bug的,什么Bug呢?先思考一下。
HashMap的扩容
可能有的同学在我上述的图中,注意到了这里,创建HashMap的时候我给了初始容量大小。
其实在日常开发中,很多同学不会给定初始容量。HashMap是有有参构造的,而且HashMap的初始容量大小为16,负载因子0.75f,,如果说初始不给我们看会怎样?
很明显,HashMap初始化预留了16个数组的空间,但是只用到了一个,就会造成资源浪费和性能不必要的消耗,而且,阿里开发手册中也规范了这一点。
但是,其实我看到这个建议,我是很不明白的,为啥要这样去设置初始值。
在jdk中,new HashMap()并且给定初始值的时候,jdk会帮我们取第一个大于初始值的2次幂。HashMap的数组长度 = 2的N次幂,也就是(2、4、8、16等等)
假如给定初始值为5,猜一下最终HashMap的数组长度是多少?
答案是8
这里可能就有疑问了,为什么我给5,最终的数组长度却为8?上面说了HashMap的数组长度 = 2的N次幂,也就是(2、4、8、16等等),5>4但<8呀,所以这就是HashMap数组长度的规则。
“哎,我只存放5个元素,HashMap数组却是8个,不还是会有资源浪费,内存不必要的消耗嘛?”
这个确实是,它虽然有一些内存的消耗,但可以说是微不足道的,它真正的目的是为了避免当我们存放的元素个数大于数组容量而导致的自动扩容(resize),那才是真正的问题所在,自动扩容非常的消耗性能。
JDK1.7 HashMap自动扩容(resize)机制
这里就说到了HashMap自动扩容(resize)机制了。那HashMap在什么时候发生自动扩容(resize)?又是如何实现自动扩容(resize)的?
当put的元素个数大于初始容量*负载因子(0.75)时就会发生自动扩容
举个例子🌰:
创建一个HashMap,给定初始值为8,然后put的元素个数为7个,在没有扩容的情况下,数组长度应该为8,但是!!!根据计算规则 8*0.75 = 6 < 7,在put第7个元素的时候发生了自动扩容。
看图,数组的长度由原本应该是8扩容到16,同时执行了ReHash,遍历原来的Entry数组,把所有的Entry重新Hash到新的数组,所以它是非常消耗性能的。
这里又有一个问题,(每一个知识点我都会想扣一个问题出来,可真是烦人😄)
为什么要重新Hash呢?直接拷贝过去不是更快嘛?(3秒思考一下)
因为数组长度扩容之后,hash规则也会随之改变。
扩容前:
扩容后:
总结:HashMap实现自动扩容一共分两步:
1.创建一个新的Entry空数组,长度是原来数组的2倍
2.遍历原来的Entry数组,把所有的Entry重新Hash到新的数组
哎,突然想到上面还遗留了一个问题,头插法在并发情况下是有Bug的,那么有什么Bug呢?(是基于链表结构而言的)
首先都知道HashMap是非线程安全的,多线程并发put操作会形成环形链表,从而产生死循环。
看下最关键部分的代码:
如果创建一个HashMap初始容量为2,在put第二个元素的时候,就会发生扩容,
(A线程)扩容前:
假如有A、B两个线程,A线程在执行图中红框中的代码,这一行线程就会被挂起,此时A线程:e=‘周杰伦’;next=’易烊千玺’;
接着B线程开始扩容,假设新的Entry中,“周杰伦”和“易烊千玺”还是存在hash冲突,
那么B线程扩容后:
此时A取消挂起,执行图中红框中的代码,将e=‘周杰伦’迁移至新的Entry,并将next=‘易烊千玺’,A线程扩容后:
于是第二次执行while循环时,由于线程B在扩容时将节点“易烊千玺”的后继节点变成“周杰伦”,所以:
接着第三次执行while循环,由于“周杰伦”的后继节点为null。所以next=null,图示:
循环结束,最终扩容后Entry变成了一个环形链表,这个时候执行get()方法,就会出现死循环。
讲完JDK1.7的,再来看看JDK1.8下HashMap做了哪些调整和优化?
JDK1.8下HashMap
在JDK1.8下,首先HashMap使用Node替代了Entry,
其次引入了红黑树的数据结构,至于什么是红黑树,这里考虑到篇幅的问题,就不过多描述,之后出单独出一篇关于红黑树的文章。
为什么会引入红黑树?
在1.7版本中 存在一个问题,即使负载因子和Hash算法设计的再合理,也避免不了链表过长的情况,一旦出现链表过长,就会严重影响HashMap的性能。于是在1.8版本中引入了红黑树,当链表长度超过8时,链表就会转换成红黑树,利用红黑树快速增删改查的特点来提高HashMap的性能,当链表长度小于6时,就会退化成链表。
那又为什么是长度超过8才会变成红黑树,为啥不是9、10、11等等?
因为时间和空间上的权衡,首先当链表为6时,查询的平均长度为n/2=3;
红黑树为log(6)=2.6。为8时:链表 8/2=4;红黑树 log(8) = 3
还有很重要的一点,在1.8版本中,HashMap采用了尾插法,使用头插会改变链表上元素顺序,但是如果使用尾插法,在扩容的时候会保持链表元素的顺序,也就不会有环形链表的问题出现了。
但!!!注意一点,不是说在1.8中HashMap就可以用在多线程中。
它依旧是线程不安全的,尽管不会出现死循环,但源码中put/get方法中并没有加同步锁,而多线程最容易出现的就是,上一秒put进去的值,下一秒没取到或者取到的还是原来的值,然后。。。。。
当然线程安全的方法是有的,HashTable和ConcurrentHashMap。
先看下HashTable,在put的时候加了同步锁。
JDK1.8 HashMap自动扩容(resize)机制
JDK1.8的扩容机制做了很大的优化,要注意的一点是1.8的HashMap再扩容的时候没有rehash的概念了,而是在扩容中只判断原来的hash值与左移动一位的值(newtable)的值,按与(&)操作是0还是1,0的话索引不变,1的话原索引加上扩容前的数组长度。
final Node<K, V>[] resize () {
Node<K, V>[] oldTab = table;
//记住扩容前的数组长度和最大容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//超过数组在java中最大容量就无能为力了,冲突就只能冲突
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//长度和最大容量都扩容为原来的二倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
double threshold
}
//...... ......
//更新新的最大容量为扩容计算后的最大容量
threshold = newThr;
//更新扩容后的新数组长度
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历老数组下标索引
for (int j = 0; j < oldCap; ++j) {
Node<K, V> e;
//如果老数组对应索引上有元素则取出链表头元素放在e中
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果老数组j下标处只有一个元素则直接计算新数组中位置放置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果是树结构进行单独处理
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else {
preserve order
//能进来说明数组索引j位置上存在哈希冲突的链表结构
Node<K, V> loHead = null, loTail = null;
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
//循环处理数组索引j位置上哈希冲突的链表中每个元素
do {
next = e.next;
//判断key的hash值与老数组长度与操作后结果决定元素是放在原索引处还是新索引
if ((e.hash & oldCap) == 0) {
//放在原索引处的建立新链表
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
//放在新索引(原索引 + oldCap)处的建立新链表
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;
}
从存储的角度两者是有区别的:HashMap允许key/value为null,而Hashtable不可以。
虽说Hashtable是线程安全的,但它是Java保留类,在多线程的环境下,还是推荐ConcurrentHashMap,源码我就不贴了,大家可以多多阅读源码。
还是那句话,知其然必先知其所以然~~
☆ END ☆