Map接口实现——HashMap源码分析

Map接口实现——HashMap源码分析,第1张

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录
  • 前言
  • 一、请说一下 HashMap的实现原理?HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现
    • 底层代码实现看完后,我们对此问题做个总结
  • 二、HashMap是怎么解决哈希冲突的?
    • 总结
  • 三、为什么HashMap中String、Integer这样的包装类适合作为key?
  • 四、HashMap 的长度为什么是2的幂次方
  • 总结


前言

本文主要围绕以下几个问题展开,重点介绍了HashMap的初始化过程和扩容机制。

1.默认容量是多少,负载因子是多少,扩容倍数?
2.底层的存储数据结构?
3.如何处理hash冲突?
4.如何计算一个key的hash值?
5.数组的长度为何是2的幂次方?
6.扩容查找过程?

随着java后端研发岗位越来越卷,HashMap的底层原理以及实现成为面试必问题。故,此文集众家之长,尽可能将所有面试题参考进来,成就最全HashMap八股。为了让自己理解的更深入,每个问题我都附上相应的源码,留作自己以及有缘人复习使用。希望有缘人在评论区留下此文未涉及的HashMap面试问题,我们共同完善此文!我亦会在文末将您加入感谢列表,让我们一起拥抱开源,成就自己!


一、请说一下 HashMap的实现原理?HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现

HashMap就是散列表。
数组的特点是寻址容易,插入和删除困难;链表的特点是寻址困难,但插入和删除容易;
HashMap将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8之前
JDK1.8之前采用的是拉链法来处理冲突。拉链法: 将链表和数组相结合。即创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8之后
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容;否则会将链表转化为红黑树,以减少搜索时间。

HashMap的底层实现,需要考虑以下几个问题:

1.默认容量是多少,负载因子是多少,扩容倍数?
2.底层的存储数据结构?
3.如何处理hash冲突?
4.如何计算一个key的hash值?
5.数组的长度为何是2的幂次方?
6.扩容查找过程?

我们用源码回答上述问题:

//默认的初始化容量是16,必须是 2 的 幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 
//最大容量,左移30位,也就是 2 的 30 次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子,不指定的话默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//Node 结点数组(链表数组),Node:表示一个key-value
transient Node<K,V>[] table;

底层的存储数据结构是结点数组(Node[] table),Node内存储的信息为:
当前对象key的hash值,key值,value值,以及下一个对象的指针。源码如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//key的hash值
        final K key;	//key
        V value;	//value
        Node<K,V> next;	//下一个结点

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
		//重写了hashCode方法,注意return 的值,
		//key的hash值与value的hash值做异或运算
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
		//重写了equals方法:比较每个对象的key和value值,用以判断是否相等
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

由上面的源码可以看出,每一个结点都存储了下一个结点,这是典型的链表。
注意: 它重写了hashCode方法和equals方法
思考问题:自定义容量以及自定义负载因子的HashMap是如何创建的?

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
        	//初始容量大于最大容量,就把最大容量赋值给初始容量
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
        //负载因子小于 0,或者非浮点数则抛出非法参数异常
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
        this.loadFactor = loadFactor;
        //tableSizeFor()是干什么的,threshold(阈值)又有什么用?
        this.threshold = tableSizeFor(initialCapacity);
    }
