Java中HashMap原理与分析

2023-11-08

HashMap的底层数据结构

HashMap是以Key-Value的方式进行数据结构存储的一种数据结构。
JDK1.7采用的是数组+链表,使用Entry类存储key和value
JDK1.8采用的是数组+链表/红黑树,使用Node类存储key和value。

HashMap扩容为什么总是2的次幂

HashMap扩容主要是给数组扩容的,因为数组长度不可变,而链表长度是可变长度。在HashMap的源码中可以看到HashMap在扩容时选择了位运算,向集合中添加元素时,会使用(n-1)&hash的计算方法来得出该元素在集合中的位置。只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)中的n是容量,(n-1)的2进制也就是11111***1111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。

HashMap扩容死循环问题

JDK1.7中HashMap采用头插法拉链表,所谓头插法,也就是说新插入的数据会从链表的头节点进行插入。
在这里插入图片描述

HashMap正常情况下的扩容就是这样一个过程,旧HashMap的节点会依次转移到新的HashMap中,旧HashMap转移链表元素的顺序是A,B,C,而新HashMap使用的是头插法插入,所以扩容完成后最终在新HashMap中链表元素的顺序是C,B,A。
导致死循环的原因:
在这里插入图片描述

第一步:线程启动,有线程T1和线程T2都准备对HashMap进行扩容操作,此时T1和T2执行都是链表头节点A,而T1和T2的下一个节点分别是T1.next和T2.next,它们都是执行B节点。
在这里插入图片描述
第二步:开始扩容,这时候,假设线程T2的时间片用完,进入休眠状态,而线程T1开始执行扩容操作,一直到线程T1扩容完成后,线程T2才被唤醒。
在这里插入图片描述
T1完成扩容之后
在这里插入图片描述
因为HashMap扩容采用的是头插法,线程T1执行之后,链表中的节点顺序发生了改变。但线程T2对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2指向的是A节点,T2.next指向的是B节点。
在这里插入图片描述
当线程T1执行完成之后,线程T2恢复执行时,死循环就发生了。
在这里插入图片描述
因为T1执行完扩容之后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之前的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成了死循环。
3、解决方案
避免HashMap发生死循环的常用解决方案有三个:
1)、使用线程安全的ConcurrentHashMap替代HashMap,个人推荐使用此方案。

2)、使用线程安全的容器Hashtable替代,但它性能较低,不建议使用。

3)、使用synchronized或Lock加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。

由链表到红黑树的转变

为了解决JDK1.7中的死循环问题,在JDK1.8中新增了红黑树。
当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。

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

这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。

    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

HashMap的put方法和get方法

put方法中,在存储k-v键值对的时候,我们首先会调用一个hash方法,然后通过这个方法,可以计算出Key的Hash值,从而得到一个10进制的数字,用这个数字和数组的长度减一去取模,就可以得到一个结果,也就是数组的下标,然后我们根据这个下标去找到数组中存储的这个单向链表,然后把链表中的每一个Key和要插入的Key进行一个equals()的比较,如果是相等的话,就直接更新这个value值,也就是覆盖,如果不相等的话把新的k-v值put()到这个链表中,在Put的过程中,我们当哈希表中存储键值对超过数组长度乘以负载因子的时候,就会对这个数组扩容为两倍,还有就是在插入链表的时候,如果链表长度超过了我们默认阈值为8的时候,结点的数据结构就会自动转化为一个红黑树的结构。
get()方法中,首先会先去调用hash方法,然后对key进行计算,用这个数字和数组的长度减一取模,也就是数组的下标,然后我们再遍历这个下标对应的链表元素,再进行equals的比较,如果key相同的话就把这个元素取回并返回给用户。

HashMap扩容机制

不管是JDK1.7或JDK1.8当put方法执行的时候,如果table为空,则执行resize()方法扩容。默认长度是16。
阈值:threshold = 容量 * 负载因子
默认情况下,HashMap的负载因子是0.75。因此,threshold的值等于容量的75%。

例如,如果HashMap的容量是16,那么threshold的计算结果为:
threshold = 16 * 0.75 = 12
JDK1.7扩容必须同时满足两点:

JDK1.7 是先扩容,在添加。具体put是否扩容需要两个条件:

  1. 存放新值的时候当前已有元素的个数必须大于等于阈值
  2. 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)

