吊打面试官之 HashMap 的前世今生


1、前置知识

HashMap 是我们常用的一个数据结构,它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却不是确定的。HashMap 最多只允许一条记录的键为null,允许多条记录的值为null。HashMap 非线程安全,即任一时间可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全要求,可以使用Collections 的synchronizedMap 方法使HashMap具有线程安全的能力,或者使用 ConcurrentMap。不建议使用 Hashtable,它虽然能保证线程安全,但是内部都是使用了 Synchronized,并发性低。HashMap 的继承关系如下:

image-20220327102116825

2、内部实现

从结构上讲,HashMap 是数组+链表+红黑树(JDK1.8增加)实现的,如下图所示:

image-20220327102128444

这里需要明白两个问题:数据底层具体存储的是什么?这样的存储方式有什么优缺点?

我们先看看图中的这个Node源码是咋样的:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //用来定位数组索引位置
        final K key;
        V value;
        Node<K,V> next;   //链表的下一个node

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

HashMap 中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,Node 是 HashMap 的一个内部类,实现了Map.Entry 接口,本质就是键值对映射。

HashMap就是使用哈希表来存储的,哈希表为了解决冲突,可以采用开放地址法和链地址法,HashMap 中采用的就是链地址法。就是数组加链表的结合,每个数组元素上都是一个链表结构,当数据被Hash后,得到数组下标,把数据放到对应下标元素的链表上。比如执行下面的代码:

map.put("帅枫","很帅");

程序将调用hashCode()方法得到“帅枫”的hashCode值,再通过hash算法来定位该键值对的存储位置,有时两个key会定位到相同的位置,表明发生了哈希碰撞,当然Hash算法计算结果越分散,Hash碰撞的概率就越小,map的存取效率就越高。

如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。

在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:

int threshold;             // 所能容纳的key-value对极限 
final float loadFactor;    // 负载因子
int modCount;  
int size;

首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,这个值可以大于1。

size这个字段其实很好理解,就是HashMap中实际存在的键值对数量,而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。

在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。

3、方法的实现

1、确定哈希桶数组索引位置

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能,下面看一下JDK7和JDK8中hash方法的实现:

JDK8:

static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是hashcode
    // ^ :按位异或
    // >>>:无符号右移,忽略符号位,空位都以0补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

JDK7:

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

上面的方法算出了key 的哈希值,我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:

static int indexFor(int h, int length) {  
	//jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
    return h & (length-1);  //第三步 取模运算
}

这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

2、HashMap 的 put 方法

HashMap 的 put 方法主要是下面的这个流程:JDK8版本

image-20220327102139758

简单解释一下流程:

  1. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容。
  2. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③。
  3. 判断table[i]这槽位上的所有的元素是否有和key一样,如果相同直接覆盖value,否则转向④。
  4. 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,转向⑥,否则转向⑤。
  5. 遍历table[i],判断链表长度是否大于8并且数组长度超过64,如果是的话则把链表转换为红黑树,在红黑树中执行插入操作,转向⑥。否则再进行判断链表长度是否小于64并且链表长度大于8,如果是的话就进行扩容之后再 rehash,否则进行链表的插入操作,转向⑥。
  6. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

源码如下:JDK8版本

public V put(K key, V 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;
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        Node<K,V> e; K k;
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            // 将第一个元素赋值给e,用e来记录
            e = p;
        // hash值不相等,即key不相等;为红黑树结点
        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);
                    // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
                    // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
                    // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
                    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;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        // 表示在桶中找到key值、hash值与插入元素相等的结点
        if (e != null) {
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改
    ++modCount;
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

JDK7与JDK8有点区别,感兴趣的同学可以自己去看看,不要看这个步骤很多,梳理清楚了还是很容易记住的。

JDK8是链表的时候采用的是尾插法,JDK7中采用的是头插法。

3、HashMap 的 get 方法

get 方法没有 put 方法那么多判断,就是遍历,下面是源码:

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

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) {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在链表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

4、HashMap 中的 resize 方法

扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组。

分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大。

void resize(int newCapacity) { 
    //传入新的容量    
    Entry[] oldTable = table;   
    //引用扩容前的Entry数组    
    int oldCapacity = oldTable.length; 
    if (oldCapacity == MAXIMUM_CAPACITY) {
        //扩容前的数组大小如果已经达到最大(2^30)了     
        threshold = Integer.MAX_VALUE; 
        //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 
        return; 
    }     
    //初始化一个新的Entry数组 
    Entry[] newTable = new Entry[newCapacity];  
    //将数据转移到新的Entry数组里  
    transfer(newTable);              
    //HashMap的table属性引用新的Entry数组  
    table = newTable;                 
    //修改阈值
    threshold = (int)(newCapacity * loadFactor);  
}

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

void transfer(Entry[] newTable) {     
    Entry[] src = table;  
    //src引用了旧的Entry数组  
    int newCapacity = newTable.length;   
    for (int j = 0; j < src.length; j++) {
        //遍历旧的Entry数组       
        Entry<K,V> e = src[j];    
        //取得旧Entry数组的每个元素      
        if (e != null) {       
            src[j] = null;
            //释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)       
            do {         
                Entry<K,V> next = e.next;   
                int i = indexFor(e.hash, newCapacity); 
                //重新计算每个元素在数组中的位置    
                e.next = newTable[i]; 
                //标记[1]          
                newTable[i] = e;  
                //将元素放在数组上   
                e = next;          
                //访问下一个Entry链上的元素     
            } while (e != null);      
        }   
    }
}

newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)。这是JDK7的版本。

那 JDK 8有哪些不同呢?

我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+原数组长度”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

注意,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,JDK1.8不会倒置。

4、HashMap 的线程安全性

再多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的呢?

  • JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap的transfer(),具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。
  • JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap的putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A在判断了hash的碰撞之后,插入之前,由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

具体的原因大家可以自己去看看源码,一切答案都在源码之中。

巨人的肩膀:

https://tech.meituan.com/2016/06/24/java-hashmap.html

https://snailclimb.gitee.io/javaguide/#/docs/java/collection/HashMap(JDK1.8)%E6%BA%90%E7%A0%81+%E5%BA%95%E5%B1%82%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E5%88%86%E6%9E%90


  目录