集合三件套之三-hashmap面试题

155 阅读14分钟

HashMap面试题

1. 你跟我讲讲HashMap的内部数据结构?

jdk1.8之前是数组+链表,jdk1.8之后是数组+链表红黑树

来个数据结构图吧:解释下

  • 首先转换为红黑树需要两个条件,阈值超过8,数组长度超过64,才会转换为红黑树,还有就是链表的节点如果小于6的话,红黑树会转换为链表,如果大于8的时候才会转为红黑树。

2. 那你清楚HashMap的数据插入原理吗?

  • 判断数组是否为空,为空进行初始化;

  • 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;

  • 查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;

  • 存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据(onlyIfAbsent为false);

  • 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;(如果当前节点是树型节点证明当前已经是红黑树了)

  • 如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8并且数组长度大于64, 大于的话链表转换为红黑树;

  • 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

土话回答:先判断数组存不存在,不存在创建,存在,计算hash值,通过取余,获取数据准备插入的下标,不存在数据,直接进行插入,存在数据,则发生了哈希碰撞,通过equals方法比较两个值,不相等但是key相等,则进行替换,如果不相等,判断下是不是红黑树节点,如果不是,是链表,创建普通的node加入链表,但是要判断一下,阈值是不是大于8,数组长度是不是大于64,是的话把链表转换为红黑树,如果是红黑树的话,直接进行插入,插入完成之后判断当前节点是否大于阈值,如果是扩容为原数组的2倍

3. HashMap怎么设定初始容量大小的

一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16。

4.你知道HashMap的哈希函数怎么设计的吗

hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作

5. 那你知道为什么这么设计吗?

这个也叫扰动函数,这么设计有二点原因:

  1. 一定要尽可能降低hash碰撞,越分散越好;

  2. 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

6. 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为**-2147483648~2147483647**,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

7. 1.8还有三点主要的优化:

  • resize 扩容优化

  • 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考

  • 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。

8. 那你平常怎么解决这个线程不安全的问题?

Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。

HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

9. 那你知道ConcurrentHashMap的分段锁的实现原理吗?

ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

10. 你前面提到链表转红黑树是链表长度达到阈值,这个阈值是多少?

阈值是8,红黑树转链表阈值为6

11. 为什么是8,不是16,32甚至是7 ?又为什么红黑树转链表的阈值是6,不是8了呢?

因为经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,概率说话。。因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化,为了预防这种情况的发生。

12.说一下hashmap的实现原理

  • HashMap概述: HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

  • HashMap的数据结构: 在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。

HashMap 基于 Hash 算法实现的

  1. 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标

  2. 存储时,如果出现hash值相同的key,此时有两种情况。

    (1)如果key相同,则覆盖原始值;

    (2)如果key不同(出现冲突),则将当前的key-value放入链表中

  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。

  4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。

需要注意Jdk 1.8中对HashMap的实现做了优化,当链表中的节点数据超过八个之后,该链表会转为红黑树来提高查询效率,从原来的O(n)到O(logn)

13.HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现

  • 在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;*

    所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做*

    拉链法的方式可以解决哈希冲突。

14.HashMap的put方法的具体流程

详见问题2

15.HashMap的扩容操作是怎么实现的

  • 在jdk1.8中,resize方法是在hashmap中的键值对大于阈值时或者初始化时,就调用resize方法进行扩容

  • 每次扩展的时候,都是扩展2倍

  • 扩展后的node对象要么在原位置,要么偏移到原偏移量两倍的位置

  • 我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

    final Node<K,V>[] resize() {    Node<K,V>[] oldTab = table;//oldTab指向hash桶数组    int oldCap = (oldTab == null) ? 0 : oldTab.length;    int oldThr = threshold;    int newCap, newThr = 0;    if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空        if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值            threshold = Integer.MAX_VALUE;            return oldTab;//返回        }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&                 oldCap >= DEFAULT_INITIAL_CAPACITY)            newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold    }    // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂    // 直接将该值赋给新的容量    else if (oldThr > 0) // initial capacity was placed in threshold        newCap = oldThr;    // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75    else {               // zero initial threshold signifies using defaults        newCap = DEFAULT_INITIAL_CAPACITY;        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);    }    // 新的threshold = 新的cap * 0.75    if (newThr == 0) {        float ft = (float)newCap * loadFactor;        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?                  (int)ft : Integer.MAX_VALUE);    }    threshold = newThr;    // 计算出新的数组长度后赋给当前成员变量table    @SuppressWarnings({"rawtypes","unchecked"})        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组    table = newTab;//将新数组的值复制给旧的hash桶数组    // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散    if (oldTab != null) {        // 遍历新数组的所有桶下标        for (int j = 0; j < oldCap; ++j) {            Node<K,V> e;            if ((e = oldTab[j]) != null) {                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收                oldTab[j] = null;                // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树                if (e.next == null)                    // 用同样的hash映射算法把该元素加入新的数组                    newTab[e.hash & (newCap - 1)] = e;                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排                else if (e instanceof TreeNode)                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);                // e是链表的头并且e.next!=null,那么处理链表中元素重排                else { // preserve order                    // loHead,loTail 代表扩容后不用变换下标,见注1                    Node<K,V> loHead = null, loTail = null;                    // hiHead,hiTail 代表扩容后变换下标,见注1                    Node<K,V> hiHead = null, hiTail = null;                    Node<K,V> next;                    // 遍历链表                    do {                                     next = e.next;                        if ((e.hash & oldCap) == 0) {                            if (loTail == null)                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead                                // 代表下标保持不变的链表的头元素                                loHead = e;                            else                                                                // loTail.next指向当前e                                loTail.next = e;                            // loTail指向当前的元素e                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。                            loTail = e;                                                   }                        else {                            if (hiTail == null)                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素                                hiHead = e;                            else                                hiTail.next = e;                            hiTail = e;                        }                    } while ((e = next) != null);                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。                    if (loTail != null) {                        loTail.next = null;                        newTab[j] = loHead;                    }                    if (hiTail != null) {                        hiTail.next = null;                        newTab[j + oldCap] = hiHead;                    }                }            }        }    }    return newTab;}
    

16.ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

  • JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

  • 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;

  • 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount;

17.ConcurrentHashMap 和 Hashtable 的区别?

实现线程安全的方式

  • 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

  • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

18.HashMap 与 HashTable 有什么区别?

线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap );

效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;(如果你要保证线程安全的话就使用 ConcurrentHashMap );

对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException。

初始容量大小和每次扩充容量大小的不同

底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

19.HashMap 的长度为什么是2的幂次方

  • 为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。

这个算法应该如何设计呢?

  • 我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

那为什么是两次扰动呢?

  • 答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;