为什么你每次被问到HashMap底层原理都一知半解,搞定它

时间:2022-07-22
本文章向大家介绍为什么你每次被问到HashMap底层原理都一知半解,搞定它,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

我相信,HashMap这个容器,在我们Java程序开发中是经常出现在我们的代码中的,主要用来存储键值对的数据。它是应用更加广泛的哈希表实现。而且,我们经常在面试中,经常被问到hashMap的底层实现,怎么存放数据的,怎么过去数据的,它的加载因子LoadFactor为什么是0.75等等问题。

如果,我们在平时没有很好的了解或者是没有看过其源码的话,对于这些问题,一般就会出现不知道怎么去回答或者回答不到点子上,甚至是直接回答不上来。所以,今天我们就结合源码来深入分析下HashMap的实现原理。

01

HashMap 整体结构

HashMap 继承了AbstractMap,同时实现了Map接口

初始化

首先,我们先来看看HashMap的构造函数,一般我们默认初始化都是这样的,new HashMap()。它对应的源码是这样的:

public HashMap() {
 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
 }

这个时候,看构造函数中有一个加载因子loadFactor,DEFAULT_LOAD_FACTOR默认值是0.75,loadFactor是为了间接设置我们的哈希表(理解为数组)的内存空间,源码中就是Entry数组的大小。这里之所以设计为0.75,而不是0.5也不是1呢?

我们都知道hashmao使用链表法解决的hash冲突,所以查询一个元素的时间复杂度是O(1+n),如果设置过小的话,则会进行频繁的扩容,比如设置0.5,本来是内存大小是8,然后目前到了4就会扩容到16,到了8就会扩容到32等,就会造成空间的大量浪费。如果设置的过大,这样空间利用率的确很充分,但是这就会造成链表更长,查询效率就会降低。

我们再来看一个带有初始容量的构造函数

 public HashMap(int initialCapacity) {
 this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }
//实现
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))
 throw new IllegalArgumentException("Illegal load factor: " +
 loadFactor);
 this.loadFactor = loadFactor;
 this.threshold = tableSizeFor(initialCapacity);
 }

这块就会涉及第二个知识点,边界值threshold ,threshold = capacity * loadFactory,当前HashMap的size到了边界值threshold的时候,就会触发扩容,这就是hashmap何时扩容resize()。

02

HashMap put 数据

HashMap初始化之后,就可以进行put添加元素了,先看put入口源码:

public V put(K key, V value) {
 return putVal(hash(key), key, value, false, true);
 }
static final int hash(Object key) {
 int h;
 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }

源码中可以看出,将一个(key,value)键值对添加到HashMap中的时候,先是对key的hashCode值然后再hash计算出来hash值,最后确定该元素在哈希表中的存储位置。

其实最核心的逻辑都在这个方法里面 putVal(hash(key), key, value, false, true)。在看源码之前,我先将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;
        if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
        else {
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
                for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
                            treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
                        break;
                    }
                    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;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

其实,hashmap存储数据的核心就是使用哈希表加链表法来避免hash冲突,从Java8开始就增加了红黑树(红黑树下次准备分享一篇文章出来)。红黑树的查询效率比链表的查询要高很多,这里TREEIFY_THRESHOLD 设置的是 8 ,也就是说当链表的长度超过 8 的时候,就会将当前链表转化为红黑树,同时我们需要注意,当在转化红黑树的时候,由于存在左旋、右旋,所以这个时候的新增效率会有所降低的。

总结,今天我们分析了HashMap的底层源码,知道了它是通过哈希表数据结构的形式来存储键值对数据,这种设计的直接效果就是查询效率较高。同时,还结合了链表来解决哈希冲突,当链表长度达到阈值时,就改变链表为红黑树的结构,已解决链表过长查询效率低下的问题。

关于架构师修炼

本号旨在分享一线互联网各种技术架构解决方案,分布式以及高并发等相关专题,同时会将作者的学习总结进行整理并分享。

更多技术专题,敬请期待