字节跳动面试官:说说HashMap 的设计与优化?

本文详细分析了HashMap在JDK1.8中的实现和优化,包括初始化、put方法、节点创建、哈希计算、红黑树转换等关键步骤。着重探讨了哈希冲突解决、树化阈值、线程安全性以及HashMap的使用注意事项。通过源码阅读,揭示了HashMap如何提高存储和查找效率。
摘要由CSDN通过智能技术生成

hashmap 是一个 key-value 形式的键值对集合。(本文内容基于 JDK1.8)下面是一个简单的 hashmap 的结构。 本文主要是通过源码的方式分析 HashMap 的实现和优化。主要是围绕源码本身展开,以添加注释的方式进行记录和分析

初始化

在创建 HashMap 对象示例的时候不会初始化存储数组,会在首次调用 put 方法的时候初始化数组。构造方法如下:

public HashMap(int initialCapacity, float loadFactor) {
    // initialCapacity 初始容量 < 0 报错; 默认 16
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // initialCapacity 初始容量是否大于最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // loadFactor 负载因子 <= 0 || isNaN ; 默认0.75
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

put 方法

添加数据通常我们采用 put 方法,该方法也是我们开发中比较常用的方法之一。最终会调用 putVal 方法进行初始化和添加数据。在这个方法中我们需要注意的有几个地方:

  1. 如果没有初始化会调用 resize() 方法进行 hashmap 存储数组的初始化。
  2. 默认通过 & 运算计算节点存储位置,这里也证明了为什么初始化数组的长度要是 2 的 n 次方
  3. 如果不存在 hash 冲突的情况下,通过然后调用 newNode 方法创建节点,存放到对应的数组下标。
  4. 如果存在 hsah 冲突的情况下。这里就会有三种情况:
  • 首次 hash 冲突的情况下,当前节点是一个普通的节点,如果 key 相同得话,将采取数据覆盖的方式;
  • 如果当前节点类型是 treeNode 类型,是一棵红黑树。将调用 putTreeVal 方法来进行添加子节点;
  • 最后,将当作链表处理,首先查找链表的尾节点,找到尾节点后,将当前节点添加到尾节点,这里有一个判断如果当前链表的节点数 > 8 并且 hashmap 的总长度 > 64 就会将当前的链表进行变换为红黑树。还有一种特殊情况,如果在链表的查找过程中查找到了一个当前新增key 相同的节点,那么就会覆盖当前节点数据并且退出循环;
  1. 前面所有的步骤都是为了找到当前的节点指针,然后再通过当前对象修改 value 值, 这里有一个需要注意的地方,在修改的时候会做一个判断如果 **_onlyIfAbsent_** 等于 false 才可以修改,就是说可能当前 hashmap 存在不可以被修改的情况,比如:map.putIfAbsent 方法的时候调用就会修改失败,最后才能修改 value 值,并且返回旧值。
  2. 最后对修改次数累加,然后判断一次是否需要拓展 hashmap 的容量,然后返回 null , 方法结束。
// put 方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

// 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;
    // 如果没有初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        // 调用 resize 初始化
        // n = tab.length 容量
        n = (tab = resize()).length;
    // 默认通过 & 运算计算节点存储位置,这里也证明了为什么初始化数组的长度要是2的n 次方
    // 并且把当前节点的数据给 p
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 如果节点数据已经存在,即存在 hash 冲突的情况
        Node<K,V> e; K k;
        // 1. 当前节点存在,并且插入k,和存在的 k 的value 值相同,那么直接刷新当前节点数据即可
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 新的节点数据 = p, 其实这里也只是获取 p 的指针
            e = p;
        // 2. 如果是 TreeNode 结构, 即红黑树结构
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 3. 其他情况,即链表结构
            for (int binCount = 0; ; ++binCount) {
                // 父节点子节点为 null
                if ((e = p.next) == null) {
                    // 将 p.next = newNode
                    p.next = newNode(hash, key, value, null);
                    // 节点数是否大于变形的阈值 (TREEIFY_THRESHOLD = 8)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 如果 tab.length < 64 默认拓容
                        // 否则进行红黑树转换
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果存在值相同的情况
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果 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;
}

总结:其实通过上面的分析和代码的,我们分析出有一下几个核心方法:

  • newNode 创建 Node 节点
  • ((TreeNode<K,V>)p).putTreeVal(**this**, tab, hash, key, value);添加节点信息;
  • treeifyBin 节点冲突情况下,链表转换为红黑树;
  • resize() HashMap 拓容;

newNode 创建节点

创建 HashMap 的节点信息,其实这个方法看上去比较普通,但是本质上也是比较普通。但是对于 hash 这个参数我们可以思考一下。

Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

hash 计算 hash 码

hash 方法如下,

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

理论上 hash 散列是一个 int 值,如果直接拿出来作为下标访问 hashmap 的话,考虑到二进制 32 位,取值范围在**-2147483648 ~ 2147483647** 范围。 大概有 40 亿个 key , 只要哈希函数映射比较均匀松散,一般很难出现碰撞。 存在一个问题是 40 亿长度的数组,内存是不能放下的。因为咱们 HashMap 的默认长度为 16 。所以这个 hashCode , (key.hashCode ) 是不能直接来使用的。使用之前先做对数组长度的与运算,得到的值才能用来访问数组下标。 代码如下:

// n = hashmap 的长度
p = tab[i = (n - 1) & hash]) 

这里为什么要使用 n -1 ,来进行与运算,这里详单与是一个”低位掩码”, 以默认长度 16 为例子。 和某个数进行与预算,结果的大小是 < 16 的。如下所示:

    10000000 00100000 00001001
&   00000000 00000000 00001111
------------------------------
    00000000 00000000 00001001  // 高位全部归 0, 只保留后四位 

这个时候会有一个问题,如果本身的散列值分布松散,只要是取后面几位的话,碰撞也会非常严重。还有如果散列本省做得不好的话,分布上成等差数列的漏洞,可能出现最后几位出现规律性的重复。 这个时候“扰动函数”的价值制就体现出来了。如下所示:

函数中有这样的一段代码: (h = key.hashCode()) ^ (h >>> 16) 右位移 16 位, 正好是32bit 的一半,与自己的高半区做成异或,就是为了**混合原始的哈希码的高位和低位,以此来加大低位的随机性。**并且混合后的低位掺杂了高位的部分特征,这样高位的信息变相保存下来。其实按照开发经验来说绝大多数情况使用的时候 hashmap 的长度不会超过 1000,所以提升低位的随机性可以提升可以减少hash 冲突,提升程序性能。

Node.putTreeVal

当前是一棵红黑树那么就需要添加节点

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
 
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值