深入剖析 HashMap

导读

在这里插入图片描述

前言

HashMap 是 Java 程序员使用频率最高的用于键值对(映射)处理的数据类型,同时也是面试重点。随着 JDK(Java Developmet Kit)版本的更新,JDK1.8 对 HashMap 底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。本文结合源码深入探讨 HashMap 的结构实现和功能原理。

文章内容主要讲解四大重点:散列函数哈希冲突扩容方案线程安全,再补充关键的源码分析和相关的问题。

本文所有内容如若未特殊说明,均为 JDK1.8 版本。

什么是 HashMap?

在官方文档中是这样描述 HashMap 的:

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

几个关键的信息

  • 基于哈希表(也叫散列表)的 Map 接口实现;
  • 允许 null 键/值;
  • 非同步;
  • 不保证有序(比如插入的顺序);也不保证序不随时间变化。

简单地说, HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。HashMap 是无序的,即不会记录插入的顺序。HashMap 最多只允许一条记录的 key 为 null ,但允许多条记录的 value 为 null。 此外,HashMap 是 Map 的一个非同步的实现。

HashMap 本质上是一个散列表,那么就离不开散列表的三大问题:散列函数哈希冲突扩容方案;同时作为一个数据结构,必须考虑多线程并发访问的问题,也就是线程安全。这四大重点则为学习 HashMap 的重点,也是 HashMap 设计的重点。

下文我们主要结合源码,从存储结构、常用方法分析、扩容以及安全性等方面深入讲解 HashMap 的工作原理。

HashMap 内部实现

搞清楚 HashMap,首先需要知道 HashMap 是什么,即它的存储结构-字段;其次弄明白它能干什么,即它的功能实现-方法

下面我们针对这两个方面详细展开讲解。

存储结构-字段

从结构实现来讲,HashMap 是数组+链表+红黑树实现的(JDK1.8 增加了红黑树部分),每个数组空间采用 Node<key,value> 节点来保存键值对,在一定的条件下会采用 TreeNode<key,value> 保存数据,如下图所示。

在这里插入图片描述

这里需要讲明白两个问题:

  • 数据底层具体存储的是什么?
  • 这样的存储方式有什么优点呢?

我们知道,HashMap 中的元素都是 Key-Value 类型的,我们姑且将每一个元素称为一个节点Node。在 HashMap 中,所有的 Node 都是存在一个数组中,而这个数组的声明如下:

transient Node<K,V>[] table;

table 数组就是我们常说的哈希数组(也叫哈希表)。可以看到,这个数组的类型是 Node 类型。Node 是 HashMap 的一个内部类,实现了 Map.Entry 接口,本质是就是一个键值对。

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 就是使用哈希表来存储的。那当我们往 HashMap 中存入一个元素时,元素将如何存入这个数组中呢?

这时候就要提到的散列函数(也叫 hash 函数)了。当我们进行 put 操作时,HashMap 底层会调用 hash函数,计算出元素 key 的 hash 值,然后再用这个 hash 值与 HashMap 的总容量进行求余,得到的余数就是这个元素在数组中存放的下标。

既然如此,两个不同的元素,根据以上方法求出的下标值很有可能是相等,也就是出现 Hash 碰撞 的情况——这要如何解决呢?

Hash 碰撞

哈希表为解决冲突,可以采用开放地址法链地址法等来解决问题,Java 中 HashMap 采用了链地址法

链地址法,简单来说,就是数组+链表的结合。在每个数组元素上都一个链表结构,当数据被 hash 后,得到数组下标,把数据放在对应下标元素的链表上。

例如程序执行下面代码:

map.put("shawn","just do it");

系统将调用 shawn 这个 key 的 hashCode() 方法得到其 hashCode 值(该方法适用于每个 Java 对象),然后再通过 hash 算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个 key 会定位到相同的位置,表示发生了 Hash 碰撞

当然 Hash 算法计算结果越分散均匀,Hash 碰撞的概率就越小,map 的存取效率就会越高。

如果哈希桶数组很大,即使较差的 hash 算法也会比较分散,如果哈希桶数组很小,即使好的 hash 算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好 hash 算法减少 Hash 碰撞。

那么通过什么方式来控制 map 使得 Hash 碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?

答案就是好的 hash 算法和扩容机制。

在理解 hash 算法和扩容流程之前,我们得先了解下 HashMap 的几个字段。

几个重要参数

从 HashMap 的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:

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

首先,Node[] table 的初始化长度 length 默认值是 16;loadFactor 为负载因子(默认值是0.75); threshold 是 HashMap 所能容纳的最大数据量的 Node(键值对)个数,threshold = length * loadFactor

也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

