Java之深入理解HashMap

Java之深入理解HashMap

引言

HashMap是Java程序员使用频率最高的一种映射(<Key,Value>键值对)数据结构,它继承自AbstractMap,又实现了Map类。

image-20241130192859341

本文将深入源码解析一下HashMap的底层原理。

数据结构

HashMap底层通过维护一个table数组来实现:

transient Node<K,V>[] table;

table数组里面的元素是一个Node类,用于存储每个键值对。

Node类是HashMap的一个静态内部类:

    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;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

可以看到,在Node类中维护了每个键值对的哈希值:hash,Key值:key,Value值:value,以及用于维护链表或者红黑树(JDK1.8)的下一个Node:next。

在理想情况下(没有哈希冲突)HashMap的操作时间复杂度可以达到O(1)。为了解决哈希冲突(也就是哈希值重复)的问题,HashMap在table数组中将哈希冲突的Node结点维护成链表或者红黑树。在JDK1.8前(不含JDK1.8),HashMap的数据结构是数组+链表,在JDK1.8及以后优化为数组+链表+红黑树。

image-20241130201458030

图中红黑树并不规范,仅作参考。一般情况下当链表长度大于8后才会转换为红黑树以提高查询效率。

Hash方法

当我们使用put方法向HashMap中加入一个键值对时:

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

在put方法内调用了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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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;
                    }
                    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;
    }

image-20241130202729239

可以看到,在putVal方法中会根据hash(key)生成的hash值来计算Node在table数组中的位置索引。

也就是通过(n - 1) & hash来计算Node的位置索引,hash就是通过hash方法算出的哈希值,n就是table数组的长度(元素个数)。

那么为什么通过(n - 1) & hash来计算Node的索引呢?

我们知道,对于一个数集[0,1,2,…,k],如果想要让一个非负数数据集B内的数据全部映射到A内,可以通过取余运算来完成:B[i] % (k+1)

此时,如果(k+1)为2的n次方,那么B[i] % (k+1)=B[i] & k。

也就是说,如果table的长度n为2的某个次方,那么(n - 1) & hash等价于hash%n,又因为对于二进制的计算机来说,位运算是比取余运算更快的,所以这里巧妙的采用(n - 1) & hash来计算Node的索引。

我们知道了hash值的作用,是用来确定Node在table数组中的位置的,接下来让我们看看hash方法是怎么算出hash值的:

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

可以看到,如果key值不为空的话,将会通过key的哈希码和key的哈希码逻辑右移16位后的值进行异或运算:(h = key.hashCode()) ^ (h >>> 16)

哈希码是通过key对象本身的Object.hashCode()方法得到的。

对于将key的哈希码逻辑右移16位,我们可以看到h也就是key的哈希码是int类型的,占32位,逻辑右移16位后得到其高16位。也就是将key哈希码的高16位与低16位进行异或运算,作用是使hash值更均匀分散,降低哈希冲突率。

此外,当两个数据哈希冲突时,会根据equals方法来判断,如果相等,则直接覆盖原数据,如果不等,则维护成链表或红黑树(JDK1.8及以后)。

  public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

扩容机制

我们在构建HashMap时,可以通过构造函数传参来自定义初始容量和负载因子:

public HashMap(int initialCapacity, float loadFactor){...}

HashMap的最大容量是2的30次方:

static final int MAXIMUM_CAPACITY = 1 << 30;

HashMap给的默认初始容量是16:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

那么,什么时候需要扩容呢?

HashMap会在容量大于阈值时触发扩容机制,这个阈值是由容量与负载因子的乘积求得的,负载因子loadFactor的默认值为0.75:

static final float DEFAULT_LOAD_FACTOR = 0.75f

那么,为什么加载因子是0.75呢?

加载因子是用来表示HashMap中数据的填满程度的,也就是说,加载因子=HashMap中数据的个数/HashMap的容量,根据这个公式,可得:

  • 加载因子越大,空间利用率就越高,但是哈希冲突率也会越高
  • 加载因子越小,哈希冲突率就越低,但是空间利用率也会越低