扩容方法是在addEntry方法中

void addEntry(int hash, K key, V value, int bucketIndex) {
    //1、判断当前个数是否大于等于阈值
    //2、当前存放是否发生哈希碰撞
    //如果上面两个条件否发生,那么就扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
      //扩容,并且把原来数组中的元素重新放到新数组中
      resize(2 * table.length);
      hash = (null != key) ? hash(key) : 0;
      bucketIndex = indexFor(hash, table.length);
    }
 
    createEntry(hash, key, value, bucketIndex);
  }

扩容使用的是头插法
扩容之后对table的调整
table容量变为2倍,所有的元素下标需要重写计算,newIndex=hash&(newLength-1)

JDK1.8 HashMap两种扩容的情况。

  1. 当map实际数量等于threshold容量的阈值时,会进行两倍扩容。
    在这里插入图片描述

  2. 当map中数组中某个桶的链表长度大于树形化阈值TREEIFY_THRESHOLD=8时,
    并且map元素的数量小于树形化最小容量MIN_TREEIFY_CAPACITY=64时候,容量进行两倍扩容。
    在这里插入图片描述

否则树形化阈值8并且map元素个数大于64时,进行链表转红黑树。

扩容总结:
jdk1.7的时候先检查是否需要进行扩容,再插入数据
当满足两个条件,存放新的元素时,已经存在的元素个数必须大于阈值。存放新的元素时,与已经存在的元素发生hash碰撞(新元素key计算hash值换算出来的数组下标的位置上已经存在元素),满足这两个条件就会进行扩容。扩容后数据会根据hash值重新计算索引的位置,然后将数据存放到对应的位置上。
jdk1.8先插入数据,再检查是否需要扩容。当map实际数量等于阈值时和链表元素大于等于8,数组容量小于64时,会进行两倍扩容。当链表元素大于等于8,并且数组的容量达到64时,则将链表结构转换为红黑树结构。当我们在删除元素后,当红黑树中的节点小于等于6时,则红黑树结构转换为链表结构。

HashMap 在扩容时为什么通过位运算 (e.hash & oldCap) 得到下标?

举个例子,假设table原长度是16(由于源码中for循环时table长度从0开始所以-1),扩容后长度是32,那么一个hash值在扩容前后的table下标是怎么计算的:
在这里插入图片描述
hash值的每个二进制位用abcde来表示,那么hash和新旧table按位与的结果,最后4位显然是相同的,唯一可能出现的区别就是第5位,也就是hash值的b所在的那一位,如果b所在的那一位是0,那么新table按位与的结果和旧table结果相同,反之如果b所在那一位是1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制就是旧table的长度
换言之,hash值的新散列下标是不是需要加上旧table长度,只需要看看hash值第5位是不是1就行了,位运算的方法就是hash值和10000(也就是旧table长度)来按位与,其结果只可能是10000或者00000。
所以,e.hash & oldCap,就是用于计算位置b到底是0还是1用的,只要其结果是0,则新散列下标就等于原散列下标,否则新散列坐标要在原散列坐标的基础上加上原table长度。

HashMap是否线程安全,为什么

JDK1.7中,由于多线程对HashMap的扩容,HashMap采用头插法,新插入的数据会从链表的头节点进行插入。这样在多线程的情况下,容易造成死循环的问题。
JDK1.8中,由于多线程对HashMap进行Put操作。假设两个线程A,B都在进行put操作,并且hash函数计算出的插入下标相同,当线程A由于时间片耗尽被挂起,而线程B得到时间片后在该下标处插入元素,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所以不会再进行判断,而是直接进行插入,这就导致线程B插入的数据被线程A覆盖了,从而线程不安全。

HashMap和HashTable的区别

  1. HashTable线程安全,key和value不能是null,否则会报空指针异常。
  2. HashMap线程不安全,key和value可以是null
  3. HashMap初始容量16,扩容直接翻倍
  4. HashTable初始容量11,扩容翻倍+1
  5. HashTable直接使用对象的hashCode,HashMap需要重写计算hash值
    在这里插入图片描述

解决Hash碰撞的方法有哪些?

