HashMap总结

1.底层数据结构,1.7和1.8的不同

1.7:数组(初始大小为16)+链表

1.8:数组(初始大小为16)+(链表or红黑树),链表长度超过 8 的时候,会将链表转化为红黑树来提高查询效率(组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。当节点数小于6时,红黑树将退化成链表

2.键的索引如何计算

1.7:

static int hash(int h) {
 
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

1.8:

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^:按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }


jdk1.7的hash方法扰动了4次,而jdk1.8的hash方法更加简洁。首先调用hashcode方法获取键(key)的哈希码,并将其与右移 16 位的哈希码进行异或运算。然后hash的结果与数组容量进行取模操作(hash&(length - 1),需要length为2的幂次方),这个余数就是对应的数组的下标,然后存放键在对应下标的位置。

3.为什么得到哈希码之后要进行二次hash操作

把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,让数据元素更加均衡的分布,增大了随机性。

4.扩容机制

HashMap 的扩容是通过 resize 方法来实现的,该方法接收一个新的容量 newCapacity,然后将 HashMap 的容量扩大到 newCapacity:

  • 获取旧数组及容量:如果旧容量已经达到 HashMap 支持的最大容量 MAXIMUM_CAPACITY( 2 的 30 次方),就将新的阈值 threshold 调整为 Integer.MAX_VALUE(2 的 31 次方 - 1)
  • 创建新数组并转移元素:将旧数组 oldTable 中的元素转移到新数组 newTable 中。转移过程是通过调用 transfer 方法来实现的。该方法遍历旧数组中的每个桶,并将每个桶中的键值对重新计算哈希值后,将其插入到新数组对应的桶中。
  • 重新计算阈值 threshold:转移完成后,方法将 HashMap 内部的数组引用 table 指向新数组 newTable,并重新计算阈值 threshold:
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

5.新的容量如何计算

  • 新容量 newCapacity 被初始化为原容量 oldCapacity 的两倍
  • 如果 newCapacity 超过了 HashMap 的容量限制 MAXIMUM_CAPACITY(2^30),就将 newCapacity 设置为 MAXIMUM_CAPACITY
  • 如果 newCapacity 小于默认初始容量 DEFAULT_INITIAL_CAPACITY(16),就将 newCapacity 设置为 DEFAULT_INITIAL_CAPACITY

6.jdk1.7扩容机制中如何将旧的小数组元素拷贝到新的大数组中

通过transfer方法实现,该方法接受一个新的 Entry 数组 newTable 和一个布尔值 rehash 作为参数,其中 newTable 表示新的哈希表,rehash 表示是否需要重新计算键的哈希值:

  • 遍历旧哈希表中的每个 Entry:如果 rehash 为 true,则需要重新计算键的哈希值,根据新哈希表的长度和键的哈希值,计算 Entry 在新数组中的位置 i。
  • 头插法:由于新元素需要被放在链表的头部,因此将新元素的下一个元素设置为当前数组位置上的元素。

头插法存在问题:扩容后可能会改变原来的顺序

7.jdk1.8扩容机制改进

差别主要在hash方法上,当数组长度为 2 的幂次方时,能够很巧妙地解决 JDK 7 中遇到的问题。1.8的hash方法如下(与上文一致):

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^:按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }


数组扩容后的索引位置,要么就是原来的索引位置,要么就是“原索引+原来的容量”,遵循一定的规律。

