Hashmap原理整理

HashMap简介:

HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。

HashMap的继承关系:
在这里插入图片描述
HashMap与Map关系如下图:在这里插入图片描述

HashMap的构造函数

HashMap共有4个构造函数,如下:

// 默认构造函数。
HashMap()
// 指定“容量大小”的构造函数
HashMap(int capacity)
// 指定“容量大小”和“加载因子”的构造函数
HashMap(int capacity, float loadFactor)
// 包含“子Map”的构造函数
HashMap(Map<? extends K, ? extends V> map)

二、JDK7 中 HashMap 底层原理

HashMap 在 JDK7 或者 JDK8 中采用的基本存储结构都是数组+链表形式。本节主要是研究 HashMap 在 JDK7 中的底层实现,其基本结构图如下所示:
在这里插入图片描述
上图中左边橙色区域是哈希表,右边蓝色区域为链表,链表中的元素类型为 Entry,它包含四个属性分别是:

K key
V value
int hash
Entry next
那么为什么会出现数组+链表形式的存储结构呢?这里简单地阐述一下,我们在使用 HashMap.put(“Key”, “Value”)方法存储数据的时候,底层实际是将 key 和 value 以 Entry的形式存储到哈希表中,哈希表是一个数组,那么它是如何将一个 Entry 对象存储到数组中呢?是如何确定当前 key 和 value 组成的 Entry 该存到数组的哪个位置上,换句话说是如何确定 Entry 对象在数组中的索引的呢?通常情况下,我们在确定数组的时候,都是在数组中挨个存储数据,直到数组全满,然后考虑数组的扩容,而 HashMap 并不是这么操作的。在 Java 及大多数面向对象的编程语言中,每个对象都有一个整型变量 hashcode,这个 hashcode 是一个很重要的标识,它标识着不同的对象,有了这个 hashcode,那么就很容易确定 Entry 对象的下标索引了,在 Java 语言中,可以理解 hashcode 转化为数组下标是按照数组长度取模运算的,基本公式如下所示:

int index = HashCode(key) % Array.length

实际上,在 JDK 中哈希函数并没有直接采取取模运算,而是利用了位运算的方式来提高性能,在这里我们理解为简单的取模运算。 我们知道了对 Key 进行哈希运算然后对数组长度进行取模就可以得到当前 Entry 对象在数组中的下标,那么我们可以一直调用 HashMap 的 put 方法持续存储数据到数组中。但是存在一种现象,那就是根据不同的 Key 计算出来的结果有可能会完全相同,这种现象叫作“哈希冲突”。既然出现了哈希冲突,那么发生冲突的这个数据该如何存储呢?哈希冲突其实是无法避免的一个事实,既然无法避免,那么就应该想办法来解决这个问题,目前常用的方法主要是两种,一种是开放寻址法,另外一种是链表法。 开放寻址法是原理比较简单,就是在数组里面“另谋高就”,尝试寻找下一个空档位置。而链表法则不是寻找下一个空档位置,而是继续在当前冲突的地方存储,与现有的数据组成链表,以链表的形式进行存储。HashMap 的存储形式是数组+链表就是采用的链表法来解决哈希冲突问题的。具体的详细说明请继续往下看。 在日常开发中,开发者对于 HashMap 使用的最多的就是它的构造方法、put 方法以及 get 方法了,下面就开始详细地从这三个方法出发,深入理解 HashMap 的实现原理。

三、HashMap put、get 方法流程图
HashMap 的 put 方法存储数据的流程图供读者参考:
在这里插入图片描述
HashMap 的 get 方法获取数据的流程图供读者参考:
在这里插入图片描述
四、常见的 HashMap 的迭代方式
在实际开发过程中,我们对于 HashMap 的迭代遍历也是常见的操作,HashMap 的迭代遍历常用方式有如下几种:
方式一:迭代器模式

Map<String, String> map = new HashMap<>(16);
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, String> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
}

方式二:遍历 Set>方式