结合负载因子的定义公式可知,threshold 就是在此 loadFactor 和 length(数组长度)对应下允许的最大元素数目,超过这个数目就重新 resize (扩容)。

一问:为什么负载因子默认为 0.75?

size 这个字段其实很好理解,就是 HashMap 中实际存在的键值对数量。注意和 table 的长度length、容纳最大键值对数量 threshold 的区别。

下面我们看功能的实现。

功能实现-方法

HashMap 的内部功能实现很多,本文主要从三个方面深入展开:

  • 哈希函数
  • put 方法的详细执行;
  • 扩容过程。

哈希函数

哈希函数的目标是计算 key 在数组中的下标。 不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。

下面来看看源码的实现。

static final int hash(Object key) {
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里的 hash 算法本质上就是两步:

  • 对 key 对象的 hashcode 进行扰动
  • 通过取模求得数组下标

通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16)

>>>: 无符号右移操作,它指的是 「无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0」 ,也就是不管是正数还是负数,右移都会在空缺位补 0 。

主要是从速度、功效、质量来考虑的,这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低位都参与到 Hash 的计算中,同时不会有太大的开销。

下面举例说明下,n 为 table 的长度。
在这里插入图片描述

这里的 (n-1)&hash 是为了计算出 HashMap 的存放位置,在 put 方法中会用到。

put 方法

public V put(K key, V value) {
    // 对key的hashCode()做hash
    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;

    // 步骤①:tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 步骤②:计算index,并对null做处理 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        
        // 步骤③:节点key存在,直接覆盖value
        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);
                    //链表长度大于8转换为红黑树进行处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // key已经存在直接覆盖value
                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;
}

大致步骤如下:

  1. 判断键值对数组 table 是否为空,为空则执行 resize() 进行扩容
    • HashMap 的懒加载策略,当执行 put 操作时检测 table 数组初始化;
  2. 根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向步骤 6,如果 table[i] 不为空,转向步骤 3 ;
  3. 判断 table[i] 的 key 是否和 要插入的 key 一样,如果相同直接覆盖 value,否则转向步骤 4,这里的相同指的是 hashCode 以及 equals;
  4. 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向步骤 5;
  5. 遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
  6. 插入成功后,判断实际存在的键值对数量 size 是否超多了扩容阈值 threshold,如果超过,进行扩容。

HashMap 的 put 方法执行过程可以通过下图来理解。

在这里插入图片描述

二问:为什么 HashMap 在链表元素数量超过 8 时候改为红黑树?

扩容机制

在 HashMap 中,「扩容阈值」大小为「桶数组长度」与「负载因子」的乘积。当 HashMap 中的键值对数量超过扩容阈值时,进行扩容。

