【Java基础】不要再问我HashMap了

516 阅读12分钟

前言

HashMap是我最开始学习 Java 接触的集合类之一,其实就是我们常说的哈希表,也是我们开发中最常用的数据结构之一。

作用是存储键值对,能够根据 key 快速定位并返回对应的 value 值。

HashMap 也是面试常考问题,可以说是起手式了。

可以看看下面这些问题,是否都能答上来

  1. HashMap 的底层数据结构是怎样的?
  2. HashMap 怎么解决 hash 冲突问题?
  3. JDK8后,HashMap 有什么改动?
  4. table 的 length 为什么是 2 的 n 次幂?
  5. 计算 hash 的时候为什么是:h & (length-1),而不是 h & length,更不是 h % length?
  6. table 的初始化时机是什么时候,初始化的 table.length 是多少、阀值(threshold)是多少?
  7. 什么时候会触发扩容?
  8. HashMap 与 HashTable 有什么区别?
  9. HashMap 为什么是线程不安全的?
image-20210704205353881

底层数据结构原理

数据结构

image-20210614162008505

HashMap 底层是数组 + 链表的结构,就是常说的拉链表

怎么解决hash冲突

当我们往 HashMap 中 put 元素时,首先会根据 hash 值,定位这个元素需要放到数组的哪个位置。

但是,我们一直往 HashMap 中 put 元素,刚好有元素需要放到相同的数组位置,

如果此时 key 相同,则直接覆盖,

如果此时 key 不相同,则说明出现了 hash 冲突。

这时,HashMap 会把元素以链表的形式存储在数组元素后面。

其实上面描述的也是设置 value 的过程。

查找 value 的过程

查找 value 的过程与 put 过程也是类似的,

首先根据 hash 值定位元素属于哪个数组,然后遍历链表, 对比 key 是否相等,相等则返回对应的value。

自动扩容

HashMap 初始化时,会指定 Entry 数组的初始化大小。但是,我们一直往里面丢元素,hash 碰撞越来越多,链表也越来越长,查询 value 时效率就会降低。这也是 HashMap 存在自动扩容机制的原因。

自动扩容涉及的三个重要参数:capacity起始容量loadFactor负载因子threshold阈值

capacity起始容量:hashmap entry数组的起始大小。

loadFactor负载因子:loadFactor负载因子是控制数组存放数据的疏密程度,默认值是0.75。

threshold阈值:hashmap进行自动扩容的阈值,threshold = capacity * loadFactor ,当数组大小 >= threshold 时,就会进行自动扩容。

JDK8后的改动

链表长度大于8同时数组大小64时,会将链表转化为红黑树,来提高查询效率。

因为当数组下的链表越来越长时,我们遍历寻找元素的时间成本就越高。

源码分析

下面的源码分析基于 JDK1.8

类成员变量

    // 数组默认初始容量16    
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
    // 数组最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默认负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当链表长度大于8时,会将链表转化为红黑树
    static final int TREEIFY_THRESHOLD = 8;
    // 当红黑树节点小于这个值时转换成链表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 当数组大小大于64时,会将链表转化为红黑树
    static final int MIN_TREEIFY_CAPACITY = 64;
    // hash数组
    transient Node<K,V>[] table;
    // 具体存放元素的集合
    transient Set<Map.Entry<K,V>> entrySet;
    // hashmap存放元素的个数
    transient int size;
    // 扩容和更改map结构的计数器
    transient int modCount;
    // 扩容阈值,threshold = capacity * loadFactor
    int threshold;
    // 负载因子
    final float loadFactor;

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) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

包含主要的key-value变量,还有用于构造链表的 next 引用。

可见 hashmap 底层是数组+链表的数据结构。

构造方法

    // 默认构造函数
    public HashMap() {
        this.loadFactor = 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);
    }
    // 指定数组初始容量
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    // 包含另一个map
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

从构造函数可以看到,提供数组初始容量负载因子的设置,

