【Java集合】你回答得出HashMap(JDK1.8)的7个问题吗?

前言

可能有小伙伴问,现在Java 14都发布了,我们还在回顾Java 8的内容,不会跟不上时代了吗?其实学习Java 8中HashMap的底层原理,除了应付面试,我们还可以多问问:为什么要做出这些改变?有什么好处吗?

本文主要对HashMap的底层结构和功能原理进行介绍。

(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)

1. 简单介绍HashMap

HashMap的底层结构是应用更为广泛的哈希表,了解HashMap,可以先抓住以下几点。

1.1 HashMap的底层结构

HashMap就是使用哈希表来存储的。在JDK1.8中,HashMap是由3种数据结构组成的:数组+链表+红黑树,而在JDK1.7中是由前两者实现。
【Java集合】你回答得出HashMap(JDK1.8)的7个问题吗?_第1张图片

1.2. HashMap的null

  • HashMap可以存储null键和null值,其中null键只能有一个,但null值可以有一个或多个。
  • 当get(key)方法返回null值时,可以表示HashMap中没有该key,也可以表示这个key对应的value是null。所以,在HashMap中不能使用get()方法来判断HashMap中是否存在某个key,应该用containsKey()方法来进行判断
  • 而在HashTable中,无论是key还是value,都不能为null。

1.3. HashMap的size

  • HashMap的初始size为16,扩容机制为newsize = oldsize * 2,HashMap的size一定为2的n次幂。
  • 当HashMap中的元素总数超过了Entry数组的75%,则触发扩容操作。为了减少链表长度,元素会分配得更加均匀。
  • 每次扩容的时候,已经存储了的元素会依次重新计算存放位置,并重新插入。

1.4. HashMap的JDK1.7与JDK1.8

  • JDK1.7底层是由数组+链表实现;而JDK1.8中底层是由数组+链表/红黑树实现。
  • JDK1.7中是先扩容再插入新值,而JDK1.8中是先插值再扩容
  • 如果是插入元素之后再扩容,有可能会因为扩容后没有元素再次插入,导致无效扩容。

1.5. HashMap的线程不安全

因为在接近扩容临界点时,此处如果有两个或多个线程进行put()操作,这些线程都会进行resize(扩容)和rehash(为key重新进行存储位置),而rehash在并发的情况可能会发生Entry链表形成环形数据结构,这时,Entry的next节点永远不为空,就会产生死循环。

上述情况在JDK1.8有所好转。因为在JDK1.7中采用的是头插法,而JDK1.8中采用了尾插法。且JDK1.7采用的是数组+链表结构,在链表长度过长的时候,会严重影响查询效率。所以在JDK1.8中,当链表长度大于阈值(默认长度为8)时,链表为转为红黑树结构。在此推荐一篇关于JDK1.7HashMap形成环形数据结构的文章:jdk1.7 HashMap中的致命错误:循环链表。

但也并未解决HashMap线程不安全的问题,因为在多线程的情况下,当Node结点转换成TreeNode结点时,可能会报出操作对象内部不一致的问题;也可能在红黑树左右旋的时候的时候出现问题。所以在并发情况下建议使用ConcurrentHashMap

可以参考:HashMap在jdk1.8中也会死循环(这篇文章可以当做参考,笔者开了万条线程都没刷出来,但就是有人刷出来了,心情复杂.jpg)、JDK8:HashMap源码解析:TreeNode类的balanceInsertion方法(这篇介绍了红黑树的重新结构化,值得一看)

在我们对HashMap有着初步了解之后,下文主要以问答的形式,介绍一些我们在使用HashMap时较少注意到的问题。

2. HashMap是什么?

在了解HashMap的底层结构之前,我们先来看看HashMap的属性

    /**
     * The default initial capacity - MUST be a power of two.
     * 默认初始容量 - 必须是2的幂次方(后文会对其进行解释)
     */
    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.
     * 如果有更大容量值,也不能超过1<<30(后文会对其进行解释)
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     * 负载因子为0.75(后文会对其进行解释)
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 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.
     * 在hash冲突发生的时候,默认采用单链表存储,当单链表节点个数大于8的时候,就会转换为红黑树存储(后文会对其进行解释)
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 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.
     * 当红黑树的节点少于6时,则转换为单链表存储
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     * 虽然在hash冲突发生的时候,默认使用单链表存储,当单链表节点个数大于8时,会转换为红黑树存储
     * 但是有一个前提(很多文章都没说):要求数组长度大于64,否则不会进行转换,而是进行扩容。
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

2.1. 最大容量为什么是不超过1<<30?

关于这个问题,我们可以从最大容量的类型为Integer下手。为了使得HashMap的容量值是2n,又不能超过Integer的范围:

public static void main(String[] args) {
     
    System.out.println(Integer.MAX_VALUE);
    System.out.println(1<<30);
    System.out.println(1<<31);
}

运行结果为:

2147483647
1073741824
-2147483648

笔者在Java的基本数据类型、拆装箱(深入版)介绍过,int类型的数据所占空间大小为32位,所以如果超过这个范围之后,会出现溢出。所以,1<<30是在int类型取值范围中2次幂的最大值,即为HashMap的容量最大值。

