高频面试题-JDK源码篇(HashMap)

简介: 我觉得HashMap是一个高频面试题,甚至被问烂了,如果你还不懂HashMap原理,你很幸运,看了这边文章之后你将不存在这个问题!这里整理了一下HahsMap可能会被问到的知识点,从源码的角度去做了一些分析,当然你可以试着自己先回答一下:HashMap底层用到了那些数据结构?为什么要用到链表结构?为什么要用到红黑树?HashMap如何扩容的?HashMap是如何Put一个元素的?HashMap是如何Get一个元素的?什么是Hash冲突还有哪些解决Hash冲突的方式?

前言

我觉得HashMap是一个高频面试题,甚至被问烂了,如果你还不懂HashMap原理,你很幸运,看了这边文章之后你将不存在这个问题!这里整理了一下HahsMap可能会被问到的知识点,从源码的角度去做了一些分析,当然你可以试着自己先回答一下:

  1. HashMap底层用到了那些数据结构?
  2. 为什么要用到链表结构?
  3. 为什么要用到红黑树?
  4. HashMap如何扩容的?
  5. HashMap是如何Put一个元素的?
  6. HashMap是如何Get一个元素的?
  7. 什么是Hash冲突
  8. 还有哪些解决Hash冲突的方式?

1. HashMap底层用到了那些数据结构?

HashMap用到了数组,链表,红黑树 。 数组中的每一个元素都是一个Key-Value键值对结构的Node对象,数组初始长度为16。