鱼与熊掌不可兼得!这就必须要求我们取一个中庸之值,至于如何取得0.75这个值,这里面涉及了泊松分布和指数分布等统计与概率学的知识,大家感兴趣自行研究!

如果HashMap的数据量超过了阈值,就会调用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;
            }
            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;
    }

扩容机制会有以下几个主要流程:

  • 如果已经超过了HashMap最大容量MAXIMUM_CAPACITY,就不再扩容,将阈值设为Integer.MAX_VALUE(2的31次方减1),直接返回。

  • 将数组table容量扩大两倍,由于Java没有动态数组,需要新创建一个两倍大小的数组,然后再将原数组中的数据都移至新数组。

  • 将数据从原数组移至新数组时,在JDK1.8之前需要重新计算hash值,重新确定数组索引位置;在JDK1.8及以后,为了使数据更分散,对于链表数据的索引做了优化:我们通过(n - 1) & hash来计算索引位置,由于数组长度n是2倍扩展,所以新的(n-1)比旧的(n-1)只是高位多了一个1,结果就是:如果hash不变的情况下,

    • 若hash多出来的高位是1,那么计算出来的新索引刚好就是原索引值+原数组长度
    • 若hash多出来的高位是0,那么索引不变。

    优化以后,转移链表数据至新数组时,就不用再重新计算hash值,提高了效率。

  • 对于红黑树,会通过split方法,将其拆分为新树或链表:

    else if (e instanceof TreeNode)
       ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
    
    final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
                TreeNode<K,V> b = this;
                // Relink into lo and hi lists, preserving order
                TreeNode<K,V> loHead = null, loTail = null;
                TreeNode<K,V> hiHead = null, hiTail = null;
                int lc = 0, hc = 0;
                for (TreeNode<K,V> e = b, next; e != null; e = next) {
                    next = (TreeNode<K,V>)e.next;
                    e.next = null;
                    if ((e.hash & bit) == 0) {
                        if ((e.prev = loTail) == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                        ++lc;
                    }
                    else {
                        if ((e.prev = hiTail) == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                        ++hc;
                    }
                }
    
                if (loHead != null) {
                    if (lc <= UNTREEIFY_THRESHOLD)
                        tab[index] = loHead.untreeify(map);
                    else {
                        tab[index] = loHead;
                        if (hiHead != null) // (else is already treeified)
                            loHead.treeify(tab);
                    }
                }
                if (hiHead != null) {
                    if (hc <= UNTREEIFY_THRESHOLD)
                        tab[index + bit] = hiHead.untreeify(map);
                    else {
                        tab[index + bit] = hiHead;
                        if (loHead != null)
                            hiHead.treeify(tab);
                    }
                }
            }
    

多线程

JDK1.7及其之前,多线程的情况下,在扩容时,链表在从旧数组转移到新数组时可能会产生死循环的问题(转移链表时采用的是单指针头插法):

image-20241201210108068

对于链表A->B->C:

线程P1刚要开始迁移,让指针p指向A,此时,线程P1的时间片用完,CPU调度线程P2开始执行。

线程P2完整执行了整个链表迁移工作,使链表变成:

image-20241201210057626

此时,CPU调度线程P1继续执行:

p.next = newTable[i];  // A.next = C
newTable[i] = p;   
p = next;  

由于新链表头结点已经由null转变成了C,导致A与C结点构成了循环:

image-20241201205909992

JDK1.8及其之后,虽然通过双指针尾插法消除了这个问题,但依然存在其它线程安全问题,在多线程的环境下,我们应该使用线程安全的ConcurrentHashMap类。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/927266.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

HTTP 探秘之旅:从入门到未来

文章目录 导言&#xff1a;目录&#xff1a;第一篇&#xff1a;HTTP&#xff0c;互联网的“快递员”第二篇&#xff1a;从点开网页到看到内容&#xff0c;HTTP 究竟做了什么&#xff1f;第三篇&#xff1a;HTTP 的烦恼与进化史第四篇&#xff1a;HTTP 的铠甲——HTTPS 的故事第…

Docker 容器网络创建网桥链接

一、网络&#xff1a;默认情况下&#xff0c;所有的容器都以bridge方式链接到docker的一个虚拟网桥上&#xff1b; 注意&#xff1a;“172.17.0.0/16”中的“/16”表示子网掩码的长度为16位&#xff0c;它表示子网掩码中有16个连续的1&#xff0c;后面跟着16个连续的0。用于区分…

springboot366高校物品捐赠管理系统(论文+源码)_kaic

毕 业 设 计&#xff08;论 文&#xff09; 高校物品捐赠管理系统设计与实现 摘 要 传统办法管理信息首先需要花费的时间比较多&#xff0c;其次数据出错率比较高&#xff0c;而且对错误的数据进行更改也比较困难&#xff0c;最后&#xff0c;检索数据费事费力。因此&#xff…

upload-labs 靶场(11~21)

免责声明 本博客文章仅供教育和研究目的使用。本文中提到的所有信息和技术均基于公开来源和合法获取的知识。本文不鼓励或支持任何非法活动&#xff0c;包括但不限于未经授权访问计算机系统、网络或数据。 作者对于读者使用本文中的信息所导致的任何直接或间接后果不承担任何…

jenkins+github+springboot自动部署

背景&#xff1a; 最近看流水线有点意思&#xff0c;就说自己也搞一套。 预期效果&#xff1a; idea提交代码后&#xff0c;GitHub接收&#xff0c;jenkins自动部署。【后续加个自动部署时的代码检查、单元测试、安全测试、sonarqube】 思路分析: idea上的spring代码push到gi…

kafka数据在服务端时怎么写入的

学习背景 接着上篇&#xff0c;我们来聊聊kafka数据在服务端怎么写入的 服务端写入 在介绍服务端的写流程之前&#xff0c;我们先要理解服务端的几个角色之间的关系。 假设我们有一个由3个broker组成的kafka集群&#xff0c;我们在这个集群上创建一个topic叫做shitu-topic&…

Springboot——SseEmitter流式输出

文章目录 前言SseEmitter 简介测试demo注意点异常一 ResponseBodyEmitter is already set complete 前言 最近做AI类的开发&#xff0c;看到各大AI模型的输出方式都是采取的一种EventStream的方式实现。 不是通常的等接口处理完成后&#xff0c;一次性返回。 而是片段式的处理…

【深度学习|目标跟踪】StrongSORT 详解(以及StrongSORT++)

StrongSort详解 1、论文及源码2、DeepSORT回顾3、StrongSORT的EMA4、StrongSORT的NSA Kalman5、StrongSORT的MC6、StrongSORT的BOT特征提取器7、StrongSORT的AFLink8、StrongSORT的GSI模块 1、论文及源码 论文地址&#xff1a;https://arxiv.org/pdf/2202.13514 源码地址&#…

AntFlow 0.20.0版发布,增加多数据源多租户支持,进一步助力企业信息化,SAAS化

传统老牌工作流引擎比如activiti,flowable或者camunda等虽然功能强大&#xff0c;也被企业广泛采用&#xff0c;然后也存着在诸如学习曲线陡峭&#xff0c;上手难度大&#xff0c;流程设计操作需要专业人员&#xff0c;普通人无从下手等问题。。。引入工作流引擎往往需要企业储…

【设计模式系列】工厂方法模式(二十一)

一、什么是工厂方法模式 工厂方法模式&#xff08;Factory Method Pattern&#xff09;是一种创建型设计模式&#xff0c;其核心目的是定义一个创建对象的接口&#xff0c;但让实现这个接口的子类来决定实例化哪一个类。工厂方法模式让类的实例化推迟到子类中进行&#xff0c;…

HCIE:详解OSPF,从基础到高级特性再到深入研究

目录 前言 一、OSPF协议基本原理 简介 基本原理 OSPF路由器类型 OSPF网络类型 OSPF报文类型和封装 OSPF邻居的建立的维护 DR和BDR的选举 伪节点 LSDB的更新 OSPF的配置 二、OSPF的高级特性 虚连接&#xff08;Virtual-Link&#xff09; OSPF的LSA和路由选择 OSPF…

分享一款 Vue 图片编辑插件 (推荐)

&#x1f4a5;本篇文章给大家分享一款强大到没朋友的Vue图片编辑插件&#xff0c;可以对图片进行旋转、缩放、裁剪、涂鸦、标注、添加文本等&#xff0c;快来试试并收藏吧&#xff01;&#x1f495; 这是一款对图片进行旋转、缩放、裁剪、涂鸦、标注、添加文本在线处理的图片处…

【时间之外】IT人求职和创业应知【53】-东莞也转型

目录 新闻一&#xff1a;Freysa挑战赛&#xff1a;人类智慧与策略战胜AI&#xff0c;奖金高达4.7万美元 新闻二&#xff1a;中国生成式AI用户规模突破2.3亿&#xff0c;行业应用广泛 新闻三&#xff1a;2024东莞智能终端新技术推广会圆满举行&#xff0c;聚焦AI与智能终端融…

大模型在推荐系统中的应用

引言 推荐系统在现代互联网应用中扮演着至关重要的角色。从电商到社交媒体&#xff0c;再到音乐与视频流媒体服务&#xff0c;推荐系统通过分析用户行为数据和内容特征&#xff0c;为用户提供个性化服务。近年来&#xff0c;大模型&#xff08;Large Language Models, LLMs&…

Electron + vue3 打包之后不能跳转路由

路由不跳转问题原因&#xff1a; 是因为electron需要将vue-router的mode调整为hash模式(两种写法) export default new Router({mode: hash, //这里history修改为hashscrollBehavior: () > ({y: 0}),routes: constantRouterMap, }) export default new createRouter({his…

Linux网络_网络协议_网络传输_网络字节序

一.协议 1.概念 协议&#xff08;Protocol&#xff09; 是一组规则和约定&#xff0c;用于定义计算机网络中不同设备之间如何进行通信和数据交换。协议规定了数据的格式、传输方式、传输顺序等详细规则&#xff0c;确保不同设备和系统能够有效地互联互通。 在网络通信中&#…

Unity3D UI 嵌套滚动视图

Unity3D 解决 UI 嵌套滚动视图滑动问题。 嵌套滚动视图 滑动问题 在游戏开发中&#xff0c;我们常常会遇到一种情况&#xff0c;在一个滚动视图列表中&#xff0c;每个 item 还包含了一个内嵌的滚动视图。 这样&#xff0c;当我们在滑动外层的滚动视图时&#xff0c;如果点…

QT6学习第五天 第一个QT Quick程序

QT6学习第五天 第一个QT Quick程序 概述创建Qt Quick程序使用Qt资源文件程序发布 概述 如果将程序的用户界面成为前端&#xff0c;程序的数据存储和逻辑业务成为后端&#xff0c;那么传统QT Widgets程序的前后端都是用C完成的。对于现代软件开发而言&#xff0c;前端演化速度远…

【C++】单目操作符详解:前置与后置自增自减及正负号操作

博客主页&#xff1a; [小ᶻ☡꙳ᵃⁱᵍᶜ꙳] 本文专栏: C 文章目录 &#x1f4af;前言&#x1f4af;单目操作符概述1 自增与自减操作符&#xff1a; 和 --2 前置 和 后置 案例 1&#xff1a;前置 案例 2&#xff1a;后置 小技巧 3 前置 -- 和 后置 --案例 1&#xff1a;前…

SAP SD学习笔记15 - 投诉处理2 - 返品处理流程之 参照请求传票(发票)来生成返品传票

上一章讲了返品处理&#xff08;退货处理&#xff09;的流程。 SAP SD学习笔记14 - 投诉处理1 - 返品处理&#xff08;退货处理&#xff09;的流程以及系统实操&#xff0c;比如 返品传票&#xff1b;请求Block标记&#xff1b;收到退货之后的处理&#xff0c;请求传票的登录_…