2.2. HashMap的加载因子为什么是0.75?

笔者在HashMap的加载因子为什么是0.75?有详细解答过,加载因子0.75是提高空间利用率和减少查询成本的折衷,因为在加载因子为0.75时,泊松分布的碰撞最小。

HashMap中除了哈希算法之外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在创建时的容量,加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量

通常,加载因子需要在时间和空间成本上寻求一种折衷。

加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;

加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。

所以,选择了0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择

2.3. HashMap具体存储的是什么数据?

在JDK1.7中,HashMap中的数组中的每一个元素其实就是Entry[] table,Map中的key和value都是以Entry的形式存储的。但是因为在JDK1.8中HashMap需要支持红黑树,所以换成了Node的形式,但其实本质上二者很相似。

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) {
     
            ....
        }

        public final K getKey()        {
      ... }
        public final V getValue()      {
      ... }
        public final String toString() {
      ... }
        public final int hashCode() {
      ... }
        public final V setValue(V newValue) {
      ... }
        public final boolean equals(Object o) {
      ... }
    }

可以看到Node实现了Map.Entry接口,本质是一个映射(键值对),上图中每一个黑点就是一个Node对象。

2.4 为什么要将链表中转红黑树的阈值设为8?

链表的时间复杂度为O(n),而红黑树的时间复杂度为O(log2(n)),红黑树相对于链表来说,是一个相对复杂的数据结构,感兴趣的读者可以参考这篇文章:教你初步了解红黑树。

笔者在HashMap的加载因子为什么是0.75?提到过,在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布,而且在哈希桶中的链表长度达到8个元素的概率为0.00000006,几乎是一个不可能事件

/* Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
     */
既然几乎是一个不可能事件,那么为什么还要在链表长度为8的时候转换成红黑树呢?

俗话说:圣人千虑,必有一失。愚人千虑,必有一得。

极小概率发生的事件,只要在基数大的环境下,它的发生就是一种必然事件。

当链表长度为8的时候,链表的性能已经非常差了。所以在这种比较罕见和极端的情况下,才会将链表转换为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了挽救性能,才会使用红黑树来提高性能。所以在大部分情况下,HashMap还是使用链表,如果是理想的均匀分布,哈希桶的节点数不到8,HashMap就自动扩容。

HashMap不直接使用红黑树,是因为树节点所占空间是普通节点的两倍,所以只有当节点足够的时候,才会使用树节点。也就是说,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占的空间比较大,所以综合考虑之下,只有在链表节点数太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。

需要注意的是,转换成红黑树的条件有两个:

  • 链表长度超过8;
  • HashMap数组长度超过64。

2.5 HashMap中的hash函数是怎么散列的?(HashMap初始容量为什么是2的n次幂?)

我们先来看段hash()函数的源码:

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

大家都知道key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。

理论上散列值是一个int型的数值,但是如果直接那散列值作为下标访问HashMap主数组的话,考虑到int型数据的取值范围在-2147483648到2147483647,前后加起来大概40亿的映射空间,一般来说只要哈希函数散列的比较均匀松散,一般应用是很难出现碰撞的。

但是40亿长度的数组,内存是放不下的。HashMap的数组初始大小也才16而已,所以这个散列值是不能用的。因此,需要对数组的长度做取模运算,得到的余数才能进行访问数组下标。

(h = key.hashCode()) ^ (h >>> 16) 执行了三步操作,接下来简单介绍一下:

  • 第一步:h = key.hashCode()

这一步会根据key值计算出一个int类型的hashCode值。而根据key计算hashCode值的hashCode()需要分情况进行介绍:

  1. 如果是我们创建的对象,在没有重写hashCode()方法的情况下,会调用Object()类hashCode()方法,返回的是对象的内存地址值。如果对象不同,那么计算出来的hashCode值也不同。
  2. 如果是Java中定义的引用类型如String、Integer等作为key,这些类一般都会重写hashCode()方法,所以一般会根据不同类型的对象返回不同的值,如Integer类的hashCode即Integer值,而String类型的hashCode()方法稍微复杂一点,这里就不赘述了。

所以,hashCode()方法就是要根据不同的key得到不同的hashCode值

  • 第二步:h>>>16

这一步是将第一步计算出来的hashCode值无符号右移16位,这一步将第三步操作需要的高低位区域分出来了。

  • 第三步:h ^ (h>>>16)

这一步将hashCode值的高低16位进行了异或处理,就是为了混合原始哈希码的高低位,以此来加大低位的随机性。而混合后的低位掺杂了高位的部份特征,这样高位的信息也被变相保留下来

至此,hash函数的散列过程就已经介绍完了,而hash函数的这个过程也称为“扰动函数”。

然而,这三步还不能确定元素存放的位置。元素在数组中存放的位置是由下面这行代码决定的

i = (n - 1) & hash  // i为数组对应位置的索引  n为当前数组的大小

