Java集合框架--HashMap面试题和源码分析

1. HashMap源码概述

HashMap源码核心属性:

// table 的默认大小16,总是为 2 的n次幂
int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
// table 的最大容量 1 * 2^30
int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化的阀值 8
int TREEIFY_THRESHOLD = 8;
// 由树退化成链表的阀值 6
int UNTREEIFY_THRESHOLD = 6;
// 可树化的最小table长度 64;因为扩容的代价小于树化的代价,因此在前期元素少的情况下优先考虑扩容,而不是树化
int MIN_TREEIFY_CAPACITY = 64;
// 构成HashMap的数组结构
Node<K,V>[] table;
// HashMap的实时键值对数量
int size;
// 记录哈希Map的被修改(add, remove)次数
int modCount;
//  table长度达到 threshold 就进行扩容, threshold = 容量*负载系数
int threshold;
//  当前的负载因子
float loadFactor;

2. 简单题

2.1 为什么初始容量必须保证为2的n次幂?

​ 因为使用hash值定位到元素所在的数组位置时用的是按位与运算 index = hash &(n-1),只有n为2的n次幂的时候按位与运算才等效于取模运算%;至于为什么要这样做其实是出于对性能的追求,因为单纯的二进制操作的速度是要大于取模操作的。

例如:

​ 当capacity = 16;(16-1) 换算成二进制为 10000-00001 = 1111;观察可知 2的n次幂的二进制比特位都为1,此时1111与任何二进制数进行&运算结果都在 0000 ~ 1111 之间,等效于取模运算%,而且性能更高。

2.2 如何确定元素在数组的位置?

  • 获取hashcode值:k.hash()
  • 进入扰动,返回hash值:hahs = hashCode ^ hashCode >>> 16 高低16位异或(让所有的bit位都能参与到异或运算中,从而让hash值变得更加分散均匀,并且异或也能让0,1出现的概率都相等,尽可能让hahs值随机均匀,降低hash冲突的几率)
  • 通过运算获取元素在数组中的位置 hash & (n-1) (疑问:取模运算不是%吗,为什么使用&?)
    • 当数组的容量保证n为2的n次幂时 hash & (n-1) 等效于 hash % n
    • &按位与是二进制操作,比%十进制的取余操作更加高效,提高性能。

2.3 你一般用什么类型来做HashMap的Key? 为什么?

  • 一般使用 String 或者 Integer 这些不变类来做为 HashMap 的 key ,因为:
    • 字符串是不可变的,所以在它创建的时候hahs值就被缓存了,不需要重新计算
    • 字符串是Java中最常用的对象,做过很多优化处理速度要快过其它的键对象
    • 要想HashMap的增删改查正常工作就必须正确重写作为key对象的hashCode,和equals方法,String和Integer都已经帮我们重写了hashCode()以及equals()方法

2.4 使用自定义对象作为HashMap的Key没有正确重写HashCode方法和Equals方法会发生什么问题?

​ 问题:

  • ①能够正常put进元素但 查找,修改,删除无法正常工作

    • 当没有对对象的hashCode进行重写时,默认使用的是Object的的hashCode方法,该方法是根据对象实例的内存地址来生成的,因此即使两个属性完全一致的对象因为他们的内存地址不一致,导致生成的hashCode值也是不一样的
    • 除非使用同一个属性未被修改过的实例,否则无法正确进行:修改,查询,删除,因为即使两个对象属性完全相同hash值也不同,永远找不到。
  • ②性能低效,每次修改了属性hash值都会发生改变,要重新计算hash值。

3. 源码和原理分析

3.1 get方法的执行过程?

  • get() 核心逻辑:
    • 首先排除掉HahsTable未初始化和table中对应位置没找到有node的情况,返回null
    • 该位置有值存在,进行三种情况判断
      • 比较该位置第一个值的key,相等即返回第一个节点first
      • 第一个节点有next,说明该位置是一棵树或一条链表
        • 该节点是TreeNode,调用getTreeNode在红黑树中查找元素
        • 是链表,遍历链表比较key是否相等,即返回该Node元素