因为 hashmap 扩容时耗费性能的操作, 所以在使用 hashmap 时,设置合理的数组初始容量和负载因子能够减少后续自动扩容的次数。

put方法

put 方法是 hashmap 最核心的方法,也有很多有意思的细节,下面我们开始一步步看下。

首先,需要根据 key 计算 hash 值,用于定位这个元素需要放置在数组的哪个位置。

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

然后,我们跟进去 hash 函数看下哈希值是怎么计算的

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

使用 key 的 hashcode 与 hashcode 的高16 位进行异或运算。

image-20210627210906148

接着回到 putVal 方法,使用数组长度与上面一步得出的 hash 进行 & 运算,确定元素需要放置的数组位置。

看到这里的时候,我自己有几个疑惑,

  1. 为什么要跟数组长度做 & 运算?
  2. hash 值为什么不直接用 hashcode?
  3. 为什么要跟 hashcode 的高16 位进行异或运算?

通过网上寻找答案,我认为下面第一个回答解释的比较好 JDK 源码中 HashMap 的 hash 方法原理是什么

为什么要跟数组长度做 & 运算?

其实这里正好解释为什么 HashMap 的数组长度要取2的整数幂

因为数组长度-1,刚好相当于一个低位掩码

“与”操作的结果就是将 hash 值的高位全部归零,只保留低位值用于数组的下标访问。

以初始长度16为例,16-1=15,2进制表示为 00000000 00000000 00001111

与某个hash“与”操作如下,结果就是截取最低的四位

image-20210703173346067

hash 值为什么不直接用 hashcode?

1、hashcode 是 int 类型,int 类型范围从-2147483648到2147483648,但是 hashmap 的数组初始大小才16,跟数组对应不起来,所以不能直接用 hashcode,至少取个模对吧。

2、上一个问题我们已经知道,hash值还会与数组长度进行“与”运算后才能使用,

因为hash方法是对象中用户自定义的,hash值本身的散列效果可能就做的不好

如果刚好最后几个低位有较多的重复,导致hash冲突太多,就GG了。

为什么要跟 hashcode 的高16 位进行异或运算?

1、就是解决 key 自身 hashcode 实现不理想的情况

2、因为与数组长度进行“与”运算时,我们保留的都是低位,h = key.hashCode()) ^ (h >>> 16) 将自己的高位和低位进行异或运算,可以混合原hashcode高低位的特征,同时增加低位的随机性

最后,回到 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;
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 没有发生 hash 碰撞,直接把元素放置在数组位置
            tab[i] = newNode(hash, key, value, null);
        else {
            // 发生 hash 碰撞
            Node<K,V> e; K k;
            // 与数组中的结点 hash 值相同,用e记录下来
            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);
                        // 结点数量达到阈值,转换为红黑树
                      	if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 如果链表中的key与需要插入的key相同,退出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    // 移动到链表下一个节点,用于遍历链表
                    p = e;
                }
            }
            // 用新值覆盖旧值
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 大小超过阈值则进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

总结下关键步骤:

  1. 当数组为空,调用resize方法初始化
  2. 没有发生 hash 碰撞,直接把元素放置在数组位置
  3. 发生 hash 碰撞,如果是红黑树结构,调用红黑树的插入方法
  4. 发生 hash 碰撞,如果是链表结构,遍历链表,如果链表中的key与需要插入的key相同,覆盖旧值,否则插入到链表尾部。
  5. 大小超过阈值则进行扩容

get方法

首先,需要定位 key 位于哪个数组位置,计算 key 对应的 hash 值,上面已经分析过。

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

接着看下 getNode 方法

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // 如果在数组位置上,直接返回
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            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;
    }

总结下关键步骤:

  1. 如果需要查找的元素在数组位置上,直接返回
  2. 否则遍历链表或者红黑树进行寻找

resize方法

    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;
            }
            // 没超过最大值没, 扩容为原来的2倍
            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;
        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;
                            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;
    }

总结下关键步骤:

  1. 超过数组最大值,不处理

  2. 没超过最大值, 扩容为原来的2倍

  3. 把元素迁移到新的结构中

