无论啥公司面试都必问的HashMap原理,得调试源码看下了!

写在前面

做任何事都要有计划,但是执行的最好时机还是当下,所以我的第一篇技术博客(不再是安装部署这种没自己理解的)就现在这个点开始书写吧!

这几天面试也是必问HashMap原理,无论外包公司还是自研公司。
希望能帮助我之后不用再去看视频,看源码,只要看这篇博文就能回想起并回答面试官各种提问!

这边做的操作就是调试get和put两个方法,看执行过的代码从而得知逻辑。

调试前->先看下HashMap源码

先看源码吗?
No,先思考下如何自己设计一个HashMap,该如何实现,用什么数据结构?
可以做下这道力扣题目706. 设计哈希映射
首先看下HashMap有哪些成员吧!get方法和put方法先不看了,肯定有点复杂!

1、内部类

这是HashMap的1个内部类。

从下面的源码中可以看出其存有4个字段,其中2个还用final修饰了。

这里大概猜想下这个key和value就是HashMap的键值对,value可变嘛。实际确实也是这样。

所以最后记住这个内部类Node<K,V>存有key-value还有不对外暴露的hash值及下一个节点(指针)。下文把这个内部类统称为节点或者节点内部类。

但这里有疑惑了,这个类是用来干嘛的?
为什么设计成这4个字段。

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
           //就是this赋值。这里省略4行。
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
           //一些判断。省略...
        }
    }

2、hash(Object key)

再看下这个方法,这边是HashMap内部封装了1个静态方法。

就是说对这个key计算其hash值,比如说String类型的或者Integer类型的key都是有hashcode值的。

这个方法可以看出来就是key为null也会计算hash值,当成0来弄。

但这个方法是用来干嘛的呢?

    /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

3、其他一些方法和字段

先大概看下HashMap有的一些字段和方法,从字段开始看起。
第一个字段DEFAULT_LOAD_FACTOR 说的是构造器中的加载因子?默认加载因子0.75f。记住它是个类常量

第二个字段table,看的出来是节点类型的数组table,那就是说HashMap类里面有个数组字段。

modCount意思指这个hashmapstructurally在结构上已经修改的次数吗?

threshold看起来是跟load factor?加载因子有关的容量,什么鬼?翻译说是要初始化的下个大小的值。数组初始化?

	/**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
	

	/**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;
	
	/**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient int modCount;

    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

再看下方法,就这2个方法。从注释可以看出来是给LinkedHashMap用的,LinkedHashMap会重写他们。但我们用HashMap的话就直接走这部的空代码块了。

    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }

4、还有它的构造方法

就这行,比较简单。意思是说用了默认的类常量加载因子后就是其他的字段初始化也是默认的。

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

调试

好的,通过第一步看HashMap源码明白了它有个内部类:节点,还有个节点数组的字段,然后构造方法没做什么事的,至少跟我看到的字段无关。还有一些方法和字段目前看不明白,也不准备去调试看相关代码了。

先直接调试get方法吧!

1、调试get方法

先看流程图,调试get方法则会进入这2个方法。最后返回节点的value值。第一个方法get(Object key)里面逻辑就是得到hash值再作为参数传入第二个方法中。

具体要调试第二个方法getNode(int hash, Object key)

校验节点逻辑:其中校验头结点这里的判断逻辑后面包括put方法都会用到,这里可以先看下有个印象:

就是说需要节点的hash和key两者都等于hash、key这2个变量(实际就是方法参数)。

       if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))

在这里插入图片描述

查看源码

	    /**
     * Returns the value to which the specified key is mapped,
     * or {@code null} if this map contains no mapping for the key.
     *
     * <p>More formally, if this map contains a mapping from a key
     * {@code k} to a value {@code v} such that {@code (key==null ? k==null :
     * key.equals(k))}, then this method returns {@code v}; otherwise
     * it returns {@code null}.  (There can be at most one such mapping.)
     *
     * <p>A return value of {@code null} does not <i>necessarily</i>
     * indicate that the map contains no mapping for the key; it's also
     * possible that the map explicitly maps the key to {@code null}.
     * The {@link #containsKey containsKey} operation may be used to
     * distinguish these two cases.
     *
     * @see #put(Object, Object)
     */
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //校验数组及节点(非空判断)
        //具体:数组不为空并且该hash对应数组元素(节点)不为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //校验头节点(hash及key是否等于形参),其中key符合则直接返回
            //具体:该数组元素hash值等于方法参数hash;并且该节点key等于方法参数key、或者说key非null且key等于该节点key。
            //(&&后面)第二个条件就是判断key是否等于形参key。
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //头结点不符合,则遍历循环节点,判断逻辑同上。
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