8.数组长度为什么是2的幂次方

  • 如果使用2的幂次方,可以用按位与操作替换取模操作,提升计算效率(当length为2的幂次方时,hash%length=hash&(length-1)
  • 当数组进行扩容的时候,需要将链表中的元素逐个遍历,将每个元素的hash值 & 原来的容量 如果=0,说明留在原地,如果不等于0,说明需要移动到新位置。然后用两个链表分别存放,最后通过原桶下标位置 + 旧数组容量就可以直接移动到新的桶下标位置,一次性移动

9.HashMap的put流程

  • 创建:hashmap的数组是懒惰初始化的,只有第一个元素插入的时候才会创建
  • 计算hash:获取键的哈希码,然后进行hash运算,最后按位与得到元素存放的下标
  • 判断下标是否有元素:如果没有,创建Node节点,然后放入
  • 如果有:
  • 如果是TreeNode,说明已经是树化了,那么需要走红黑树的更新逻辑
  • 如果是Node,那么走链表的更新逻辑,如果链表长度达到树化阈值,那么需要走树化逻辑。1.7是头插法,1.8是尾插法
  • 扩容,1.7超过扩容阈值不会马上扩容,需要判断是否有空位,如果没有在进行扩容, 1.8是超过扩容阈值就马上扩容(1.8有优化:按位与原来的容量判断是否0)

10.负载因子为什么是0.75

  • 为了在时间和空间成本之间达到一个较好的平衡点,既可以保证哈希表的性能表现,又能够充分利用空间。
  • 如果负载因子过大,填充因子较多,那么哈希表中的元素就会越来越多地聚集在少数的桶中,这就导致了冲突的增加,这些冲突会导致查找、插入和删除操作的效率下降。
  • 如果负载因子过小,那么桶的数量会很多,虽然可以减少冲突,但会导致更频繁地扩容,在空间利用上面也会有浪费。

11.线程不安全:put 会导致元素丢失

两个线程同时判断一个位置是null,所以同时创建了节点准备插入桶下标,然后其中一个插入完成,另一个进行了覆盖,造成了一个数据丢失

12.线程不安全:扩容会死循环(1.7头插法出现)

  • 线程1进行扩容,会有两个临时变量e和next,分别指向第一个跟第二个节点(a和b),然后准备进行扩容的时候,发生了线程上下文切换
  • 线程2执行了,他也有两个临时变量,然后进行扩容,扩容之后,因为1.7使用的是头插法,所以原就数组链表中的元素比如a 指向 b,会变成 b 指向a,然后扩容完成
  • 切换会线程1,他还会执行完扩容流程,此时线程1的 e 和 next还是原先的a 和 b,但是此时他们的顺序已经发生了改变,此时e是a插入,然后next是b,然后e插入a之后,获取next值,也就是b,然后插入,next值就会去获取下一个节点的值,因为刚才线程2扩容使得两个节点顺序发生了改变,b指向了a,然后此时next值是a,那么刚才已经插入过 a了 ,此时又插入了a,那么链表的顺序是 a和b两个节点互相指向,造成了死循环

13.线程不安全:put 和 get 并发时会导致 get 到 null

因为线程 1 执行完 table = newTab 之后,线程 2 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移

14.重写HashMap的equal和hashcode方法需要注意什么?

HashMap使用Key对象的hashCode()和equals()方法去决定key-value对的索引。当我们试着从HashMap中获取值的时候,这些方法也会被用到。如果这些方法没有被正确地实现,在这种情况下,两个不同Key也许会产生相同的hashCode()和equals()输出,HashMap将会认为它们是相同的,然后覆盖它们,而非把它们存储到不同的地方

15.重写HashMap的equal方法不当会出现什么问题

  • HashMap在比较元素时,会先通过hashCode进行比较,相同的情况下再通过equals进行比较。所以 equals相等的两个对象,hashCode一定相等。hashCode相等的两个对象,equals不一定相等。
  • 重写了equals方法,不重写hashCode方法时,可能会出现equals方法返回为true,而hashCode方法却返回false,这样的一个后果会导致在hashmap等类中存储多个一模一样的对象,导致出现覆盖存储的数据的问题,这与hashmap只能有唯一的key的规范不符合
  • 另外作为key的对象需要是不可变类,不然对象被修改了,下次hashcode值发生了改变就找不到了。

16.为什么要用红黑树,为什么一上来不树化

  • 红黑树是用来避免Dos攻击,防止链表过长时性能下降,树化时应时一种偶然情况
  • 链表的查找效率为O(n) ,而红黑树的查效率为O(logn),TreeNode占用空间也比普通的Node大,所以如果非必要,尽量还是使用链表,不要树化。

17.树化的阈值为什么是8

hash值如果足够随机,则在hash表中按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现的概率是一亿分之6,选择8是为了让树化的几率足够小。

全部评论
学姐加油
点赞 回复 分享
发布于 05-27 23:32 四川
哇 太细了 收藏收藏
点赞 回复 分享
发布于 05-28 16:34 香港
满帮集团
校招火热招聘中
官网直投
写的真好
点赞 回复 分享
发布于 06-02 11:16 山东
哥,为什么jdk1.8扩容的时候会加速rehash?不都是需要从新计算hash值,然后直接定址嘛?虽然有规律但没什么用呀,求解答😵
点赞 回复 分享
发布于 07-07 09:56 辽宁

相关推荐

面试时间30min(开始觉得为撒面的这么快,后来整理面试题的时候才发现是自己面的太差了但是真的忘完了,有没有牛牛可以讲一下如何刷八股吗?##面经#)1、面向对象的特点?继承、封装、多态继承:继承是指使用已存在的类的定义作为基础新建立类的技术,新类的定义可以增加新的数据或新的功能。也可以用父类的功能,但不能选择性地继承父类。子类拥有父类对象地所有属性和方法(包括私有属性和方法),但是父类中的私有属性和方法子类是无法访问的,只是拥有。子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。子类可以用自己的方式实现父类的方法。封装:封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。多态:一个对象具有多种形态,具体表现为父类的引用指向子类的实例。多态的特点:对象类和引用类之间具有继承/实现的关系。引用类型变量发出的方法调用的到底是那个类的方法,必须在程序运行期间才能确定;多态不能调用只在子类存在但在父类中不存在的方法如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写 父类的方法则执行的是父类的方法。2、接口和抽象类?相同点:实例化:接口和抽象类都不能直接被实例化,只能被实现或继承(抽象类)抽象方法:接口和抽象类都包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。区别:设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。继承和实现:一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。成员变量:接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private, protected, public),可以在子类中被重新定义或赋值。方法: - Java 8 之前,接口中的方法默认是 public abstract ,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 default(默认) 方法和 static (静态)方法。 自 Java 9 起,接口可以包含 private 方法。- 抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。3、你用过哪些集合?集合为三类:list、set以及maplist:arrayList:数组LinkedList:双向链表Set:hashSet:无序内部是通过hashmap实现的LinkedHashSet:内部是通过LinkedHashMap来实现的TreeSet:有序(自平衡二叉树)Map:Hashmap:数组加链表(链表是为了解决hash冲突)hashtable:数组加链表4、Mysql优化?5、如何加索引?6、JVM如何实现线程同步?方法        优点        缺点        适用场景使用synchronized关键字        - 简单易用- 内置的锁机制- 自动释放锁        - 不可中断- 无法设置超时时间- 只能使用一把锁        - 单一资源的同步- 简单的同步需求使用ReentrantLock类        - 可重入锁- 可以实现公平性- 可以设置超时时间        - 需要手动释放锁- 代码复杂一些        - 复杂的同步需求- 需要更灵活的同步控制使用wait()、notify()和notifyAll()方法        - 线程间协作和通信- 可以唤醒等待的线程        - 需要手动管理等待和唤醒的顺序- 只能在同步代码块中使用        - 多个线程间的合作和协调- 生产者消费者模式使用CountDownLatch和CyclicBarrier        - 线程间同步和等待- 灵活的等待方式        - CountDownLatch只能使用一次- CyclicBarrier需要重置后才能再次使用        - 一组线程间的同步和等待- 多个线程间达到某个状态后再继续执行7、如何设置线程池?8、线程池的核心参数?5个核心线程池数,最大线程池数,等待队列,拒绝策略,存活时间9、类加载的过程?加载->连接->初始化连接过程:验证->准备->解析加载:1. 通过全类名获取定义此类的二进制字节流。2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。连接:验证:1. 文件格式验证(Class 文件格式检查)2. 元数据验证(字节码语义检查)3. 字节码验证(程序语义检查)4. 符号引用验证(类的正确性检查)准备:这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。相关阅读:《深入理解 Java 虚拟机(第 3 版)》勘误#75open in new window这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。初始化:初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。10、TCP协议?11、在项目中角色?12、你喜欢看技术博客吗?13、你如何处理压力大的情况?14、反问(面试有哪些不足,如果能入职的话,将是那种工作方式?下一次的面试时间?)
诺瓦星云一面182人在聊 查看14道真题和解析
点赞 评论 收藏
分享
13 56 评论
分享
牛客网
牛客企业服务