/**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    //返回一个与给定容量最接近的一个 2 的幂次方(包含等于)的数,也就是说 HashMap 的容量始终都是 2 的幂次方。

jdk1.8之后,将HashMap的初始化方法直接集成到了扩容函数resize()中 ,但是resize()是由put *** 作调用的,我们一步一步分析!先看看HashMap 的put *** 作是如何实现的。

public V put(K key, V value) {
	//我们调用put,其实真正执行的方法是putVal
	//从putVal的参数列表我们也可以看出,每个结点存储的有key的hash值、key、value
 	return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
        //初始时,table数组一定为空。调用resize()进行初始化
        //resize()承担了初始化动作和扩容动作
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
        //hash寻址,发现要插入的位置没有元素,则直接插入进去
            tab[i] = newNode(hash, key, value, null);
        else {
            省略...处理冲突的逻辑
            感兴趣的小伙伴可以看一下源码
            其中有个重要 *** 作:
            遍历table[i],判断链表长度是否大于8,如果大于8并且数组长度大于64的话
            把链表转换为红黑树,在红黑树中执行插入 *** 作,否则进行链表的插入 *** 作;
            遍历过程中若发现key已经存在直接覆盖value即可;
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //数组中实际大小大于临界值(第一次是12 = 16 * 0.75)时,扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

HashMap的核心代码之一,扩容代码!也是面试必问之一

final Node<K,V>[] resize() {
		//table最开始是空的
        Node<K,V>[] oldTab = table;
        //旧表的长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //旧表的阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //真正的初始化,创建一个Node数组
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
   ********************分隔*********************
   // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
        if (oldTab != null) {
        	//遍历新数组的所有桶下标
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
                    oldTab[j] = null;
                    // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                    if (e.next == null)
                    	// 用同样的hash映射算法把该元素加入新的数组
                        newTab[e.hash & (newCap - 1)] = e;
                    // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            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;
    }
底层代码实现看完后,我们对此问题做个总结
  • HashMap 默认容量是 16,默认的负载因子是 0.75f
  • 构造方法只会初始化一个空的 Node 数组,真正的初始化过程是在 put 方法中的 putVal 里面调用的,主要核心方法是 resize 方法
  • HashMap 的容量固定为 2n 次方,也就是如果我们传入了初始化容量,HashMap 不会使用我们传入的容量,而是会帮我们计算与我们传进去的参数最接近(大于等于)的一个 2n 的值作为容量
  • HashMap 内部会存在一个阈值(threshold),该值为容量和负载因子的乘积,当 HashMap 里面的容量大于等于阈值时,会触发扩容。
  • 每次扩展的时候,新容量为旧容量的2倍;
  • 扩展后元素的位置要么在原位置,要么移动到原位置 + 旧容量的位置。
  • 我们在一开始new HashMap<>();时,并没有为我们初始化一个table[],而是在第一次put数据的时候初始化的。这是懒加载机制,防止程序员创建出HashMap不使用,浪费!
二、HashMap是怎么解决哈希冲突的?

前面我们说过,HashMap的数据结构是链表类型的数组(即数组里的每一个元素都是一个链表)。在我们往HashMap中添加数据时,源码会将我们的key,value以及使用hash函数计算的key的hash值封装为链表的结点,并根据key的哈希值在数组中寻找插入位置。
如果这个插入位置已经有数据了,那么便发生了哈希冲突。 那么我们会将发生碰撞的结点,插入到碰撞位置链表的尾部。

//计算hash值的函数
static final int hash(Object key) {
        int h;
        // 与自己右移16位进行异或运算(高低位异或),降低hash碰撞的概率
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。但是依旧会发生碰撞。
JDK1.8之后新增红黑树

为什么加入红黑树?
当我们的HashMap中存在大量数据时,数组中某个位置下对应的链表有n个元素,我们在get()此位置下的数据时,遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);

总结
  • 使用拉链法(使用散列表)来链接拥有相同hash值的数据。
  • 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均。
  • 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快。
三、为什么HashMap中String、Integer这样的包装类适合作为key?

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

  • 都是final类型,即不可变性,保证key的不可更改性,不会存在同一对象获取hash值不同的情况。
  • 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况;
四、HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。(这个实现就是把数据存到哪个链表/红黑树中的算法。)
计算hash值的时候为什么是两次扰动?
这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;

总结

本文从源码角度分析总结了HashMap的初始化、扩容机制。对于单纯的HashMap面试题来说,这些差不多是够用的。
HashMap、ConcurrentHashMap的比较;
Hashtable、HashMap、TreeMap的比较;
什么是红黑树;
等问题放到后续文章介绍。

写在最后:
文中部分内容摘抄自技术人之路。拥抱开源,侵权必删。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/922471.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-16
下一篇 2022-05-16

发表评论

登录后才能评论

评论列表(0条)

保存