常见的解决方法有:

  1. 开发地址法:在发生哈希碰撞时,通过一定的算法在哈希表中寻找一个空闲的位置,并将元素插入到该位置。
  2. 链表哈希表:在每个哈希表的元素位置上,存储一个链表,哈希碰撞时,将元素插入到相应的链表中。
  3. 再哈希法:如果一个哈希函数产生的哈希值发生了碰撞,就再次使用另一个哈希函数计算哈希值

ConcurrentHashMap的数据结构

在jdk1.7中,ConcurrentHashMap的数据结构是由Segments数组+HashEntry数组+链表实现的。
在jdk1.8中,选择了和HashMap相同的Node数组+链表+红黑树结构,采用CAS+Synchronized来保证并发安全性。

  1. ConcurrentHashMap是如何保证线程安全?
    ConcurrentHashMap使用分段锁的方式来实现线程安全,它将一个大的哈希表分成多个小的哈希表,每个小的哈希表都有自己的锁。这样,不同的线程可以同时访问不同的小哈希表,从而避免了多个线程同时竞争一个锁的情况,提高了并发性能。
  2. ConcurrentHashMap的扩容机制是怎么样的?
    CurrentHashMap的扩容机制与HashMap类似,它会在已经存放个数达到阈值时进行扩容。扩容的过程中,ConcurrentHashMap会将原来的小哈希表逐一复制到新的大哈希表中,这个过程中仍然可以保证线程安全。扩容后,ConcurrentHashMap会继续使用分段锁的方式来维护新的小哈希表。
  3. ConcurrentHashMap的get()方法是否需要加锁?
    ConcurrentHashMap的get()方法不需要加锁,因为它是线程安全地,并发访问时,ConcurrentHashMap使用了volatile和CAS等机制来保证数据的一致性和可见性。
  4. ConcurrentHashMap与Hashtable有什么区别?
    ConcurrentHashMap和Hashtable都是线程安全的哈希表,但是它们有很大的区别,ConcurrentHashMap使用了分段锁的方式来提高并发性能,而Hashtable使用了一个全局锁来保证线程安全,所以并发性能比ConcurrentHashMap差很多。此外ConcurrentHashMap允许空键和空值,而Hashtable不允许。另外,ConcurrentHashMap支持更多的操作,比如ConcurrentHashMap支持的批量操作和原子操作等,Hashtable不支持。
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Java中HashMap原理与分析 的相关文章

