HashMap必知必会

1、HashMap的初始容量为什么为2^n ?

目的:减少hash冲突

tab[(n - 1) & hash]

jdk1.8使用该代码来获取数组元素,不仅效率高,而且可以保证数组不越界,让元素分布尽可能均匀。

为了保证这些优势,所以hashmap的容量要为2^n。

  • 数组的长度为2^m,n-1的形式为0111…111,所以与hash进行&运算,结果值一定比n小;

  • n-1的形式为0111…111,进行&运算能更好地区分开数,减少哈希冲突。


2、加载因子LoadFactor为什么是0.75 ?

负载因子loadFactor是和扩容机制有关的,是一个扩容的阈值,意思是如果当前容器的使用度,达到了我们设定的最大值,就要开始执行扩容操作。

  • 比如默认的容器容量为16,我们现在使用了的有16*0.75=12,此时就应该扩容。HashMap的数据结构为数组+链表/红黑树(链表过长时转换,默认为8,可以提高查询效率),下面根据其底层的数据结构,从时间效率和空间利用率两个方面来说明一下负载因子取值的问题:
  • 1.0:loadFactor为1.0时,根据localFactoroldCapcity计算阈值,表明我们会在数组容量用尽之后才开始扩容,此时插入数据会产生大量的hash冲突,导致底层的链表或红黑树的结构变得复杂,查询效率变低,这是用时间效率来换得空间的利用率的提高。结论是负载因子取值过大
  • 0.5:loadFactor为0.5时,根据localFactor*oldCapcity计算阈值,表明当数组容量使用一半就开始扩容,此时hash冲突少,链表的长度或者红黑树的高度就会降低,查询效率就明显变高了,但是空间利用率会变的很低,每次会有一半空间用不到,这是用空间利用率去换得时间效率的提高。结论是负载因子取值过小。
  • 0.75:根据大量数据测试得到,此时的空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了时空效率,所以一般使用默认值就行。

3、JDK1.8做了哪些优化 ?

1、底层数据结构的变化

2、插入元素的方式

3、哈希表为空时的操作

4、hash函数的变化

5、扩容策略

  • 在 JDK 1.7 中 HashMap 底层数据结构是数组加链表,JDK 1.8 之后新增了红黑树的组成结构,当链表大于 8 并且容量大于 64 时,链表结构会转换成红黑树结构,在极端情况下,hashcode 完全冲突(一般不会发生),链表查询时间复杂度为O(n),而如果是红黑树,查找某个特定元素,也只需要O(log n)的开销,查询效率明显提升了。不过树化是需要一定的成本的,这里暂时不考虑。

  • jdk1.7之前是使用头插法,因为考虑到了一个所谓的热点数据,即新插入的数据可能会更早用到,但问题是jdk1.7中rehash的时候,旧链表迁移到新链表,如果在新表的数组索引位置相同,则链表元素会倒置, 所以最后的结果还是打乱了插入的顺序。而且头插法还会造成死链表:链表太长就需要扩充数组长度进行rehash减少链表长度,如果多线程同时触发扩容,在移动节点时会导致一个链表中的2个节点相互引用,从而生成环链表。jdk1.8之后使用的尾插法,把待插入元素挂在链尾,解决了死循环问题。

  • jdk1.7中当哈希表为空时,会先调用inflateTable()初始化一个数组;而1.8则是直接调用resize()扩容。

  • jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀。

  • jdk1.7中是只要不小于阈值就直接扩容2倍,而1.8的扩容策略更优化。当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数不小于7就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。


4、HashMap获取结点的步骤

1、根据hash得出数组下标索引。
2、根据索引获得索引位置所对应的键值对链表/红黑树。
3、遍历键值对链表/红黑树,根据key找到对应的Entry结点。
4、返回该结点。

final Node<K,V> getNode(int hash, Object key) {
        //通过hash查找Entry所在的桶,然后通过key匹配对象返回
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //哈希表存在 && 数组长为2^m && 能找到对应的数组下标结点first
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //查看第一个结点是否符合
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                //红黑树结构遍历查询
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    //单链表遍历查询
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

5、HashMap是线程安全的 ?

hashmap显然不是线程安全的。主要是由于其在put过程中的扩容导致的,当然如果只是单纯的取数在jdk1.8是线程安全的。
在多线程情况下,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先需要计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。


6、HashMap与ConcurrentHashMap的区别 ?

HashMap线程不安全但效率高,ConcurrentHashMap线程安全但是效率比HashMap要低,ConcurrentHashMap会有更多的变量来支持线程安全所需要的乐观锁,在put方法中也加入了synchronized代码块锁住了当前的节点头使其只能一个线程访问,在扩容时其他线程添加元素会被搁置并且协助进行扩容,并声明自己正在处理哪个位置。


7、ConcurrentHashMap如何实现线程安全 ?

通过乐观锁与synchronized来实现线程安全,在初始化数组与空节点添加元素的时候使用乐观锁来实现线程安全,在给非空节点添加元素的时候会使用synchronized代码块锁住当前节点头从而实现线程安全,而在扩容的时候会将转移后的节点单独保存起来以便于在扩容中的put与get操作,如果put需要操作的元素还没有转移完成则会被搁置等待转移完成,被搁置的线程也会被分配到转移数据的任务从而协助扩容。


Thank you.

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值