因为这个过程是做’&‘运算的位计算,计算机能直接进行运算,特别高效。’&'运算的计算方法是,只有当对应位置的数据都为1,运算结果才为1,否则为0。所以当HashMap的容量是2的n次幂时,(n-1)的2进制也就是以"1111111"的形式显示,这样与添加元素的hash值进行位运算时,能够充分进行散列,使得添加的元素在HashMap上均匀分布,减少hash冲突

举个栗子,这整个过程就是这样的:
【Java集合】你回答得出HashMap(JDK1.8)的7个问题吗?_第2张图片

2.6 HashMap的put()方法是什么样的?

我们先来整段源码看看:

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

HashMap中的put方法会调用putVal()方法,而在调用putVal()方法之前还会先调用hash()函数来计算key的哈希值,接下来我们来看看putVal()方法。

  • putVal()方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
     
        Node<K,V>[] tab; Node<K,V> p; int n, i;
    	//判断表是否为空,如果为空,则调用resize()函数初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
    	//如果没有hash冲突,直接插入即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
     
            Node<K,V> e; K k;
            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);
            //如果是链表结构,就使用尾插法进行插入
            else {
     
                for (int binCount = 0; ; ++binCount) {
     
                    if ((e = p.next) == null) {
     
                        p.next = newNode(hash, key, value, null);
                        //如果链表长度到达了转换陈红黑树的长度,执行treeifyBin()方法
                        //如果hashMap数组长度小于64,则进行扩容操作
                        //否则进行树化操作
                        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,则考虑值覆盖
            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;
    }

可以看到,首先会先判断table是否为空,如果是空的话,就调用resize()函数进行初始化。之后进行hash映射计算(n-1)&hash,得到元素在HashMap中的位置,如果没有发生hash冲突,就直接插入。

之后需要分两种情况来讨论:

  • 红黑树结构:按照红黑树的方式进行插入或覆盖。
  • 链表结构:使用尾插法进行插入或替换,并且如果插入后,超过规定的阈值,链表结构会转换成后红黑树结构。

最后,如果插入元素后发现数组中包含的元素超过了加载阈值,则调用resize()函数进行扩容操作。

2.7 HashMap的扩容机制是什么样的?(HashMap的扩容为什么是2倍的形式?)

前文介绍了,HashMap的数组元素数量超过了加载阈值,则会触发扩容机制,而且扩容了之后,原数组会进行rehash的过程。接下来具体看看HashMap的扩容机制:

final Node<K,V>[] resize() {
     
        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;
        @SuppressWarnings({
     "rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
    	//以下是rehash的过程
        if (oldTab != null) {
     
            for (int j = 0; j < oldCap; ++j) {
     
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
     
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果是红黑树结构,则进行分裂操作
                    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;
                            //第一种情况:n&hash == 0
                            if ((e.hash & oldCap) == 0) {
     
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //第二种情况:n&hash != 0
                            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;
    }

我们看到,如果原数组容量扩大两倍之后小于最大容量阈值,就可以进行扩容操作,而且不仅原数组扩容成两倍大小,加载阈值也随之扩大两倍。而数组大小必须是2的幂次方,因为在进行(n-1)&hash计算的时候,只有在n为2的幂次方时,(n-1)才能是前部均为0,尾部均为1的形式,这样在进行(n-1)&hash运算时,其范围才会是[0, n-1]

在扩容之后,原数组必然会有一个rehash的过程。此处有两种情况:

  1. 红黑树结构:通过分裂实现rehash过程。

  2. 链表结构:分情况,主要是通过原数组大小n与结点的hash值之间的’&'操作结果。(以下结论参考:你真的懂大厂面试题:HashMap吗?)

    • 如果n & hash == 0,则 (2n-1) & hash == (n-1) & hash
    • 如果n & hash != 0,即 (2n-1) & hash == (n-1) & hash + n

    所以可以根据n & hash的结果,将链表中的元素分成两个链表,一个依旧放在原位置,另一个放在原位置+n处

结语

比起前面的文章,HashMap的前期准备要多一点,拖延症突然发作,所以这篇晚了一点。笔者前面也有一些文章,各位看官有需要可以看看:

关于String的这9个问题,值得一看

你真的有好好了解过序列化吗:Java序列化实现的原理

【Java集合】ArrayList的使用及原理

【Java集合】LinkedList的使用及原理

【Java面试题】除了Vector,还有另一个提供线程安全的List是什么?

计算机网络知识框架总结(复习)

怎么打开Chrome网上应用店+分享Chrome六个好用插件

如果本文对你有帮助,请给一个赞吧,这会是我最大的动力~

参考资料:

HashMap在Jdk1.7和1.8中的实现

Java 8系列之重新认识HashMap

HashMap初始容量为什么是2的n次幂及扩容为什么是2倍的形式

HashMap默认加载因子为什么选择0.75?(阿里)

JDK1.8以后的hashmap为什么在链表长度为8的时候变为红黑树

HashMap中的hash函数

JDK1.8中HashMap如何应对hash冲突?

你真的懂大厂面试题:HashMap吗?

你可能感兴趣的:(Java集合,链表,数据结构,java,hashmap)