HashMap详解

此文章包含了大部分HashMap有关的面试题,如有其它欢迎在评论区补充。

HashMap的一些基础知识:
在这里插入图片描述

:HashMap的默认初始容量是多少?

:默认初始容量是16,且默认初始容量必须是2的次幂

:为什么默认初始容量必须是2的n次幂?若创建HashMap传入的initialCapacity不是2的次幂会发生什么?

:因为(2的次幂数 - 1)的二进制形式表示都是1,这样在和经过异或运算的h进行按位与运算的时候才可以最多地保留其特性,减少产生哈希碰撞的概率,让数组空间均匀分配。

如果传入的initialCapacity不是2的次幂数,则HashMap会通过一通位移运算和或运算得到一个容量比传入的initialCapacity大的最小的2的次幂数,并将其作为HashMap的初始容量。例如传入7得到初始容量为8的HashMap,传入9得到初始容量为16的HashMap。

补充源码:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

:插入新数据时如何计算其在数组的索引?

:HashMap首先调用 hashCode() 方法,获取键key的 hashCode值h,然后对其进行高位运算:将h右移16位以取得h的高16位,与原h的低16位进行异或运算。再将返回的值与(数组长度-1)进行按位与运算,得到的便是新插入数据在table中的索引。

:为什么计算数组索引的时候要将哈希值与哈希值无符号右移16位进行异或运算?

:如果当n即数组长度很小,假设是16的话,那么n-1的二进制表示为1111,这样的值和哈希值直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小时,这样就很容易造成哈希碰撞,所以这里把高低位都利用起来,从而解决这个问题。

:当两个对象的hashCode值相等时会发生什么?

:会产生哈希碰撞,若key值内容相同则替换旧的value;否则连接到链表后面,链表长度超过阈值8且table长度大于64就转换为红黑树。JKD8之前使用头插法,JDK8之后使用尾插法。

:为什么Map桶中节点个数超过8才转为红黑树?

:TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且查看源码时发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这也解释了为什么不是一开始就将其转换为 TreeNodes,而是需要一定节点数才转为 TreeNodes。其实就是权衡,在空间和时间中权衡。
当 hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机 hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机 hashCode算法下所有bin中节点的分布频率会遵循lambda为0.5的泊松分布,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的Java每一项改动和优化都是非常严谨和科学的。
也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以才会选择8这个数字。

还有另外一种解释:红黑树的平均查找长度是log(n) ,如果长度为8,平均查找长度为log(8) = 3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log(6) = 2.6,虽然速度也比链表快,但是转化为树结构和生成树的时间并不会太短,且红黑树占用的内存比链表更大。

总之,树化阈值选择8是在时间和空间中找到一个最好的平衡点。

:什么时候HashMap会扩容?怎样扩容?
:当 HashMap中键值对的数量超过数组大小(数组长度)* loadFactor(负载因子)时,就会进行数组扩容。
扩容时先新建一个容量为原来两倍的HashMap,再进行rehash操作将原来的HashMap中的数据迁移到新的HashMap中去。
JDK8是使用新的rehash方法,即如果元素和原先数组长度进行按位与运算的结果为0,那么其迁移后的数组索引不变;否则迁移后的数组索引变为原数组长度+原数组索引。

:为什么要reHash呢,直接复制过去不香么?

:因为扩容后的重新计算索引的值也相应改变。
index = HashCode(Key) & (Length - 1)
因此如果直接复制过去的话,新扩容的一半数组都是空的,数据不够分散,会增大以后产生哈希碰撞的概率。

:默认负载因子(加载因子)是多少?为什么设置为这个值?

:默认负载因子为0.75。
负载因子不能设置太小很容易理解,因为设置太小的话会很容易进行扩容操作,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能,所以开发中尽量减少扩容的次数,且有大量开辟出的新空间未被使用,造成资源浪费。
不能将其设置太大是因为一般情况下哈希桶很难被填满,如果loadFactor设置太大的话,当size达到扩容阈值的时候,很有可能某些哈希桶下的链表长度会非常长,此时查询速度减慢,性能降低。
总的来说,负载因子太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。 loadFactor的默认值为0.75,是官方经过大量试验后给出的一个比较好的临界值。

:table中用来存放元素的数组是什么类型的?

:JKD8之前是Entry<K,V>类型,JDK8之后是Node<K,V>类型。其实只是换了一个名字,都实现了一样的接口:Map.Entry<K,V>。负责存储键值对数据。

:HashMap是线程安全的吗?

HashMap不是线程安全的

:为什么HashMap不是线程安全的?

:第一点是put时候多线程可能导致数据不一致
第二点是JDK7中HashMap的get操作可能因为resize而引起死锁

:在什么情况下put时多线程会导致数据不一致?

:假设有线程A和线程B,一开始线程A希望插入一个键值对到HashMap中,但是计算得到桶的索引坐标,获取到该桶里面的链表头结点后阻塞了,而此时线程B执行,成功地将键值对插入到了HashMap中。假设此时线程A被唤醒继续执行,而线程A保存的插入点正好是线程B插入元素的位置,如此一来ji就覆盖了线程B的插入记录,造成了数据不一致的现象。

:JDK7中的HashMap为什么会出现死锁?

:JDK7版本中的HashMap扩容时使用头插法,假设此时有元素一指向元素二的链表,当有两个线程使用HashMap扩容的时,若线程一在迁移元素时阻塞,但是已经将指针指向了对应的元素,线程二正常扩容,因为使用的是头插法,迁移元素后将元素二指向元素一。此时若线程一被唤醒,在现有基础上再次使用头插法,将元素一指向元素二,形成循环链表。若查询到此循环链表时,便形成了死锁。而JDK8版本中的HashMap在扩容时保证元素的顺序不发生改变,就不再形成死锁,但是注意此时HashMap还是线程不安全的。

:使用数模方法解决HashMap线程不安全的问题?

:使用线程安全的ConcurrentHashMap
或者给HashMap加锁,但是会使效率变低。

:ConcurrentHashMap是怎样保证其是线程安全的?

:。。。。。。

小故事:在HashMap的继承关系中有一个很奇怪的现象,就是 HashMap已经继承了 AbstractMap,而 AbstractMap类实现了Map接口,但是HashMap又去实现了Map接囗。同样在 ArrayList中 LinkedList中都是这种结构。
据Java集合框架的创始人 Josh Bloch描述,这样的写法是一个失误。在Java集合框架中,类似这样的写法很多,最开始写Java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。

  • 14
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值