java map原理_Java HashMap底层原理分析

前两天面试的时候,被面试官问到HashMap底层原理,之前只会用,底层实现完全没看过,这两天补了补功课,写篇文章记录一下,好记性不如烂笔头啊,毕竟这年头脑子它记不住东西了哈哈哈。好了,言归正传,今天我们就来深入探索下HashMap 。

哈希表作为一种优秀的数据结构,本质上存储结构是一个数组,加以链表和红黑树辅助。JDK 1.7 之前没有红黑树,仅仅是链表作为辅助;在JDK 1.8 以后新增了红黑树,效率得到大大优化,至于怎么优化的,我们后续再进行分析。我们知道数组结构在查询和插入删除的时间复杂度分别为O(1)和O(n),链表结构在查询和插入删除的时间复杂度分别为O(n)和O(1),二叉树在这两者之间做了平衡,查询和插入删除的时间复杂度都为O(logn),而在哈希表中两者均为O(1),由此可见哈希表的优越性。

首先来看一下HashMap的结构

可以看到,HashMap是由一个数组构成的,数组中存的是链表或者红黑树(后面再进行分析)。

下面我们来介绍几个常量

// 默认初始化容量 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30;

// 负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 转化为红黑树的长度 static final int TREEIFY_THRESHOLD = 8;

// 退化为链表的长度 static final int UNTREEIFY_THRESHOLD = 6;

首先,我们先来创建一个哈希表,它的键是字符串,值对应一个学生对象

我们首先来看new一个哈希表时都进行了哪些操作,这就得看它的构造函数了 ,在JDK源码中,有四种构造函数,分别如下

可以看到,我们刚刚在创建HashMap的时候使用的是第3种构造方法,在这个方法中很简单,就是初始化了一个默认的负载因子,默认值为0.75。我们看在第1种构造方法初始化了一个容量,但是刚刚我们创建的时候没有指定容量,那么它怎么往其中添加元素呢?不急,我们接着往下看

当我们调用put方法时,内部调用了putVal方法 ,其中key就是哈希表的键,即我们传进去的“学生一“,value就是学生对象。 这里调用了hash()方法,用来计算键值key的哈希值

相同的key一定会得到相同的哈希值,不同的key也有可能得到相同的哈希值,这就是所谓的哈希碰撞,那么发生哈希碰撞了 怎么办?这个我们后面再说。先看一下putVal方法做了哪些工作

在这里,我们看到一个Node[]数组,它就是我们前面所说的,里面存的是链表。在往哈希表中put元素的时候,首先会进行上图中的第一步,检查该数组是否为空,为空的话则要执行resize()方法,进行扩容。我们刚刚在创建HashMap的时候,没有指定初始容量,仅仅初始化了一个负载因子,所以说此时数组是空的,在put第一个元素的时候自然要执行resize()方法。这个方法我们后面再讲。在初始化了数组之后进行put操作,首先会根据key的哈希值寻找该元素在数组中应该放置的位置,也就是上图中标号为2的代码。这里解释一下该段代码

if ((p = tab[i = (n - 1) & hash]) == null)

tab[i] = newNode(hash, key, value, null);

其中i = (n-1) & hash得到的是该元素放置位置的索引值 ,这里的n是数组的长度,由于在计算机中是使用&来进行按位取余的,采用的都是二进制数,所以无论key的哈希值是多少,i的值一定会被限制在n-1之间,即在数组的合法下标之中。比如我们假设:n=16,元素的哈希值为hash=41,那么在计算机中表示为n-1 = 0000 1111 ,hash = 0010 1001 ,这里我们省略了高位,仅表示到第8位,那么在执行(n-1) & hash之后,i = 0000 1001 ,也就是十进制数字9。

在得到下标之后,判断该位置是否已经有了元素,没有的话则放到该位置。如果该位置已经存在元素,说明此时发生了哈希碰撞,则进入碰撞处理。碰撞处理的思路如下:下面单独将该部分代码贴出来进行分析

首先进行1处的判断,这里的判断是什么意思呢?该部分其实做的是值更新的处理,即如果我们的哈希表中已经存在一个键为key1的数据,当我们再插入一个键为key1的数据时, 新的值替换旧值,键还是原来的key1。这个时候也能说明相同的键会有相同的哈希值,自然要进行哈希碰撞处理了。

代码2部分比较简单,进行红黑树的碰撞处理,那么什么时链表会转化为红黑树呢? 前面有两个常量

// 转化为红黑树的长度 static final int TREEIFY_THRESHOLD = 8;

// 退化为链表的长度 static final int UNTREEIFY_THRESHOLD = 6;

其中TREEIFY_THRESHOLD = 8表示呢,当链表节点数量达到8时,则将链表转化为红黑树以提升效率。既然可以转过去,当然也可以转回来,当我们删除链表中的节点到6个的时候,红黑树退化成为链表。

如果1和2部分都不满足的话,那么就说明是在链表中产生了哈希碰撞。代码3就是处理链表中的哈希碰撞的,需要注意的是,在此处处理时需要判断是否需要将链表转化为红黑树。

put操作说完了 ,接下来看下是如何从HashMap中取出一个元素的,get方法用来获取一个元素,如下所示