HashMap源码如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   /** 16 初始容量
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    /** 最大容量
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;
    /** 扩容因子,元素个数达到容量的75%开始扩容
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /** 链表节点数达到8,转换为红黑树
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;
    /** 红黑树节点个数小于6转为链表
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    ...省略...
  //一个 Node 代表一个key-value 键值对,也是数组中的一个元素
  static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//key的hash值  
        final K key;  //键
        V value;      //值
        Node<K,V> next; //指向下一个Node
   }

2. HashMap为什么要用到链表结构?

使用链表是为了解决Hash冲突问题,这就要说到HashMap存储元素的位置映射算法了,HashMap通过 hash(key) % 16 算法来计算某个key在数组中的存放位置 ,通过hash算法得到 key的hash值,对数组长度(16)取余数,结果为0到15其中一个数字,正好对应数组的索引位置。

而Hash冲突就是两个不同的 key ,使用Hash函数得到了相同的Hash值(这是有可能的),这就意味着两个各不同键值对计算出了相同的存储位置(一个位置怎么能放两个键值对呢?)这就是Hash冲突。

HashMap使用 链地址法 来解决Hash冲突,其实就是把相同位置的元素以链表的方式进行存储,如下:

你现在应该知道Node节点对象中的 next 的作用了。

3. 还有哪些解决Hash冲突的方式?

  • 第一种:拉链法/链地址法 :把Hash碰撞的元素指向一个链表,HashMap用的就是这种方法
  • 第二种:开放寻址法:当 p=h(key)出现冲突,就以p为基础产生另一个Hash,如:p1=h§,直到不冲突了把元素放在该位置
  • 第三种:再散列法:准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个
  • 第四种:建立公共溢出区:把Hash表分为基本表和溢出表,把和基本表冲突的元素移到溢出表

4. HashMap为什么要用到红黑树?

HashMap使用红黑树是为了解决链表过长,查询变慢的问题大家都知道红黑树的查询性能是及其可观的。

如果HashMap出现Hash冲突的情况变多,那么链表的长度会越来越长,由于链表的查询方式是从前往后一个一个遍历,查询速度比较慢,所以当链表长度达到 8 的时候 ,HashMap会把链表变成一个红黑树结构来提高查询效率 , 当删除元素,红黑树的节点数小于6又会把红黑树变回链表 , 下面这2个字段就是红黑树和链表的转换阈值:

/** 链表节点数达到8,转换为红黑树
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;
    /** 红黑树节点个数小于6转为链表
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

5.为什么HashMap要使用红黑树而不是用AVL平衡树

这就要说到AVL树和红黑树的特点了,这里简单说一下,红黑树的查询性能略微逊色于AVL树,因为相比AVL树红黑树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的AVL树最多多一次比较,但是,红黑树在插入和删除上优于AVL树,AVL树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于AVL树为了维持平衡的开销要小得多

总结:AVL查询性能好,增删性能差,红黑树查询和增删性能相对折中,实际应用中,若搜索的次数远远大于插入和删除,那么选择AVL,如果搜索,插入删除次数几乎差不多,应该选择红黑树。

HashMap选择红黑树就是因为我们使用Map查询和增删该都会涉及到。

为什么不使用多叉树?多叉树和用于大数据存储,或者文件系统存储,不太适合HashMap这种小数据量存储结构。

6. HashMap如何扩容的?

HashMap的扩容因子是0.75,也就是说当数组中的已存储节点数(Node) > 数组容量 ∗ 扩容因子时,就需要扩容,调整数组的大小为当前的 2 倍

/** 扩容因子,元素个数达到容量的75%开始扩容
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

扩容的方式是创建 2 倍容量的新的数组,然后把所有元素重新hash到新的数组中(数组长度变量,需要重新计算元素的存储位置)。

7. HashMap是如何Put一个元素的?

put执行流程如下:

  1. 为key计算出hash值
  2. 根据key的hash值计算出存储位置(16 - 1) & hash 等同于 hash % 16
  3. 取出该位置的值,如果为空,就把传入的key-value包装成Node,加入该位置
  4. 如果该位置已经有值了,就出现了hash碰撞了,该Node后面可能有一个链表,或者红黑树
  5. 判断该位置的元素的key和传入的key是否一样,如果一样就做值的覆盖操作
  6. 如果该位置的元素的key和传入的key不一样, 那就判断Node的类型是不是红黑树
  7. 如果是红黑树就走红黑树的添加流程
  8. 如果不是红黑树,就遍历链表,取出该位置的元素,使用next往后遍历 。如果遍历的过程中某个Key和传入的key一致了,就覆盖值,否则遍历到最后next为nul,就把传入的新元素存储到链表尾。
  9. 同时要判断链表的元素如果大于8了要进行红黑树的转换。

put方法源码

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

计算hash值

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

put主流程

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                  boolean evict) {
       Node<K,V>[] tab; Node<K,V> p; int n, i;
       //1.如果数组是空的,调用 resize() 初始化数组
       if ((tab = table) == null || (n = tab.length) == 0)
           //tab就是数组, n是数组长度
           n = (tab = resize()).length;
        // 2.计算位置: (n - 1) & hash  等同于   hash % 16 ,计算key的存储位置
        // 3.取出该位置的元素是否为空
       if ((p = tab[i = (n - 1) & hash]) == null)
        //4.如果为空,就创建一个新Node,把当前的key-value存储进去
           tab[i] = newNode(hash, key, value, null);
       else {
          //到这里说明hash冲突了,该位置有元素,那么就有2种执行方案:
          //如果有和当前传入key相同的key存住,覆盖值,如果没有和传入的key相同的key,那就添加元素
           Node<K,V> e; K k;
           //5.如果key相等,hash值也相等,进行值的覆盖
           if (p.hash == hash &&
               ((k = p.key) == key || (key != null && key.equals(k))))
               e = p;
            //6.如果不满足第5步,那就要进行链表或者红黑树的遍历了 , 这里在判断是不是红黑树
           else if (p instanceof TreeNode)
            //走红黑树的添加流程
               e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
           else {
            //7.到这里就是链表了 , 循环链表
               for (int binCount = 0; ; ++binCount) {
                //8.通过next一个一个往下遍历,如果某一个node的next为空 ,说明找到最后一个Node了,
                //就把新的key-value加入最后一个元素的next
                   if ((e = p.next) == null) {
                       p.next = newNode(hash, key, value, null);
                       //这里在判断是否要做红黑树转变
                       if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                           treeifyBin(tab, hash);
                       break;
                   }
                   //9.如果在遍历的过程中,找到了和传入的key一样的key,hash值也一样,那就进行值的覆盖
                   if (e.hash == hash &&
                       ((k = e.key) == key || (key != null && key.equals(k))))
                       break;
                   p = e;
               }
           }
           if (e != null) { // existing mapping for key
               V oldValue = e.value;
               if (!onlyIfAbsent || oldValue == null)
                   e.value = value; //10.用新的值覆盖老的值
               afterNodeAccess(e);
               return oldValue;
           }
       }
       ++modCount;
       if (++size > threshold)
           resize();
       afterNodeInsertion(evict);
       return null;
   }

8. HashMap是如何Get一个元素的?

其实根据put流程就能推断出Get流程

  1. 根据key计算hash值
  2. 根据hash % 16 计算出Key的存储位置
  3. 把该位置的元素取出来,使用传入的key和该位置第一个元素的 key进行比较,如果相同,就返回
  4. 如果第一个元素不是查找的元素,就进行链表或者红黑树的遍历
  5. 如果第一个节点类型是TreeNode,就走红黑树的查找流程
  6. 如果类型不是TreeNode,就遍历链表,一边遍历一遍比较key,如果key相同就返回值,直到next为null。

get方法入口:

public V get(Object key) {
        Node<K,V> e;
        //hash(key):计算key的hash值
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

get主流程:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
        //计算key存储位置,取出该位置的元素
            (first = tab[(n - 1) & hash]) != null) {
            //检查是不是第一个元素,比较key,如果是直接返回
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                //直接返回第一个元素
                return first;
                //如果next不为空,开始遍历链表
            if ((e = first.next) != null) {
               //如果是红黑树,就遍历红黑树
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                  //一直next遍历下一个元素,然后比较key,知道遍历完
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

9.你知道HashMap在多线程下死循环问题吗

这个问题在JDK1.8中已经得到解决,在JDK1.7及以前是有这个问题,造成原因是因为之前HashMap链表插入元素使用的是头插法,当HashMap扩容后,进行元素重新Hash位置重排的时候会出现链表上的元素循环指向的问题。

比如:有链表上元素按照:A -> B -> C 顺序排列,三者Hash值相同 , 现在HashMap扩容,然后进行元素重新Hash,A,B,C三者由于Hash值相同,在新的数组中依然会计算出相同的存储位置。

我们可以这样理解,链表上的元素在进行重新Hash的时候是先取第一个元素A进行Hash,然后存储到数组上的某个位置 , 然后再取B进行Hash,同样存储到该位置,由于使用头插法,所以形成了下图第二部的效果:B -> A , 这时你已经意识到问题了,在之前的链表上是: A -> B, 而重新Hash之后变成了B -> A ,其实这里就出现了元素相互引用的问题,即:死循环。

JDK1.8通过链表元素尾插法解决了这个问题,尾插法可以维持原有链表的顺序:

10.在多线程环境中能使用HashMap吗?

不能,HashMap暴露给外界的方法并不是同步的,在多线程环境中是可能会出现线程安全问题,在多线程环境中可以使用HashTable ,或者ConcurrentHashMap。 HashTable直接使用 synchronized 同步锁来保证线程安全,比较古老现在基本不用了。ConcurrentHashMap是JUC并发库中的容器 ,它是专门为多线程环境设计,并发控制使用 Synchronized 和 CAS 来操作,性能良好,在JDK1.8中ConcurrentHashMap也使用到了数组链表红黑树的结构来存储数据。

11.HashMap和HashTable的区别

HashTable是HashMap的线程安全版本,也是比较老旧的一个map,HashTable方法是加了同步锁,线程安全,性能会受到影响。而HashMap没有加同步锁,线程不安全,但是性能更高。

Hashtable既不支持Null key也不支持Null value , 而HashMap值可以为null,key支持一个key为nul。

目录
相关文章
|
3天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
15 2
|
1月前
|
Java
让星星⭐月亮告诉你,HashMap中保证红黑树根节点一定是对应链表头节点moveRootToFront()方法源码解读
当红黑树的根节点不是其对应链表的头节点时,通过调整指针的方式将其移动至链表头部。具体步骤包括:从链表中移除根节点,更新根节点及其前后节点的指针,确保根节点成为新的头节点,并保持链表结构的完整性。此过程在Java的`HashMap$TreeNode.moveRootToFront()`方法中实现,确保了高效的数据访问与管理。
29 2
|
1月前
|
Java 索引
让星星⭐月亮告诉你,HashMap之往红黑树添加元素-putTreeVal方法源码解读
本文详细解析了Java `HashMap` 中 `putTreeVal` 方法的源码,该方法用于在红黑树中添加元素。当数组索引位置已存在红黑树类型的元素时,会调用此方法。具体步骤包括:从根节点开始遍历红黑树,找到合适位置插入新元素,调整节点指针,保持红黑树平衡,并确保根节点是链表头节点。通过源码解析,帮助读者深入理解 `HashMap` 的内部实现机制。
33 2
|
1月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
52 0
|
3月前
|
JavaScript 前端开发
【Vue面试题二十五】、你了解axios的原理吗?有看过它的源码吗?
这篇文章主要讨论了axios的使用、原理以及源码分析。 文章中首先回顾了axios的基本用法,包括发送请求、请求拦截器和响应拦截器的使用,以及如何取消请求。接着,作者实现了一个简易版的axios,包括构造函数、请求方法、拦截器的实现等。最后,文章对axios的源码进行了分析,包括目录结构、核心文件axios.js的内容,以及axios实例化过程中的配置合并、拦截器的使用等。
【Vue面试题二十五】、你了解axios的原理吗?有看过它的源码吗?
|
3月前
|
JavaScript 前端开发
【Vue面试题二十七】、你了解axios的原理吗?有看过它的源码吗?
文章讨论了Vue项目目录结构的设计原则和实践,强调了项目结构清晰的重要性,提出了包括语义一致性、单一入口/出口、就近原则、公共文件的绝对路径引用等原则,并展示了单页面和多页面Vue项目的目录结构示例。
|
1月前
|
存储
让星星⭐月亮告诉你,HashMap的put方法源码解析及其中两种会触发扩容的场景(足够详尽,有问题欢迎指正~)
`HashMap`的`put`方法通过调用`putVal`实现,主要涉及两个场景下的扩容操作:1. 初始化时,链表数组的初始容量设为16,阈值设为12;2. 当存储的元素个数超过阈值时,链表数组的容量和阈值均翻倍。`putVal`方法处理键值对的插入,包括链表和红黑树的转换,确保高效的数据存取。
53 5
|
2月前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析
本文是“Java学习路线”专栏的导航文章,目标是为Java初学者和初中高级工程师提供一套完整的Java学习路线。
405 37
|
1月前
|
算法 索引
让星星⭐月亮告诉你,HashMap的resize()即扩容方法源码解读(已重新完善,如有不足之处,欢迎指正~)
`HashMap`的`resize()`方法主要用于数组扩容,包括初始化或加倍数组容量。该方法首先计算新的数组容量和扩容阈值,然后创建新数组。接着,旧数组中的数据根据`(e.hash & oldCap)`是否等于0被重新分配到新数组中,分为低位区和高位区两个链表,确保数据迁移时的正确性和高效性。
64 3
|
1月前
|
Java 索引
让星星⭐月亮告诉你,HashMap中红黑树TreeNode的split方法源码解读
本文详细解析了Java中`HashMap`的`TreeNode`类的`split`方法,该方法主要用于在`HashMap`扩容时将红黑树节点从旧数组迁移到新数组,并根据`(e.hash & oldCap)`的结果将节点分为低位和高位两个子树。低位子树如果元素数少于等于6,则进行去树化操作;若多于6且高位子树非空,则进行树化操作,确保数据结构的高效性。文中还介绍了`untreeify`和`replacementNode`方法,分别用于将红黑树节点转换为普通链表节点。
23 2