/**
   * 本质还是调用的是getNode() 方法
   */
public V get(Object key) {
    HashMap.Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final HashMap.Node<K,V> getNode(int hash, Object key) {
        // tab:引用table
        // first:table中的某位置的第一个Node元素
        // e:临时Node变量
        // n:table数组长度
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;

        // 排除掉hashMap为空,或者table中该(n - 1) & hash位置找不到元素的情况,直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
            
            // 匹配数组中该位置的第一个元素,如果该元素的key相等即找到,立即return first(幸运情况)
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;

            // 该位置是一棵树或者一条链表
            if ((e = first.next) != null) {
                // 是红黑树,调用 getTreeNode 方法查找红黑树中的元素并返回
                if (first instanceof HashMap.TreeNode)
                    return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);

                // 是链表,遍历链表,逐个比较node.key ,找到立即return node
                do {
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

3.2 put 方法的执行过程?

  • put() 核心逻辑:
    • 调用putVal()
      • if- 判断table容量是否为空,为空调用resize()初始化
      • if- 判断对应位置没有值,没有直接将元素加入table
      • else -对应位置已有值,进入以下三种情况判断
        • if- 情况一:该元素和待加入元素hahskey都相等,直接将value进行覆盖后返回oldValue
        • else if- 情况二:该位置是一颗红黑树,调用putTreeValue方法将元素加入到红黑树中(或覆盖红黑树中的相同key元素)
        • else- 情况三:该位置是一条链表,遍历链表,若找到key相等的就覆盖value,没找到就在链表尾部插入,最后判断链表元素是否达到 8 个,达到就调用treeifyBin() 进行树化
        • 元素是覆盖旧的)覆盖value,return oldValue值
      • 元素是新加入的)判断数组长度是否大于阈值(capacity * loadFactor),大于就调用resize进行扩容,最后return null
/**
   *  put方法内部通过调用putVal方法传入:key的hash值,key,value
   *  三个核心参数实现将元素加入到 HashMap 中
   */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

 /**
     * @param hash key对应的哈希值
     * @param key key值
     * @param value value值
     * @param onlyIfAbsent 如果元素已存在就不进行覆盖,false覆盖(不重要)
     * @param evict 给 afterNodeInsertion(evict) 空实现函数使用的参数(不重要)
     * 还有几个关键方法要解决:
     * resize() 扩容方法
     * putTreeVal() 将元素加入到红黑树中方法
     * treeifyBin() 树化方法             
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        /**
         * tab 对当前table数组的引用
         * p 计算出在table中的元素
         * n 数组长度
         * i 数组索引
         */
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;

        /**
         * 如果数组为空,调用resize方法进行初始化
         */
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        /**
         * 将hash值和数组长度n-1进行与运算,计算出元素所在数组中的位置。
         * 如果该位置没有元素就直接加入到数组中
         */
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

        /**
         * 数组中对应位置已经有值的情况
         */
        else {
            /**
             * e 临时桶变量
             * k 临时key变量
             */
            HashMap.Node<K,V> e; K k;
            
            /**
             * table中已存在的元素与要加入元素的hash值相等并且他们的key也相等,直接覆盖掉数组中的元素
             * (将要加入hashMap中的元素赋值给临时变量e后续会进行替换操作并返回旧的value)
             * 注意是在这里初始化p的 key和hash
             */
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            /**
             * 该位置是一颗红黑树,调用 putTreeValue 方法将元素加入到红黑树中
             *(或覆盖红黑树中的相同key元素)
             */
            else if (p instanceof HashMap.TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

            /**
             * 遍历链表,若找到key相等的元素就覆盖value,没找到就在链表尾部插入;
             * 最后判断链表元素是否达到8个,达到就调用treeifyBin() 进行树化
             */
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    
                    /**
                     * hash值相同,key相同,进行覆盖
                     */
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

            /**
             * 如果临时变量e不为空,代表是进行了覆盖(覆盖table中的,覆盖链表中的,覆盖红黑树中的)
             * 从而覆盖value,return oldValue值
             * 覆盖hashMap size未发生改变,因此无需检查是否需要扩容
             */
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        /**
         * 到这里代表元素是新加入操作,因此要判断数组长度是否大于阈值(capacity * loadFactor),
         * 大于就调用resize进行扩容
         * 最后return null
         */
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

3.3 resize 扩容过程?

为什么发生hash冲突的元素经过扩容后位置分布只有两种可能性?要么在原来的位置要么在依赖的位置+oldCapacity

例如:从16 扩容到32;hahs & (16-1)-> hahs & (32-1);hahs & 1111 -> hash & 11111 观察发现总是最高位多出一位1

因此当hahs值最高位为0时位置不变,当hash值最高位为1是位置变为oldIndex + oldCapacity。

验证程序:

// 验证扩容是元素的偏移
    public static void main(String[] args) {
        Map<String,String> map = new HashMap<>();
        int hash1 = 15; // 01111
        int hash2 = 31; // 11111
        int n =16; // 尝试从16 -> 32 库容查看输出位置
        System.out.println(hash1 & (n-1)); // 索引不变
        System.out.println(hash2 & (n-1)); // 索引 = oldIndex + dolCapacity 发生偏移
    }

完整源码分析:

  • resize核心扩容逻辑:
    • 第一步:确定新的数组table容量和新的阈值threshold
      • 初始化table和threshold,直接返回初始化好的table
      • 正常扩容
      • 达到了table最大容量,直接返回
    • 第二步将元素重新映射到新数组中
      • 单个的元素,随机映射到新数组高位或低位(e.hash & (newCap - 1)
      • 是红黑树:调用split方法将红黑树元素重新映射到新数组中(split的核心逻辑也待理解)
      • 是链表:创建高位链表和低位链表,将元素对应添加到高位链表和低位链表,最后将高位链表和低位链表重新加入到新的table中,返回信息的table
final HashMap.Node<K,V>[] resize() {

        // oldTab:引用扩容前的table
        HashMap.Node<K,V>[] oldTab = table;
        // oldCap:扩容前table的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // oldThr:扩容前table的扩容阈值
        int oldThr = threshold;
        // newCap,newThr:定义新的table容量和库容阈值
        int newCap, newThr = 0;
        //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 这一段都是为了确定newCap和newThr值的
        // table已经被初始化过,进行正常扩容
        if (oldCap > 0) {
            // 如果table已经达到最大限制,直接设置扩容阈值为Int最大值,并返回原来的table不再进行扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // table容量在16~MAX之间,设置newCap长度变为oldCap的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 使用的是有参构造方法,间接设置了threshold,扩容阈值的值
        // new HashMap(int initialCapacity, float loadFactor)
        // new HashMap(int initialCapacity)
        // new HashMap(Map<? extends K, ? extends V> m) 传入的map是有数据的
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;

        // capacity=0,threshold=0;使用的是无参构造方法new HashMap(),赋予默认值
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY; // 16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
        }

        // 当出现newThr为零的情况,还得将newThr计算出来
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑这一段都是为了确定newCap和newThr值的

        //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 创建新数组,准备将旧的table中的数据重新映射到新table中去
        @SuppressWarnings({"rawtypes","unchecked"})
        HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
        // 直接让table指向新构建的数组
        table = newTab;
        // 如果旧得table为null,说明只执行初始化,直接将初始化好的数组返回,否则将旧数组中的数据重新映射到table中
        if (oldTab != null) {
            // 遍历oldTable,将数据重新移动到newTable中
            for (int j = 0; j < oldCap; ++j) {
                // e:存放旧的
                HashMap.Node<K,V> e;
                // oldTable中有元素
                if ((e = oldTab[j]) != null) {
                    // 赋值为null,便于垃圾回收掉oldTab
                    oldTab[j] = null;
                    // 只有一个元素,直接移动,在newTable的新位置为: 旧的位置+oldTable.length 或者是旧的位置
                    // 因为只有两种结果 16 -> 32; hahs&1111 -> hahs&11111结果只看hahs的第六位0不变1偏移16
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // 该位置是一颗红黑树
                    else if (e instanceof HashMap.TreeNode)
                        // 调用split方法将红黑树元素映射到新table中
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    // 该位置是一条链表
                    else { // preserve order
                        // 创建高位链表和低位链表,将数组拆成两条
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.Node<K,V> next;
                        do {
                            next = e.next;
                            // 最高位为0,加入到低位链表中(注意这里是e.hash & oldCap 参考的是oldCapacity的二进制最高位)
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 最高位为1,加入到高位链表中
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);

                        // 将低位链表挂载到数组低位
                        if (loTail != null) {
                            loTail.next = null; // 防止成环
                            newTab[j] = loHead;
                        }
                        // 将高位链表挂载到数组高位
                        if (hiTail != null) {
                            hiTail.next = null; // 防止成环
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

3.4 reomve()方法分析

final HashMap.Node<K,V> removeNode(int hash, Object key, Object value,
                                   boolean matchValue, boolean movable) {
    // tab: 指向table
    // p:临时Node变量,存储table指定位置的元素
    // n:table长度
    // index:在table中的索引
    HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, index;

    // 首先排除HashTable为空和index位置无元素情况
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {

        // node:要查找的元素
        // e:table中第一个元素的next
        HashMap.Node<K,V> node = null, e; K k; V v;

        // 比较hash和key, table中的第一个元素就是要删除的元素,赋值给node进行保存
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;

        // 该位置是红黑树或者链表
        else if ((e = p.next) != null) {

            // 是红黑树,通过getTreeNode()方法查找到元素赋值给node保存
            if (p instanceof HashMap.TreeNode)
                node = ((HashMap.TreeNode<K,V>)p).getTreeNode(hash, key);

            // 是链表,逐个匹配hahs和key,找到就将元素赋值给node保存
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }

        // 若node变量不为空,说明找到了元素,准备删掉,matchValue变量代表当value也相等才删除
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {

            // node是树节点,调用removThreeNode进行删除
            if (node instanceof HashMap.TreeNode)
                ((HashMap.TreeNode<K,V>)node).removeTreeNode(this, tab, movable);

            // node 是单个节点,直接覆盖table元素
            else if (node == p)
                tab[index] = node.next;

            // node是链表元素,直接删除
            else
                p.next = node.next;
            
            // 记录修改次数,调整hashTable大小
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

4. 相关补充 💪

异或和按位与运算:

  • 异或运算^的特点,0,1出现的概率都相等(相同为0不同为1

    • 1^1 = 0
    • 1^0 = 1
    • 0^1 = 1
    • 0^0 = 0
  • 按位与&运算的特点,二进制位置相与(同1才为1

    • 1&1 = 1
    • 1&0 = 0
    • 0&1 = 0
    • 0&0 = 0

关于哈希算法:

定义:将任意长度输入数据转换成固定长度输出

  • 特点:
    • 相同的输入一定得到相同的输出;
    • 不同的输入大概率得到不同的输出。
  • 哈希冲突:
    • 两个不同的输入得到了相同的输出
  • 好的哈希函数:
    • 哈希值均匀分散,发生碰撞的概率低
  • 常见哈希算法与输出长度:
    • MD5 128 bits
    • SHA-256 256 bits
    • SHA-512 512 bits
  • 哈希算法的用途:
    • 验证数据完整性
    • 验证数据是否被篡改

查找链表和红黑树的时间复杂度:

  • 链表:O(n)
  • 红黑树:O(logn)

2021年10月8日16:11:08 会持续更新的…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值