HashMap 之底层数据结构和扩容机制

简介: HashMap 之底层数据结构和扩容机制

一、底层实现原理

1.1 底层数据结构

HashMap 的底层数据结构是一个由数组和链表实现的类似字典的结构,或者是红黑树的结构。在 HashMap 的长度小于 8 时是前者,大于或等于 8 时是后者。这也就意味着,HashMap 不仅仅会扩容,还可以“缩容”。

1.1.1 在 JDK1.8 之前

在这之前,HashMap 还没有被优化,底层的数据结构只可能是 数组 + 链表 的方式,不存在 HashMap 长度大于等于 8 后数据结构转变为红黑树的情况。那么,是怎么个 数组 + 链表的结构呢?如下图:

数组 + 链表

通过上面的图,我们可以很清晰地看懂 HashMap 的 数组 + 链表 结构,其中,数组中的元素是每个链表的头节点。

1.1.2 在 JDK1.8 及 1.8 之后

JDK1.8 之后,HashMap 被极大地优化了,在长度小于 8 时没有太大变化,但跟以往不同,在存储的数据特别大的情况下(长度至少大于等于 8),HashMap 的性能有了明显的改善,这要得益于数据结构转变成了红黑树。关于数据结构 —— 红黑树,我们这里不做详细的说明,具体参见:【红黑树】。

红黑树具有良好的性能,加速了 HashMap 一些操作的速度。此时 HashMap 的底层数据结构大致像下面这样:

数组 + 链表 + 红黑树

1.2 put 方法

put 是 HashMap 的一个方法,它可以往 HashMap 中添加或者修改一个键值对,它的具体过程如下:

在不考虑 HashMap 的长度达到 8 时结构由链表转变为红黑树的这种情况下,put 一个键值对 <key, value>,首先 key 和 value 会首先被实例化为一个实现了 Map.Entry<key, value> 接口的 Node 对象,然后调用 key 的 hashCode 方法得到 key 的哈希值,并与 HashMap 中已有的键的哈希值进行比较,如果在 HashMap 中没有出现这个哈希值,那么这个 Node 对象就会被视为一个新的键值对,并被添加到 HashMap 中去,如果出现了相同的哈希值,则会再次调用 key 的 equals 方法对 HashMap 中原有的这个键进行比较,若不同,则说明此时发生了哈希冲突,并将这个键值对添加到 HashMap 中去,若没有出现上述过程,则说明 HashMap 中已经有这个键值对了,那么就用 value 替换对应 key 的值。

下面用一张图展示了这个过程:

put 方法执行过程

当然,若是添加之后,HashMap 的长度大于或等于 8,那么在添加的时候还有将 HashMap 底层数据由链表转换为红黑树这一过程。这一部分在后续的“扩容机制”一节中将具体讲解。

二、扩容机制

2.1 负载因子(loadFactor)

HashMap 的负载因子是一个用来计算扩容阈值的数,计算公式是:

阈值(threshold)= 负载因子(loadFactor)× 容量(capacity)

当 HashMap 存储的数据个数达到阈值时,HashMap 将会自动进行容量扩充,负载因子的值默认为 0.75。

那么,为什么要有负载因子的存在呢?像 ArrayList 那样,当容量不够的时候再进行扩容难道不行吗?实际上,不是不行,只是不好。由于 HashMap 的增删改查或多或少涉及到哈希相关算法,若是像 ArrayList 那样,容量不够的时候再进行扩容,很容易导致出现“哈希碰撞”的情况,而出现“哈希碰撞”时就需要调用 equals 方法来解决这一问题,而 equals 方法的性能并不高,为了提高 HashMap 的性能,就需要适当地提前进行扩容。但是,又不能扩容非常多,因为扩容太多,而数据存储量并没有那么多的话,就会耗费过多内存空间,于是就有了负载因子的存在。

HashMap 的扩容本质上就是一个对时间和空间权衡的问题,想要时间少,那么空间就要耗费多,反之,想要空间耗费小,那么时间就要消耗多。只有一个不大不小的值,才能权衡这二者之间的利弊。

但是,这个值为 0.75 是怎么计算出来的呢?

