Java面试之HashMap

1.什么时候用链表?什么时候用红黑树?

  • 对于插入,默认情况下是使用链表节点。当同一个索引位置的节点在新增后达到9个(阈值8):如果此时数组长度大于等于64,则会触发链表节点转红黑树节点;而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容,因为此时的数据量还比较小。
  • 对于移除,当同一个索引位置的节点在移除后达到 6 个,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。

2.为什么链表转红黑树的阈值是8?

  • 我们平时在进行方案设计时,必须考虑的两个很重要的因素是:时间和空间。对于 HashMap
    也是同样的道理,简单来说,阈值为8是在时间和空间上权衡的结果
  • 红黑树节点大小约为链表节点的2倍在节点太少时,红黑树的查找性能优势并不明显,付出2倍空间的代价作者觉得不值得
  • 理想情况下,使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为0.00000006(跟大乐透一等奖差不多,中大乐透?不存在的),这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。
    在这里插入图片描述

3.为什么转回链表节点是用的6而不是复用8?

  • 如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗

4. HashMap 有哪些重要属性?分别用于做什么的?

  1. size:HashMap 已经存储的节点个数
  2. threshold扩容阈值,当 HashMap 的个数达到该值,触发扩容。在我们新建 HashMap 对象时, threshold还会被用来存初始化时的容量。HashMap 直到我们第一次插入节点时,才会对 table 进行初始化,避免不必要的空间浪费
  3. loadFactor负载因子,扩容阈值 = 容量 * 负载因子。
  4. HashMap的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传9,容量为16。

5.为啥必须是2的N次方呢?

计算索引位置的公式为:(n - 1) & hash,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时任何值跟 n - 1 进行 & 运算会等于其本身,达到了和取模同样的效果,实现了均匀分布。实际上,这个设计就是基于公式:x mod 2^n = x & (2^n - 1),因为 & 运算比 mod 具有更高的效率

当 n 不为 2 的 N 次方时,hash 冲突的概率明显增大。

2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。 例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。 而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。 所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。

6.怎么计算容量大小呢?

static final int tableSizeFor(int cap) {
    int n = cap - 1;  //首先-1,防止cap本身就是2的N次方
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;  //不断的进行无符号右移操作,将低位全变为1,最后在+1,称为2的N次方
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

7.为啥负载因子默认0.75?

  • 这个也是在时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 hash冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。

8.HashMap的插入流程?

在这里插入图片描述

9.key的hash值怎么计算?

拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

将 hashCode 的高16位参与运算?

  • 主要是为了在 table 的长度较小的时候,让高位也参与运算,并且不会有太大的开销。索引结果取决于低位,造成hash冲突

10.扩容流程

在这里插入图片描述

11.HashMap 是线程安全的吗?

不是。HashMap 在并发下存在数据覆盖、遍历的同时进行修改会抛出 ConcurrentModificationException 异常等问题,JDK 1.8 之前还存在死循环问题。
(1)多线程扩容,引起的死循环问题
(2)多线程put的时候可能导致元素丢失
(3)put非null元素后get出来的却是null

12.JDK1.7中死循环的问题?(未解决)

导致死循环的根本原因是 JDK 1.7 扩容采用的是**“头插法”**,会导致同一索引位置的节点在扩容后顺序反掉。而 JDK 1.8 之后采用的是“尾插法”,扩容后节点顺序不会反掉,不存在死循环问题。
在这里插入图片描述

13.JDK1.8的主要优化?

  1. 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) ->O(logn)。
  2. 计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
  3. 优化了 hash 值的计算方式,老的通过一顿瞎JB操作,新的只是简单的让高16位参与了运算。
  4. 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。
  5. 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。

14.Hash冲突的解决方法?

  1. 开放定址法: 所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空 的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入公式为:fi(key) = (f(key)+di) MOD m (di=1,2,3,„„,m-1)
  2. 再哈希法: 再哈希法又叫双哈希法,有多个不同的 Hash 函数,当发生冲突时,使用第 二个,第三个,„.,等哈希函数
  3. 链地址法: 链地址法的基本思想是:每个哈希表节点都有一个 next 指针,多个哈希表 节点可以用 next指针构成一个单向链表,被分配到同一个索引上的多个节 点可以用这个单向链表连接起来
  4. 建立公共溢出区:这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本 表发生冲突的元素,一律填入溢出表

15.为啥用数组?不用LinkedList?不用ArrayList?

在HashMap中,定位桶的位置是利用元素的key的哈希值对数组长度取模得到。此时,我们已得到桶的位置。显然数组的查找效率比LinkedList大
因为采用基本数组结构,扩容机制可以自己定义,HashMap中数组扩容刚好是2的次幂,在做取模运算的效率高。 而ArrayList的扩容机制是1.5倍扩容

16.你知道哪些hash算法

Hash函数是指把一个大范围映射到一个小范围。把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。 比较出名的有MurmurHash、MD4、MD5等等

String中HashCode的实现?

//String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。
//主要是因为31是一个奇质数,所以31*i=32*i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

17.一般用什么作为key?

一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。

  1. 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
  2. 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。

18.如果让你实现一个自定义的class作为HashMap的key该如何实现?

此题考察两个知识点

  1. 重写hashcode和equals方法注意什么?
  2. 如何设计一个不变类

