HashMap 的设计与优化(上)

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

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


image.png


初始化


在创建 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 存储数组的初始化。


  1. 默认通过 & 运算计算节点存储位置,这里也证明了为什么初始化数组的长度要是 2 的 n 次方


  1. 如果不存在 hash 冲突的情况下,通过然后调用 newNode 方法创建节点,存放到对应的数组下标。


  1. 如果存在 hsah 冲突的情况下。这里就会有三种情况:


  • 首次 hash 冲突的情况下,当前节点是一个普通的节点,如果 key 相同得话,将采取数据覆盖的方式;


  • 如果当前节点类型是 treeNode 类型,是一棵红黑树。将调用 putTreeVal 方法来进行添加子节点;


  • 最后,将当作链表处理,首先查找链表的尾节点,找到尾节点后,将当前节点添加到尾节点,这里有一个判断如果当前链表的节点数 > 8 并且 hashmap 的总长度 > 64 就会将当前的链表进行变换为红黑树。还有一种特殊情况,如果在链表的查找过程中查找到了一个当前新增key 相同的节点,那么就会覆盖当前节点数据并且退出循环;


  1. 前面所有的步骤都是为了找到当前的节点指针,然后再通过当前对象修改 value  值, 这里有一个需要注意的地方,在修改的时候会做一个判断如果 **_onlyIfAbsent_** 等于 false 才可以修改,就是说可能当前 hashmap 存在不可以被修改的情况,比如:map.putIfAbsent 方法的时候调用就会修改失败,最后才能修改 value 值,并且返回旧值。


  1. 最后对修改次数累加,然后判断一次是否需要拓展 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, 只保留后四位 


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


image.png


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


如果感兴趣的小伙伴可以浏览下一下 Peter Lawlay 的专栏《An introduction to optimising a hashing strategy》



相关文章
|
6月前
|
存储 安全 算法
【JAVA】HashMap扩容性能影响及优化策略
【JAVA】HashMap扩容性能影响及优化策略
|
3月前
|
存储 开发者 C#
WPF与邮件发送:教你如何在Windows Presentation Foundation应用中无缝集成电子邮件功能——从界面设计到代码实现,全面解析邮件发送的每一个细节密武器!
【8月更文挑战第31天】本文探讨了如何在Windows Presentation Foundation(WPF)应用中集成电子邮件发送功能,详细介绍了从创建WPF项目到设计用户界面的全过程,并通过具体示例代码展示了如何使用`System.Net.Mail`命名空间中的`SmtpClient`和`MailMessage`类来实现邮件发送逻辑。文章还强调了安全性和错误处理的重要性,提供了实用的异常捕获代码片段,旨在帮助WPF开发者更好地掌握邮件发送技术,提升应用程序的功能性与用户体验。
65 0
|
3月前
|
存储 Java 开发者
揭秘!HashMap底层结构大起底:从数组到链表,再到红黑树,Java性能优化的秘密武器!
【8月更文挑战第24天】HashMap是Java集合框架中的核心组件,以其高效的键值对存储和快速访问能力广受开发者欢迎。在JDK 1.8及以后版本中,HashMap采用了数组+链表+红黑树的混合结构,实现了高性能的同时解决了哈希冲突问题。数组作为基石确保了快速定位;链表则用于处理哈希冲突;而当链表长度达到一定阈值时,通过转换为红黑树进一步提升性能。此外,HashMap还具备动态扩容机制,当负载因子超过预设值时自动扩大容量并重新哈希,确保整体性能。通过对HashMap底层结构的深入了解,我们可以更好地利用其优势解决实际开发中的问题。
108 0
|
5月前
|
存储 安全 Java
深入解析Java HashMap的高性能扩容机制与树化优化
深入解析Java HashMap的高性能扩容机制与树化优化
148 1
|
5月前
|
存储 算法 Java
Java性能优化(三):Java基础-HashMap的设计与优化
HashMap核心特性数据结构:HashMap采用哈希表数据结构来存储键值对,利用哈希函数和哈希表快速定位元素位置,提供高效的键值对查询。参数设置初始容量:HashMap允许用户根据使用场景设定初始容量,以优化性能。在预知数据量时,可以通过计算(初始容量=预知数据量/加载因子)来设定合适的初始容量,以减少扩容操作,提高效率。加载因子:加载因子定义了哈希表何时进行扩容的阈值。加载因子较小时,哈希表会更早地进行扩容,减少哈希冲突;加载因子较大时,会提高内存利用率但可能增加哈希冲突。
291 2
|
4月前
|
存储 安全 算法
如何优化Java中的HashMap性能?
如何优化Java中的HashMap性能?
|
设计模式 算法 Java
面试官:JDK1.8 HashMap扩容rehash算法是如何优化的?
本文跟大家聊一聊一个常见的面试题,那就是JDK1.8 HashMap扩容rehash算法是如何优化的?
|
6月前
|
存储 缓存 安全
Java HashMap:哈希表原理、性能与优化
Java HashMap:哈希表原理、性能与优化
282 1
|
存储 数据采集 Java
从数据库中提取大量数据到 HashMap 集合中,优化方案有以下几点:
从数据库中提取大量数据到 HashMap 集合中,优化方案有以下几点:
254 0
【JavaP6大纲】Java基础篇:为什么jdk8以后HashMap会使用红黑树优化?
【JavaP6大纲】Java基础篇:为什么jdk8以后HashMap会使用红黑树优化?
112 0