Map<String, String> map = new HashMap<>(16);
for (Map.Entry<String, String> entry : map.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

方式三:forEach 方式(JDK8 特性,lambda)

Map<String, String> map = new HashMap<>(16);
map.forEach((key, value) -> System.out.println(key + ":" + value));

方式四:keySet 方式

Map<String, String> map = new HashMap<>(16);
Iterator<String> keyIterator = map.keySet().iterator();
while (keyIterator.hasNext()) {
    String key = keyIterator.next();
    System.out.println(key + ":" + map.get(key));
}

把这四种方式进行比较,前三种其实属于同一种,都是迭代器遍历方式,如果要同时使用到 key 和 value,推荐使用前三种方式,如果仅仅使用到 key,那么推荐使用第四种。

参考链接:https://blog.youkuaiyun.com/banzhuanhu/article/details/108127478?utm_medium=distribute.pc_feed.none-task-blog-personrec_hot-2.nonecase&depth_1-utm_source=distribute.pc_feed.none-task-blog-personrec_hot-2.nonecase&request_id=5f4399030388ae0b5643c811

<think>嗯,用户想了解HashMap的put方法的原理。结合用户给出的引用内容,我需要整理一下这方面的知识体系。 用户主要在查询HashMap中put方法的实现机制,尤其关注JDK1.8版本的特性。用户提供的引用材料涵盖了数据结构、哈希计算、冲突处理、树化机制等核心内容。 准备从数据结构开始说明,在JDK1.8中HashMap采用数组+链表/红黑树的复合结构。数组作为桶(bucket)存放链表头节点或树根节点,当链表长度超过8且数组容量达到64时会树化。这点在引用[3]中有提到树化转换条件。 哈希计算部分需要强调扰动函数的优化。JDK1.8的hash()方法将key的hashCode高16位与低16位异或,减少哈希碰撞。引用[2]提到使用hashCode()和哈希算法寻找索引,但没说明扰动细节,这点我会补充。 根据引用[3]描述的索引定位公式:(n-1)&hash,其中n是数组长度。这个位运算相当于取模但效率更高。当发生哈希冲突时,引用[2]说明会先判断节点类型:如果是树节点则调用红黑树的插入方法,否则遍历链表并考虑树化条件。 扩容机制方面着重说明引用[3]提到的优化:重新哈希时通过hash&oldCap判断元素位置。若结果为0则保持原索引,否则新位置=原索引+旧容量。这种设计避免重新计算哈希值,提升扩容效率。 最后需要强调线程安全问题。虽然不属于put原理本身,但实际应用很重要。考虑提醒用户HashMap非线程安全,多线程环境下应该用ConcurrentHashMap。 </think>好的,我们来详细解析一下 JDK 1.8 中 HashMap 的 `put(K key, V value)` 方法底层原理。整个过程围绕着**数组+链表/红黑树**的数据结构、**哈希计算**、**哈希冲突解决**以及**扩容**这几个核心概念展开: ### 核心数据结构与流程概述 1. **初始化与底层结构:** * HashMap 内部维护一个 `Node<K, V>[] table` 数组(通常称为桶数组或 bucket array)。`Node` 是存储键值对的基本单元,是一个链表节点(包含 `key`, `value`, `hash`, `next` 指针)。 * 在 JDK 1.8 中,当单个桶(数组元素)中的链表长度过长(达到阈值 8)**且**整个 `table` 数组的长度达到一定阈值(默认 64)时,该桶处的链表会转换为**红黑树(TreeNode)**,以提高极端情况下的查询效率($O(n)$ -> $O(\log n)$)[^1][^2]。 2. **put 方法的核心步骤:** * **Step 1: 计算 Key 的哈希值 (`hash`)** * 调用 `key.hashCode()` 获取原始哈希码。 * 关键优化:HashMap 内部使用 `(h = key.hashCode()) ^ (h >>> 16)` 进行一次**扰动计算**。 * 目的:**降低哈希碰撞**。原始哈希码的高 16 位与低 16 位进行异或,使得高位的变化也能影响最终的低位结果。因为后续确定桶位置时(`(n - 1) & hash`),当 table 长度 `n` 较小时(如初始 16),实际上是只用到了 `hash` 的低几位(如 `n-1=15`,二进制 `1111`,只用了低 4 位)。扰动让高位也参与到影响低位中,让哈希分布更均匀[^1][^2]。 * **Step 2: 计算桶索引 (`index`)** * 使用公式: `index = (table.length - 1) & hash` * 解释: * `table.length - 1`:因为桶数组长度 `n` 总是 2 的幂(如 16, 32, 64...),所以 `n-1` 的二进制表示是一个低位全为 `1` 的数(如 15 -> `1111`, 31 -> `11111`)。 * `& hash`:执行按位与操作 (`&`)。这相当于对 `hash` 值进行 `取模(n)` 操作 (`hash % n`),但效率远高于取模运算。 * 结果:`index` 的值在 `[0, table.length - 1]` 之间,确定了键值对应该放入哪个桶(数组哪个位置)[^1][^2][^3]。 * **Step 3: 处理目标桶 (`table[index]`)** * **情况 A:桶为空 (`table[index] == null`)** * 最简单的情况:直接在该索引位置创建一个新的 `Node` 节点存放键值对即可。 * **情况 B:桶不为空 (哈希碰撞发生)** * 需要遍历该桶位置的链表或红黑树,进行更新或插入: 1. **检查第一个节点:** * 比较 `table[index]` 节点的 `hash` 值和传入的 `hash` 值。 * 如果 `hash` 相等,再用 `equals()` 方法(或 `==`)比较 `key`。 * 如果 `key` 也相等:找到了**重复的 key**!用新 `value` **覆盖**旧的 `value`,返回旧 `value`。 2. **后续节点处理:** * 如果第一个节点不匹配,判断该桶结构是链表还是红黑树: * **如果是红黑树 (`TreeNode`):** 调用红黑树的 `putTreeVal()` 方法进行查找/插入。该方法会按照红黑树的规则查找 key。如果找到相同 key 则覆盖 value,找不到则插入新节点并在必要时调整树结构以保持平衡。 * **如果是链表:** 遍历链表(`next` 指针): * 对每个节点,重复步骤 1 的比较(`hash` 和 `key.equals()`)。 * 如果找到相同 key:覆盖 value,返回旧 value。 * 如果遍历到链表尾部 (`next == null`) 仍未找到:在链表尾部插入一个新的 `Node` 节点。 * **链表树化检查:** 插入新节点后,检查当前链表长度: * 如果插入后链表长度 **>= 8** (`TREEIFY_THRESHOLD = 8`),则调用 `treeifyBin(tab, hash)` 尝试将该链表转换为红黑树。 * 在 `treeifyBin` 方法中,会**先检查当前 `table` 的长度**: * 如果 `table` 长度 **< 64** (`MIN_TREEIFY_CAPACITY = 64`),则不进行树化,而是优先选择**扩容 (`resize()`)**,因为扩容本身就能分散节点,可能解决链表过长问题。 * 如果 `table` 长度 **>= 64**,则真正将该桶处的链表节点转换为 `TreeNode` 节点,并构建成一颗红黑树[^1][^2]。 * **Step 4: 修改计数与扩容检查** * 每次成功添加新节点(而非覆盖旧值)后,`size`(HashMap 中键值对总数)会加 1。 * 检查 `size` 是否超过了 `threshold`(阈值 = `capacity * loadFactor`,`capacity` 是 `table.length`,`loadFactor` 是负载因子,默认 0.75)。 * 如果 `size > threshold`:触发 **`resize()`** 扩容操作。 3. **扩容 (`resize()`) 原理:** * **目的:** 增大桶数组容量,减少哈希碰撞,为链表树化或保持性能提供空间。 * **过程:** 1. 创建一个新的 `Node` 数组,其容量是原数组容量的 **2 倍**(`newCap = oldCap << 1`)。 2. 遍历原数组的每一个桶(链表或树)。 3. 将原桶中的每个节点重新计算其在新数组中的位置(`(newCap - 1) & hash`),并将其移动到新数组对应的桶中。 * **JDK 1.8 优化:** 重新计算索引 (`index`) 时,利用了一个特性: * 因为 `newCap = oldCap * 2`(是 2 的幂),所以 `newCap - 1` 的二进制表示比 `oldCap - 1` 多一个高位 `1`(例如,16 -> 15(`1111`), 扩容到 32 -> 31(`11111`))。 * 节点在新数组中的位置只可能是两个值之一:**原位置 `oldIndex`** 或 **原位置 + 原容量 `oldIndex + oldCap`**。 * **判断依据:** 计算 `(e.hash & oldCap)`(注意这里是 `oldCap`,不是 `oldCap-1`): * 结果为 **0**:节点在新数组中的位置 = **原位置 (`oldIndex`)**。 * 结果**不为 0**:节点在新数组中的位置 = **原位置 + 原容量 (`oldIndex + oldCap`)**。 * **优点:** 省去了重新计算 `hash` 值的开销(`hash` 值本身不变),只需要判断一次位运算 (`hash & oldCap`) 的结果,就可以高效地将节点分配到新数组的两个桶中(要么在原桶,要么在原桶+oldCap 位置)。这个优化显著提升了扩容效率[^3]。 * 树节点在移动过程中,如果树变得太小(节点数 <= `UNTREEIFY_THRESHOLD = 6`),会被转换回链表。 ### 总结流程图 ```mermaid graph TD A[调用 putkey, value] --> B[计算 key 的 hash = key.hashCode ^ hashCode>>>16] B --> C[计算桶索引 index = n-1 & hash] C --> D{桶 table[index]为空?} D -- Yes --> E[创建新 Node 插入] D -- No --> F{桶中第一个节点 key 匹配? hash 和 equals} F -- Yes --> G[覆盖 value] F -- No --> H{桶结构是红黑树?} H -- Yes --> I[调用 putTreeVal 插入/覆盖] I -- 新节点插入? --> J[增加 size] H -- No 链表 --> K[遍历链表] K --> L{找到相同 key?} L -- Yes --> M[覆盖 value] L -- No 到尾节点 --> N[尾部插入新 Node] N --> O[增加 size] O --> P{链表长度 >= 8?} P -- Yes --> Q{table 长度 >= 64?} Q -- Yes --> R[转换链表为红黑树] Q -- No --> S[优先触发 resize 扩容] P -- No --> T[继续后续流程] E & J & R --> U[增加 size] U --> V{size > threshold?} V -- Yes --> W[触发 resize 扩容] V -- No --> X[流程结束] G & M -- 覆盖操作 --> X ``` ### 关键点回顾 1. **扰动哈希:** `(h = key.hashCode()) ^ (h >>> 16)` 提升散列均匀性。 2. **索引计算:** `index = (table.length - 1) & hash` 高效定位桶。 3. **冲突解决:** 通过链表或红黑树解决同一个桶内的哈希冲突。 4. **树化条件:** 链表长度 >= 8 **且** 数组长度 >= 64 时才树化,否则优先扩容。 5. **扩容触发:** `size > threshold` (capacity * loadFactor)。 6. **扩容优化:** 利用 `(e.hash & oldCap) == 0` 高效定位节点在新数组的位置(原位置或原位置+oldCap),避免重新计算hash。 7. **覆盖规则:** Key 相同(`hash` 相等且 `key.equals()` 为 true)时覆盖旧值。 通过以上步骤和优化,HashMap 的 `put` 方法能够在平均情况下实现高效的 $O(1)$ 时间复杂度(常数时间)的插入操作,并在冲突较多时通过链表或红黑树维持可接受的性能。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值