在该方法中调用了getNode方法用来返回一个节点,源码中getNode方法如下

该方法相对来说就简单多了,我们还是将其分为三个部分。首先根据哈希值计算出其在数组中的索引值,如果存在,则根据具体情况进入代码1、2、3。

每次都是先判断索引处的第一个元素,无论该处是链表还是红黑树,符合情况就返回并结束。这也是代码1的意思。如果不是,则根据该处是链表还是红黑树,分别进行相应的查找办法,对应上面的代码2、3。

最后,来简单介绍一下扩容机制,那么什么时候会调用resize进行扩容呢?上面我们提到了“负载因子”这个概念,这个时候它就派上用场了。当我们哈希表的存储的元素个数超过DEFAULT_LOAD_FACTOR * Cap时(也就是达到当前容量的 0.75 倍)就需要进行扩容了 ,这是一个相当费时的操作。下面我们来看一下,是如何进行扩容的

上面这部分就是一些边界判断,真正的精华看下面这部分

主要上图圈红的部分,每次扩容新表容量变为旧表的2倍。对旧表中的每个位置进行遍历,如果该位置只有一个元素,则放到原位置不动。该位置若为树则进入树的分解方法(这里我们暂且不讨论)。下面分析一下链表的分解方法:

由于数组扩容为原来的2倍,就把原来的单链表拆分为2队(采用hash & OldCap分解),一队奇队,一队偶队。然后分别放在原位置j和新位置j + OldCap,至此,扩容完成。

好了,到这里HashMap的基本原理已经介绍完了,内容属实不少,脑阔疼。最后我们再来看几个小问题:Java 强调在重写equals()方法时,必须重写hashCode方法,这是为什么呢?因为在两个键的hash值相等的时候,会去调用equals()方法判断两者是否为同一对象,默认的equals()方法在比较时是比较两个变量的内存地址,此时一定是不相等的。假设我们有两个相等的键,key1和key2(但是在内存中的位置不同), 在重写euqals()方法之前,key1.equals(key2) = False,重写之后 ,key1.equals(key2) = True,在 Java 中希望把它们当做同样的键来处理。下面我们用一个例子来进行说明:

这里我们没有重写hashCode()和equals()方法,可以看到map中的两个key是一样的,但是此时输出的map.size = 2。

重写了这两个方法之后,输出的map.size = 1,可以看到此时map中才是不存在相同的键的。

2. 负载因子是大点好,还是小点好?由于数组是定长的,当添加的元素增多时,发生哈希碰撞的概率就增大,此时哈希表会逐渐退化成链表。Java 中默认的负载因子为0.75,较大的负载因子会导致哈希碰撞增多,较小的负载因子则会导致内存浪费。

2020.08.31 更

前天面试依图科技时,HashMap又被再一次拿了出来。在这次面试中,面试官又提及了几个问题,是之前没有注意到的,特此来巩固一下。先来回顾一下问题:initialCapacity指的是什么?

HashMap在什么时候进行扩容?

好!首先来看第一个问题,我们都知道当HashMap的容量(存储的key-value的节点数量)达到最大容量的DEFAULT_LOAD_FACTOR(0.75)时,就需要进行扩容了。那么问题来了,这个最大容量指的是数组的长度还是HashMap中key-value的个数呢?在源码中,Cap这个量一直是与数组长度和threshold相关的。

上面是resize()方法中的一段,无论我们在初始化的时候指定initialCapacity是多少,都不会立即给它分配内存。当在put第一个元素的时候,首先会进行扩容,此时的扩容是跟构造函数息息相关的。如果初始化的时候没有指定initialCapacity,那么加载默认的负载因子。此时threshold = 0

initialCapacity = 0

此时在resize()方法中进行第一次扩容的时候,直接进入情况C,使用默认的容量个负载因子构建HashMap如果初始化的时候制定了initialCapacity,即便仅仅指定了initialCapacity一个参数,也会调用HashMap(int initialCapacity, float loadFactor)函数,在这个方法中,有一个关键函数this.threshold = tableSizeFor(initialCapacity);

tableSizeFor()函数用于指定threshold。截止到此时,只指定了threshold的值,Cap仍然为0。在第一次put的时候,进入情况BnewCap=OldThr除了这两种情况之外,其他情况直接进入A(在第一次put之后,需要扩容的情况),newCap=OldCap << 1

newThr=OldThr << 1

容量和阈值都扩为原来的两倍。

之后就是使用更新好的newCap创建新的Node[]数组。

下面看第二个问题:什么时候进行扩容?扩容和Cap这个量没有直接关系,Cap代表的是Node[]数组的容量,根据Cap和loadFactor会计算出一个threshold。在每次put元素时,会更新size变量,每次递增1,size代表的是HashMap中的Node的数量,当size>threshold时,调用resize()方法进行扩容。

需要牢记一点即可:resize和Cap没有直接关系,和threshold有直接关系,但是threshold又与Cap有关threshold第一次确定与Cap有关

之后threshold的值都更新为原来的2倍

下面贴一个HashMap容量初始化的博客java中hashmap容量的初始化 - 杨冠标 - 博客园​www.cnblogs.com

作者:孙旭森

联系方式:2909300605

注:转载请注明出处!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值