这里,我们需要关注下在什么场景下会进行 resize 操作?

  1. 第一次调用 putVal 方法时,会调用 resize 方法进行初始化
  2. size > threshold 阈值的时候,会调用 resize 方法进行扩容

HashMap 与 HashTable 的区别

相信这个问题大家在面试中也没被少问,

HashTable 与 HashMap 最大的区别是 HashTable 是线程安全的,因为它内部的方法基本都是经过synchronized 关键字修饰。

还有就是 HashTable 不允许 key 和 value 为 null。

HashMap是线程安全的吗

HashTable 是线程安全的,HashMap 是线程不安全,但是线程安全在什么地方呢?

JDK1.7的HashMap

jdk1.7下的 HashMap 在多线程环境下, 可能会出现死循环数据丢失的问题。

出现问题的根源扩容方法transfer 方法中,

这个函数主要作用:HashMap 数组进行扩容后,把原来的元素转移到新的数组中。

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                // 使用的头插法
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

使用头插法是造成链表反转死循环数据丢失的关键点。

扩容造成死循环分析的过程大家可以参考【20期】你知道为什么HashMap是线程不安全的吗?逐步进行推演。

JDK1.8的HashMap

jdk1.8对 HashMap 进行了优化,处理 hash 冲突不再使用头插法,而是直接插入链表尾部

所以多线程环境下不会出现环形链表的情况。

但是,会还是会出现数据丢失的情况!

原因出在 putVal 方法注释的位置,两个线程同时执行到注释位置,A线程被挂起,B线程正常执行,当A线程获取到时间片进行执行时,不再进行 hash 判断,将B线程的数据覆盖了,造成数据丢失。

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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
            tab[i] = newNode(hash, key, value, null);
  
  ...

怎么线程安全地使用键值对结构

在多线程环境下,如果需要保证线程安全,我们可以使用 HashTableConcurrentHashMap

但是 HashTable 是过时的类,更建议使用 ConcurrentHashMap

最后

通过这篇文章,文章开头的几个问题应该都能答得上来吧,下面我简要回答下:

  1. HashMap 的底层数据结构是怎样的?

    数据+链表+红黑树的结构

  2. HashMap 怎么解决 hash 冲突问题?

    通过拉链表解决,key相同则覆盖,否则加到链表尾部

  3. JDK8后,HashMap 有什么改动?

    当链表长度大于8同时数组大小64时,会将链表转化为红黑树

  4. table 的 length 为什么是 2 的 n 次幂

    方便在计算数组索引值时,用于计算低位掩码

  5. 计算 hash 的时候为什么是:h & (length-1),而不是 h & length,更不是 h % length?

    因为length-1,刚好相当于一个低位掩码,同时将高位全部归零,只保留低位值用于数组的下标访问

  6. table 的初始化时机是什么时候,初始化的 table.length 是多少、阀值(threshold)是多少?

    put 元素时进行初始化,初始化 table.length 为 16,threshold 为 12

  7. 什么时候会触发扩容?

    1、第一次调用 putVal 方法时,会调用 resize 方法进行初始化

    2、当 size > threshold 阈值的时候,会调用 resize 方法进行扩容

  8. HashMap 与 HashTable 有什么区别?

    HashTable 与 HashMap 最大的区别是 HashTable 是线程安全的, HashTable 不允许 key 和 value 为 null。

  9. HashMap 为什么是线程不安全的?

    1、在jdk1.7中,在多线程环境下,因为采用头插入方式,扩容时会造成死循环或数据丢失。

    2、在jdk1.8中,在多线程环境下,putVal 方法的并发调用,可能会发生数据丢失的情况。

image-20210704205629404

如果你觉得这篇文章对你还有帮助,想邀请你帮我三个小忙:

  1. 点赞,评论,转发,这对我真的很重要~
  2. 搜索关注微信公众号 【NotOnlyJava】,定期分享原创文章
  3. 同时可以期待后续文章

参考资料