随机推荐

  • sql 递归查询_SQL如何求解递归问题?

    点击上方SQL数据库开发 关注获取SQL视频教程 SQL专栏SQL数据库基础知识汇总SQL数据库高级知识汇总 递归 递归是指程序调用自身的一种编程技巧 在SQL中也有递归查询 下面我们通过一个省市区的示例来讲解递归查询的用法 问题 有如下一
  • 【C语言刷题2】

    1 杨辉三角 思路 首先观察一下示例 会发现需要一个二维数组来帮助解题 根据题目描述 会发现每一行的元素个数等于行数 并且每行首尾元素都是1 中间的元素都是左上角和上方元素和 这样我们就可以建立一个选择语句if else 如果是每行第一个或
  • UE4 C++ Timeline

    UE4 C Timeline 我命名有点不规范注意点看 结束事件绑定 每次更改绑定 调用时间轴开始方法方法 1 先建C 类 用碰撞触发时间轴 代码 h Fill out your copyright notice in the Descri
  • Compareable接口的compareTo方法详解

    Compareable接口可以实现类中成员属性的排序方法 通过重写Compareable接口中的CompareTo方法实现自定义规则的排序 针对Compareable接口的排序方式 将通过对学生类和测试类进行一个代码演示 一般情况下 一般情
  • Java用集合实现斗地主洗牌发牌

    案列分析 准备4种花色牌与13种数值牌循环嵌套为52张牌 加两种特殊牌大王小王牌共54种 再进行洗牌发牌 文章目录 一 思路分析 二 准备牌 1 准备一个集合存放所有牌 2 准备两个数组分别存取扑克牌的4种花色和13种数值 3 进行嵌套组合
  • 调度器简介,以及Linux的调度策略

    进程是操作系统虚拟出来的概念 用来组织计算机中的任务 但随着进程被赋予越来越多的任务 进程好像有了真实的生命 它从诞生就随着CPU时间执行 直到最终消失 不过 进程的生命都得到了操作系统内核的关照 就好像疲于照顾几个孩子的母亲内核必须做出决
  • Linux找回密码

    Linux找回密码 1 开启的时候要尽快点击键盘上下键 选中上面一个 然后输入 e 2 然后点击键盘上下键 找到linux16开头这一行 在行的最后输入 init bin sh 3 接着 输入完成后 直接按快捷键 Ctrl x 进入单用户模
  • CSDN的chatGPT为什么会有很多问题无法回答?

    ChatGPT是一个被OpenAI训练的大型语言模型 它使用机器学习算法 可以根据上下文和用户的输入来回答问题 然而 由于我们的认知有限 有时ChatGPT无法正确理解用户的问题或句子 从而导致它无法给出准确的回答
  • 登录文档服务器,开启登录服务器

    开启登录服务器 内容精选 换一换 如果您已在购买存储库时绑定服务器 文件系统或磁盘 可以跳过此章节 云服务器备份存储库 SFS Turbo备份存储库和云硬盘备份存储库创建后 通过向存储库绑定服务器 文件系统或磁盘来进行备份 复制操作 当混合
  • C++学习之new 与 delete表达式

    new和delete表达式动态创建和释放单个对象 a 基本知识介绍 定义变量时 必须指定其数据类型和名字 而动态创建对象时只需指定其数据类型而不必为该对象命名 取而代之的是 new表达式返回新创建对象的指针 我们通过指针来访问此对象 int
  • 解决ImportError: Could not find the DLL(s) ‘msvcp140_1.dll‘问题

    解决ImportError Could not find the DLL s msvcp140 1 dll 问题 刚安装好tensorflow安装包去试试import tensorflow as ft时 出现错误 错误原因 ImportEr
  • 【项目实战】---需求分析+表关系分析

    SSH 小编初次接触的时候傻傻的以为这个跟SHE有什么关系呢 又是哪路明星歌手 后来才知道小编又土鳖了 原来SSH是这个样子滴 百度百科对她这样阐述 SSH即 Spring Struts Hibernate Struts对Model Vie
  • Python员工离职数据分析

    Python员工离职数据分析 import pandas as pd import seaborn as sns import matplotlib pyplot as plt import warnings warnings filter
  • 2022年国际土木与海洋工程联合会议(JCCME 2022)

    海南大学主办 2022年国际土木与海洋工程联合会议 JCCME 2022 重要信息 会议网址 www jccme org 会议时间 2022年12月23 25日 召开地点 海口 截稿时间 2022年11月20日 录用通知 投稿后2周 收录检
  • git官网进去很慢我们可以去镜像下载

    git下载
  • 五脏六腑在脸上的反射区图片_人体五大反射区的有图详解。

    原标题 人体五大反射区的有图详解 反射区是遍布全身的神经聚集点 与身体各器官相对应 比如手 足 耳等反射区 它们与身体的五脏六腑 头部的大小脑 淋巴腺 内分泌腺 肌肉 关节紧密相连 其中 每个器官 部位的神经末梢 在手 足 耳等部位都有一个
  • antV G2 常用指标参数 01

    antV G2 会比较多的API 查看起来也比较费时间 所以把一些常有的方法 参数 指标列举 方便运用 01 柱状图两边留空间 time 是横坐标的 指标 chart scale time range 0 05 0 95 02 自定义纵坐标
  • Linux查找特定进程信息

    命令 查找ssh进程 root linuxcentos ps ef grep ssh 执行结果 root 1303 1 0 Apr17 00 00 00 usr sbin sshd root 3260 3087 0 Apr17 00 00
  • matlab中std函数的用法,matlab std函数 用法及实例

    MATLAB常常用到std函数来进行标准差计算 下面我就通过实例介绍一下 matlab std函数怎么用 1 std函数是用来计算标准偏差的一个函数 由于其有不同的参数 我们就用下面的例子进行介绍 A 1 2 3 1 1 1 标准差的两种计
  • Java中HashMap原理与分析

    HashMap的底层数据结构 HashMap是以Key Value的方式进行数据结构存储的一种数据结构 JDK1 7采用的是数组 链表 使用Entry类存储key和value JDK1 8采用的是数组 链表 红黑树 使用Node类存储key