针对问题一,记住下面四个原则即可

  • 两个对象相等,hashcode一定相等
  • 两个对象不等,hashcode不一定不等
  • hashcode相等,两个对象不一定相等
  • hashcode不等,两个对象一定不等

针对问题二,记住如何写一个不可变类

(1)类添加final修饰符,保证类不被继承。 如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。

(2)保证所有成员变量必须私有并且加上final修饰 通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。

(3)不提供改变成员变量的方法包括setter 避免通过其他接口改变成员变量的值,破坏不可变特性。

(4)通过构造器初始化所有成员,进行深拷贝(deep copy) 如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如:

public final class ImmutableDemo {  
    private final int[] myArray;  
    public ImmutableDemo(int[] array) {  
        this.myArray = array; // wrong  
    }  
}

这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。 为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法:

public final class MyImmutableDemo {  
    private final int[] myArray;  
    public MyImmutableDemo(int[] array) {  
        this.myArray = array.clone();   
    }   
}

(5)在getter方法中,不要直接返回对象本身,而是克隆对象并返回对象的拷贝 这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。

19.HashMap如何保证线程安全?ConcurrentHashMap的底层实现?JDK1.7和1.8的区别?

JDK1.7 ConcurrentHashMap
JDK1.8 ConcurrentHashMap

20.ConcurrentHashMap—JDK1.8?

20.1 sizeCtl含义
  1. 为0,表示数组未初始化,且数组的初始容量为16;(空参构造就是0)
  2. 为正数,如果数组未初始化,记录的是数组的初始容量,已经初始化,记录的是数组的扩容阈值;
  3. -1,表示正在进行初始化
  4. 小于0,并且不是-1,-(1+n)表示此时有n个线程正在共同完成数组的扩容操作
20.2 有参构造
  1. 初始容量要比你给的数大?给32,那么容量就是64,怎么算的呢?你传入32,实际传入的是32+32>>>1+1
  2. 计算hash值的时候,算出来的 一定是正值,本身异或右移16位在&0x7fffffff,方便后面判断该节点的类型
    0x7fffffff最高位是0,其余都是1,一定是正值,方便进行节点判断,是链表还是红黑树(大于0一定是一个链表结构)在这里插入图片描述
  3. 不允许空值空键
  4. cas加自旋操作完成扩容安全
  5. 初始化完成之后,sizeCtl变为数组的扩容阈值在这里插入图片描述
  6. 在第一次put的时候,进行了两次cas操作,首先计算hash值,与上数组容量-1,得到数组元素下标,cas取出所在位置的值,判断是否为空,为空要通过cas插入当前位置,不为空的话,判断hash值是否大于0,大于0为链表,进行链表的插入,否则进行红黑树的插入。期间还需要判断当前位置的hash是否为-1,正在扩容,需要协助扩容。结束以后,判断是否符合树化条件,以及是否需要扩容
  7. 在维护数组长度时,通过cas加自旋完成线程安全的处理(一个常量+一个数组,数组中维护的是一个对象,里面有个value属性,最后需要吧数组的值和常量加一起)
    在这里插入图片描述
20.3 put过程
  1. 首先判断key-value是否为空,为空,直接抛出异常
    (1)因为concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,
    (2)而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。
  2. for的死循环,为了实现CAS的无锁化更新,如果table为null或者table的长度为0,则初始化table,调用initTable()方法(第一次put数据,调用默认参数实现,其中重要的sizeCtl参数)。
    (1)首先判断sizeCtl的值,如果sizeCtl < 0,说明有线程在初始化,当前线程便放弃初始化操作(Thread.yield())。否则,将SIZECTL设置为-1,Hash表进行初始化。
    (2)初始化成功以后,将sizeCtl的值设置为当前的容量值
  3. 通过tableAt()方法找到位置tab[i]的Node,当Node为null时为没有hash冲突的话,使用casTabAt()方法CAS操作将元素插入到Hash表中,ConcurrentHashmap使用CAS无锁化操作,这样在高并发hash冲突低的情况下,性能良好。
  4. 当f不为null时,说明发生了hash冲突,当f.hash == MOVED==-1 时,说明ConcurrentHashmap正在发生resize操作,使用helpTransfer()方法帮助正在进行resize操作。(因为发生了哈希冲突,当前线程正在f所在的链表上进行更新操作,假如此时另外一个线程也需要到这个链表上进行更新操作,则需要等待当前线程更新完后再执行
  5. .以上情况都不满足的时,使用synchronized同步块上锁当前节点Node,并判断有没有线程对数组进行了修改,如果没有则进行:
    (1)遍历该链表并统计该链表长度binCount,查找是否有和key相同的节点,如果有则将查找到节点的val值替换为新的value值,并返回旧的value值,否则根据key,value,hash创建新Node并将其放在链表的尾部
    (2)如果Node f是TreeBin的类型,则使用红黑树的方式进行插入。然后则退出synchronized(f)锁住的代码块
  6. 执行完synchronized(f)同步代码块之后会先检查binCount,如果大于等于TREEIFY_THRESHOLD = 8则进行treeifyBin操作尝试将该链表转换为红黑树。
  7. 执行了一个addCount方法,主要用于统计数量以及决定是否需要扩容.
    参考:
    HashMap面试指南
    HashMap
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值