小结

结合源码得到以下结论:

  • 数组下标获取方式:key->hash->下标:根据key来获取table数组内对应元素->链表存放哪个下标中。

    具体: 其中key是外部传入的键,内部再对其进行计算hash值,再根据(数组长度-1)和hash值进行&位与运算最后得到下标位置。

  • 校验节点是否是我们需要的逻辑:判断其的key值和hash值是否等于我们传入的key和内部计算的hash值。

    这边hash值就是内部方法hash(key)计算而来的,一般不会有误。所以我认为这个校验逻辑主要就是看下key是否相同,并保证hash也要相同。光hash相同不行,有哈希冲突这个现象的。

对了,记住上面2点结论或者说逻辑,因为put方法也会用到的!

2、调试Put方法

先看下流程图,也是走2个方法,第一个put就是调用hash,并且多传了2个参数过去。

先初步看下第二个putVal方法的参数,hash和key传过去用于校验。后面2 个参数:

  • onlyIfAbsent (翻译)如果true,不改变已存在的value值。这边调试传的是false。遍历搜索了这个变量逻辑也没有用到true的情况,就这里的put方法用了1下。
  • evict (翻译)如果false, 这个数组会是创建模式。这边传的是true。看了下其他地方有用到的,比如HashMap的子类LinkedHashMap。知道设计模式的话,其实它这边是用到了模板设计模式的写法。

在这里插入图片描述

看具体源码之前,思考下put方法应该要考虑的逻辑。稍微有点复杂,有以下几种情况

  • 某个key及其value第一次put
  • 某个key及其value不是第一次put
  • 哈希冲突:某个key具有和已经put过的key相同的hash值,如key类型为Object则null和0,计算后的hash都是0。

查看源码