首先,负载因子不能太大,也不能太小,我们可以大致估计出它在 0.5 ~ 1 之间最为合适,其中间值 0.75 是个不错的选择。其次,我们要知道,HashMap 每次扩容与 ArrayList 扩容 1.5 倍不同,HashMap 每次直接翻倍,而 HashMap 初始化后的默认容量是 16(24),因此 HashMap 的容量一般来说都是 2 的指数幂,而 0.75(3/4)与任何大于等于 4 的 2 指数次幂相乘的结果都是整数,正好,HashMap 的默认容量 16 是满足这个条件的,这样就避免了分数的麻烦。

2.2 默认容量

HashMap 初始化后的默认容量是 16,那么为什么是 16,而不像 ArrayList 那样,初始为 0,要添加的时候再进行扩容呢?

实际上这个值也是通过对时间和空间的权衡利弊得到的。我们可以简单分析一下,根据上面负载因子的值及扩容相关知识,我们可以知道,初始容量应该为 2 的指数次幂才可以保证 HashMap 的容量始终是 2 的指数次幂。很显然,当默认容量取得太大时,会有很多空间不一定利用,导致了空间的浪费,而当默认容量太小时,稍微让 HashMap 存储多一点的数据,它就需要多次调用扩容相关的代码,一次只扩容一倍,非常耗时。通过权衡时间和空间的利弊,HashMap 的默认容量就被定为了 16。

2.3 缩容

一般情况下,是不建议对 HashMap 进行缩容的,但是,这并不代表 HashMap 无法进行缩容了,它只是不会自动地缩容,要想缩容,必要手动进行。

2.3.1 不缩容的理由

我们不妨想想,缩容是通过时间来空间的操作,如果是在空间充足的情况下,这只会消耗时间,就算是空间不太充足的情况下,既然你扩容到了这么大,那么说明你确实有这么多的数据要存储,那又为什么要缩容呢?所以,一般来说,是没必要缩容的,除非真的在某一刻程序急需内存来处理某些棘手的问题,这个时候才需要缩容来腾出空间给其他程序使用,但这样对于空间不足的问题治标不治本,更好的方法是升级硬件配置或者改善程序来增加可用内存空间。

2.3.2 如何缩容

很简单的一种方法,实例化一个新的 HashMap,然后把原来 HashMap 的数据搬过来,再释放原来 HashMap 的内存空间,以达到“缩容”的目的。但是,这显然不是真正的缩容,实际上,Java 是不支持直接对一个 HashMap 对象进行真正意义上缩容的,从之前“不缩容的理由”一节中就可以看出,这样做,真的不是一个好的做法。

总结一句话就是,HashMap 缩容,没有必要。

目录
相关文章
|
3月前
|
存储 安全 算法
【JAVA】HashMap扩容性能影响及优化策略
【JAVA】HashMap扩容性能影响及优化策略
|
2月前
|
存储 安全 Java
深入解析Java HashMap的高性能扩容机制与树化优化
深入解析Java HashMap的高性能扩容机制与树化优化
55 1
|
2月前
|
存储 自然语言处理 NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之倒排索引(三)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之倒排索引(三)
|
2月前
|
存储 自然语言处理 NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之列存(二)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之列存(二)
|
2月前
|
存储 JSON NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之行存(一)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之行存(一)
|
2月前
|
索引
HashMap扩容为什么每次都是之前的2倍
HashMap扩容为什么每次都是之前的2倍
31 0
|
2月前
|
存储 Java 数据处理
Java Map实战:用HashMap和TreeMap轻松解决复杂数据结构问题!
【6月更文挑战第18天】在Java中,HashMap基于哈希表提供快速的键值对操作,适合无序数据;而TreeMap利用红黑树保证排序,适用于有序场景。示例展示了HashMap如何存储并查找用户信息,以及TreeMap如何按员工编号排序存储员工名。两者在不同需求下优化了数据处理。
79 0
|
3月前
|
存储 安全 Java
Java程序员必须掌握的数据结构:HashMap
HashMap底层原理实现是每个Java Boy必须掌握的基本技能,HashMap也是业务开发每天都需要遇到的好伙伴。如此基础且核心的底层数据结构,JDK也给其赋予了线程安全的功能,我们来看看~
Java程序员必须掌握的数据结构:HashMap
|
3月前
|
Java 索引
HashMap的扩容看这一篇足够
HashMap的扩容看这一篇足够
65 2