HashMap 中的扩容机制是由 resize() 方法来实现的,下面我们就来认识下。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 记录Map当前的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //记录Map允许存储的元素数量,即阈值(容量*负载因子)
    int oldThr = threshold;
    //声明两个变量,用来记录新的容量和阈值
    int newCap, newThr = 0;

	//若当前容量不为0,表示存储数据的数组已经被初始化过
    if (oldCap > 0) {
		//判断当前容量是否超过了允许的最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 若超过最大容量,表示无法再进行扩容
            // 则更新当前的阈值为int的最大值,并返回旧数组
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //若扩容后的新容量(旧容量左移1位,相当于乘2)未超过最大值,
        //并且旧容量大于默认初始容量(16),将旧阈值乘2得到新阈值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 若不满足上面的oldCap > 0,表示数组还未初始化,
    // 若当前阈值不为0,就将数组的新容量记录为当前的阈值;
    // 为什么这里的oldThr在未初始化数组的时候就有值呢?
    // 这是因为HashMap有两个带参构造器,可以指定初始容量,
    // 若你调用了这两个可以指定初始容量的构造器,
    // 这两个构造器就会将阈值记录为第一个大于等于你指定容量,且满足2^n的数(可以看看这两个构造器)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    
    // 若上面的条件都不满足,表示你是调用默认构造器创建的HashMap,且还没有初始化table数组
    else {               // zero initial threshold signifies using defaults
        
        // 则将新容量更新为默认初始容量(10)
        // 阈值即为(容量*负载因子)
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 经过上面的步骤后,newCap一定有值,但是若运行的是上面的第二个分支时,newThr还是0
    // 所以若当前newThr还是0,则计算出它的值(容量*负载因子)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    //将计算出的新阈值更新到成员变量threshold上
    threshold = newThr;

    // 创建一个记录新数组用来存HashMap中的元素
    // 若数组不是第一次初始化,则这里就是创建了一个两倍大小的新数组
    @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;

            //若原数组的j位置有节点存在,才进一步操作
            if ((e = oldTab[j]) != null) {
                //清除旧数组对节点的引用,利于回收
                oldTab[j] = null;
                //如果他没有后继节点,那么直接使用新的数组长度取模得到新下标
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果桶中元素还有下一个元素,节点类型是TreeNode类型,那么按照TreeNode方式插入。这个比较复杂
                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 源码中把初始化操作也放到了扩容方法中,因而扩容方法源码主要分为两部分:

  • 确定新的数组大小;
  • 迁移数据。
确定新的数组大小

确定新的数组大小主要做了这几个事情。

(1)首先,判断 HashMap 中的数组的长度大于 0,也就是 (Node<K,V>[])oldTab.length() ,再判断数组的长度是否大于等于最大的的长度也就是 2^30 ,是的话阈值取最大长度 Integer.MAX_VALUE,并返回旧数组。

否则,利用位运算 << 将新容量和阈值扩容为原来的两倍。

在这里插入图片描述

(2)如果原数组长度为0 ,再判断扩容阈值 threshold 是否大于 0 ,若有指定则直接使用,对应的情况就是新建 HashMap 的时候指定了数组长度。

在这里插入图片描述
这里简单说明一下何时 oldThr > 0,因为 oldThr = threshold ,这里其实比较的就是 threshold,因为 HashMap 中的每个构造方法都会调用 HashMap(initCapacity,loadFactor) 这个构造方法,所以如果没有外部指定 initialCapacity,初始容量使用的就是 16,然后根据 this.threshold = tableSizeFor(initialCapacity); 求得 threshold 的值。

三问:tableSizeFor 方法的作用。
tableSizeFor 的功能(不考虑大于最大容量的情况)是返回大于输入参数且最近的 2 的整数次幂的数。比如 10,则返回 16。

(3)否则,直接使用默认的初始容量和扩容阈值,走 else 的逻辑是在 table 刚刚初始化的时候。
在这里插入图片描述
「在扩容后需要把节点放在新扩容的数组中,这里也涉及到三个步骤」。

  • 循环遍历原数组。判断表中当前位置的元素后面没有其他元素,直接计算新 index,插入新表中。
  • 如果是树形结构则按照树形结构进行拆分,拆分方法在 split 方法中。
  • 如果不是树形结构,则遍历链表,并将链表节点按原顺序进行分组。

线程安全

HashMap 作为一个集合,主要功能则为 CRUD,也就是增删查改数据,那么就肯定涉及到多线程并发访问数据的情况。并发产生的问题,需要我们特别关注。

HashMap 并不是线程安全的,在多线程的情况下无法保证数据的一致性。

举个例子:

  • HashMap 下标 1 的位置为 null,线程 A 需要将节点 X 插入下标 1 的位置;
  • 在判断是否为 null 之后,线程 A 被挂起;
  • 此时线程 B 把新的节点 Y 插入到下标 1 的位置;
  • 恢复线程 A,节点 X 会直接插入到下标 2,覆盖节点 Y,导致数据丢失。

如下图:

在这里插入图片描述

在多线程使用场景中,应该尽量避免使用线程不安全的 HashMap,而使用线程安全的 ConcurrentHashMap。

总结

HashMap 是一个非常重要的集合,日常使用也非常的频繁,同时也是面试重点。

HashMap 本质是一个散列表,具有以下几个特点:

  • 基于哈希表的 Map 接口实现;
  • 允许 null 键或值;
  • 非同步;
  • 不保证有序(比如插入的顺序);也不保证序不随时间变化。

HashMap 的数据结构是哈希表结构(数组+链表),当链表长度超过 8 时,链表转换为红黑树。

为什么使用数组+链表?

  • 数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到。
  • 链表是用来解决 hash 冲突问题,当出现 hash 值一样的情形,就在数组上的对应位置形成一条链表。这里的 hash 值并不是指 hashcode,而是将 hashcode 高低十六位异或过的。

这种通过链表解决 hash 冲突的方法叫「链地址法」,其他的还有:开放定址法、再哈希法也可以解决 hash 冲突问题。

关于红黑树的转化,HashMap 做了以下限制:

  • 链表的长度>=8数组长度>=64时,会把链表转化成红黑树。
  • 链表长度>=8,但数组长度<64时,会优先进行扩容,而不是转化成红黑树。
  • 红黑树节点数<=6,自动转化成链表。

同时我们针对 HashMap 的 put 方法和扩容方法做了详细的分析。最后也简单介绍了 HashMap的线程安全问题。注意在多线程使用场景中,避免使用 HashMap。

最后

关于 HashMap 的内容很难在一篇文章讲完,涉及到的内容非常多,比如:为什么 HashMap 是线程不安全的?以及在文章中的一些问题,这些内容我将在另外的文章做补充。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值