带着思考结合具体源码来分析,源码这边是怎么针对这3个情况的!:

    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent 如果true,不改变已存在的value值
     * @param evict  如果false, 这个数组会是创建模式。.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 赋值n! 第二次put时则会将n赋值为16
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //赋值p! p:该key->hash->下标的节点内部类。如果为null说明这个key对应的hash值是第一次put 
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            //该节点已存在(某hash第二次以上进行put)。
            Node<K,V> e; K k; //p就是下面循环会用下。e则是一直下面都有用了
            //赋值k!校验这个节点的hash值及key是否等于形参。 2022年6月13日10:47:08 所以这里用了==和equals
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //该节点hash和key有1个不等于形参hash、key:
            //一般hash是跟着key来的。能进这条链表(数组这个下标的元素)说明hash值没问题。所以这里一般哈希冲突会走以下代码。
            else {
                //遍历这条链表各个节点,当满足该节点hash和key有1个等于形参hash、key则
                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;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //这个key已经put过了->覆盖老的value。
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

回到一开始的思考了,情况分为3种!某个key及其value第一次及后面再put或者hash冲突。这3种情况的hash值都相同

​ 所以最后这个方法解决的问题是某hash第一次进行put需创建链表(头节点)、某hash第二次以上进行put、产生哈希冲突情况下(key不同,但hash相同)去遍历链表寻找指定key和hash的节点,还有判断了是否是树节点。

​ 当某个链表节点个数到达8以后会走treeifyBin(tab, hash)。这个方法这边不展开了,大体执行意思是:如果数组长度还没到64,则先进行数组扩容,首次会将初始长度16扩到32,然后扩到64。扩容时候链表会一分为二存到原来下标和原来下标+16或者32的下标下。然后还是会某个链表节点个数到达8,则此时不扩容,而是将这个链表转化为红黑树。

小结

  • 首次put1个Key-value,会在数组内指定下标位置添加元素,元素就是链表!数组初始长度为16

  • 只要hash相同,就存在1条链表中,其中节点的key可以不同。体现在第二次put某个key-value或者相同hash值得key及其value。

    • key相同,则覆盖value。
    • key不同,通过链表的遍历节点的key得到属于它的节点里的value,这样解决了哈希冲突问题。
  • 某个链表节点个数到达8,这个值内部写死了。

    • 内部的数组就会进行扩容到长度为64为止,长度为8的链表会一分为2存在数组2个下标中。
    • 再有链表节点个数到达8,这个链表会转化为红黑树。

总结

​ 首先HashMap内部有个table字段,是个数组,元素存的是HashMap的1个内部类Node<K,V>。了解数据结构的话知道节点也就是链表上的节点。所以可以说HashMap是数组加链表的形式,并且要记住节点存放key-value。一开始我记的是链表只存value就显然不对了。

​ 所以节点/链表存在数组哪个下标需要设计。put的操作具体就会先根据key来判断下标位置,实质上是求得key的hash值,所以也可以说是key的hash值来获取这个key要存放的下标位置。

​ 并且hash分2种情况,重复的key的hash自然相同,进行put就直接覆盖该节点的value值。不同key但hash相同即哈希冲突则是对链表节点的追加。节点存了key所以get方法查询的时候会遍历这个链表的节点的。

​ 最后链表个数到达8会对内部数组进行扩容,扩容到64则链表会转化为红黑树。
所以put和get,存取的逻辑到这里应该通过源码看明白了。

我的几个问题

  • hashMap存了key的原始值还是hash值?
  • 1个类只重写hashCode()方法是不是就能用HashMap来去重了?
    答: 必须重写。查看put源码,先判断是否是第一次put,如果不是,再比对 == oldKey是否为true 或者equals(oldKey)为true,如果都为false则添加进去。
    所以必须重写equals()方法。所以要自定义去重还是得重写equals,而hashcode只是为了找到map存的那个链表 。

    散列表需要使用 hashCode 来定位元素放到哪个桶。如果自定义对象没有实现自定义的 hashCode 方法,就会使用 Object 超类的默认实现,得到的两个 hashCode 是不同的,导致无法满足需求。

面试问过的相关真题

1、TODO


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
HashMap是一种散列表,它存储的内容是键值对(key-value)映射。当我们将一个键值对存储在HashMap中时,它会根据键的哈希码找到对应的桶(bucket),然后将键值对存储在桶中。当我们需要获取一个键对应的值时,HashMap会根据键的哈希码找到对应的桶,然后在桶中查找对应的值。 在面试中,如果被问到HashMap是否有序,我们需要回答说HashMap不是有序的,因为HashMap中的键值对是根据哈希码存储的,而哈希码是无序的。如果被问到HashMap的存储原理,我们需要回答说HashMap是通过哈希算法将键映射到桶中的,然后将键值对存储在桶中。如果被问到哈希算法的底层实现,我们需要回答说哈希算法的底层实现是哈希表。 以下是一个简单的HashMap的实现代码,仅供参考: ```java public class MyHashMap<K, V> { private static final int DEFAULT_CAPACITY = 16; private static final float DEFAULT_LOAD_FACTOR = 0.75f; private Entry<K, V>[] table; private int size; private int capacity; private float loadFactor; public MyHashMap() { this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR); } public MyHashMap(int capacity, float loadFactor) { this.capacity = capacity; this.loadFactor = loadFactor; this.table = new Entry[capacity]; } public void put(K key, V value) { int index = hash(key); Entry<K, V> entry = table[index]; while (entry != null) { if (entry.key.equals(key)) { entry.value = value; return; } entry = entry.next; } Entry<K, V> newEntry = new Entry<>(key, value); newEntry.next = table[index]; table[index] = newEntry; size++; if (size > capacity * loadFactor) { resize(); } } public V get(K key) { int index = hash(key); Entry<K, V> entry = table[index]; while (entry != null) { if (entry.key.equals(key)) { return entry.value; } entry = entry.next; } return null; } private int hash(K key) { return key.hashCode() % capacity; } private void resize() { capacity *= 2; Entry<K, V>[] newTable = new Entry[capacity]; for (Entry<K, V> entry : table) { while (entry != null) { Entry<K, V> next = entry.next; int index = hash(entry.key); entry.next = newTable[index]; newTable[index] = entry; entry = next; } } table = newTable; } private static class Entry<K, V> { K key; V value; Entry<K, V> next; public Entry(K key, V value) { this.key = key; this.value = value; } } } ```

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值