JAVA:HashMap设计原理(附部分源码分析)
HashMap 是 Java 集合框架中最常用的映射表实现,几乎所有 Java 开发者都用过它存储键值对。但你是否想过:为什么它的查询效率那么高?为什么允许 null 键值?为什么多线程下会出问题?
本文将从 HashMap 的核心特性出发,结合 JDK 8 源码解析其实现原理,最后探讨实际使用场景与避坑指南,建立对 HashMap 的全局认知。
一、HashMap 核心特性
HashMap 本质是基于哈希表的键值对存储结构,其核心特性可概括为:
- 键值对存储:以
key-value形式存储数据,通过key快速查询value。 - 哈希表结构:底层由「数组 + 链表 + 红黑树」组成,平衡查询与插入效率。
- 允许 null 键值:支持
key和value为null(key仅允许一个null)。 - 无序性:存储顺序与插入顺序无关,遍历结果不保证有序。
- 线程不安全:多线程并发修改可能导致数据错乱或死循环。
- 动态扩容:当元素数量达到阈值时,自动扩容为原来的 2 倍。
- 高效性:理想情况下,
put、get、remove操作的时间复杂度为O(1)。
二、源码解析:特性如何通过代码实现?
HashMap 的源码围绕「哈希表的高效操作」设计,逐个特性拆解其实现逻辑。
特性 1:键值对存储——Node 节点与 put 方法
HashMap 的核心功能是存储 key-value 键值对,这一特性通过内部的 Node 类和 put 方法实现。
源码证明:
-
存储单元:
Node类HashMap用Node类封装键值对,每个Node包含哈希值、键、值、下一个节点引用(用于链表):static class Node<K,V> implements Map.Entry<K,V> { final int hash; // key 的哈希值(预计算,提高效率) 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; } // getKey()、getValue()、setValue() 等方法省略 } -
核心方法:
put(K key, V value)put方法负责将键值对存入哈希表,流程可概括为:「计算哈希值 → 确定存储位置 → 处理哈希冲突 → 插入节点」:public V put(K key, V value) { // 计算 key 的哈希值,调用 putVal 实际处理 return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 1. 若哈希表(table 数组)未初始化,先初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 2. 计算索引 i = (n - 1) & hash,若该位置为空,直接插入新 Node if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 3. 该位置已存在节点(哈希冲突),处理冲突 Node<K,V> e; K k; // 3.1 若 key 相同(哈希值+equals),直接覆盖 value if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 3.2 若当前节点是红黑树节点,调用树的插入方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 3.3 否则是链表,遍历链表 else { for (int binCount = 0; ; ++binCount) { // 3.3.1 找到链表尾部,插入新节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 若链表长度超过 8,尝试转为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8 treeifyBin(tab, hash); break; } // 3.3.2 若链表中存在相同 key,跳出循环准备覆盖 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 4. 若存在相同 key,覆盖 value 并返回旧值 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); // 空实现,供 LinkedHashMap 重写 return oldValue; } } ++modCount; // 修改次数(用于快速失败机制) // 5. 若元素数量超过阈值,触发扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); // 空实现,供 LinkedHashMap 重写 return null; }
结论:通过 Node 封装键值对,put 方法处理哈希计算、冲突解决和插入逻辑,实现了键值对的存储功能。
特性 2:哈希表结构——数组 + 链表 + 红黑树
HashMap 底层不是单纯的数组或链表,而是数组(哈希桶)+ 链表 + 红黑树的混合结构,这种设计是为了平衡「哈希冲突」带来的性能问题。
源码证明:
-
数组(哈希桶)
哈希表的主体是transient Node<K,V>[] table;(数组),称为「哈希桶」。数组的每个元素是链表或红黑树的头节点,索引通过key的哈希值计算:// 计算索引:哈希值与数组长度-1 取模(等价于 hash % n,但效率更高) int index = (n - 1) & hash; -
链表:解决哈希冲突
当不同key计算出相同索引时(哈希冲突),HashMap会将新节点挂在已有节点的后面(俗称拉链),形成链表。源码中通过Node的next字段实现:// 链表插入新节点(putVal 方法片段) p.next = newNode(hash, key, value, null); -
红黑树:优化长链表查询
当链表长度超过阈值(TREEIFY_THRESHOLD = 8),且数组长度 >= 64 时,链表会转为红黑树(TreeNode),将查询时间复杂度从O(n)优化为O(log n):// 树化方法 final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 若数组长度 < 64,先扩容而非树化 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY = 64 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); // 红黑树构建逻辑 } }反之,当红黑树节点数量减少到
UNTREEIFY_THRESHOLD = 6时,会转回链表,避免树结构的维护成本。
在 HashMap 中,链表转红黑树的阈值设为 8,红黑树转链表的阈值设为 6,这个设计并非随意选择,而是基于哈希冲突的概率分布、数据结构的性能平衡和避免频繁转换的抖动问题综合考量的结果。
- 一、为什么链表转红黑树的阈值是 8?
这个阈值的设定与哈希表中链表长度的概率分布密切相关。
HashMap 的底层哈希表通过哈希函数将 key 映射到数组索引,理想情况下,key 的分布是均匀的,链表长度应该很短。但实际中,由于哈希函数的局限性或 key 本身的特性,可能出现哈希冲突,导致部分链表过长。
Java 开发者在设计时参考了泊松分布(一种描述随机事件发生概率的分布模型):在理想的哈希函数下,链表中节点数量的概率分布近似泊松分布。根据计算,当负载因子为默认的 0.75 时:
链表长度为 8 的概率约为 0.0000001(千万分之一);
更长的链表(如 9、10)出现的概率更低。
这意味着:在正常情况下,链表长度几乎不可能达到 8。一旦达到 8,说明该链表的哈希冲突异常频繁(可能是哈希函数设计不佳,或 key 的哈希值分布极不均匀),此时将链表转为红黑树(查询时间复杂度从 O(n) 优化为 O(log n))才有实际意义。
简言之,8 是一个 “小概率事件阈值”—— 只有在极特殊情况下才会触发树化,避免不必要的性能开销(红黑树的维护成本比链表高)。- 二、为什么红黑树转链表的阈值是 6?
这个阈值的核心目的是避免频繁的 “树化 - 反树化” 抖动。
如果红黑树转链表的阈值也设为 8(与树化阈值相同),会出现一个问题:当红黑树的节点数量在 8 附近波动时(例如 8→7→8→7),会频繁触发 “红黑树→链表→红黑树→链表” 的转换。这种频繁转换会带来大量不必要的性能开销(红黑树的插入、删除需要旋转平衡,链表转树 / 树转链表也需要遍历节点)。
将反树化阈值设为 6,可以在树化阈值(8)和反树化阈值(6)之间形成一个缓冲区间(6~8):
当红黑树节点数量减少到 6 及以下时,才会转回链表(此时节点数量少,链表的性能更优);
若节点数量在 6~8 之间波动(如 7→8→7),则不会触发转换,避免抖动。
结论:数组提供快速定位能力,链表处理少量哈希冲突,红黑树优化大量冲突场景,三者结合让 HashMap 在各种情况下都有较高效率。
特性 3:允许 null 键值——对 null 的特殊处理
HashMap 允许 key 和 value 为 null,其中 key 仅允许一个 null(重复插入会覆盖),这是通过对 null 键的哈希值特殊处理实现的。
源码证明:
null键的哈希值hash(key)方法专门处理null键,返回哈希值 0:static final int hash(Object key) { int h; // 若 key 为 null,哈希值为 0;否则计算 key.hashCode() 的二次哈希 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
h >>> 16 配合 ^ 运算,本质是用低成本的位操作提升哈希值的随机性:
让高位信息参与索引计算,避免因低位重复导致的冲突;
仅通过一次右移和一次异或,几乎不增加性能开销;
在数组容量较小时(日常开发的常见场景),效果尤为明显。
这是一种 “用最小代价换取更均匀哈希分布” 的设计,体现了 HashMap 源码对性能的极致优化。
由于 n 通常较小(默认 16,扩容后也是 32、64 等 2 的幂),n-1 的二进制是低位全为 1,高位全为 0。例如 n=16 时,n-1=15(二进制 0000 0000 0000 0000 0000 0000 0000 1111)。
此时,(n-1) & hash 实际上只用到了哈希值的低几位(如 n=16 时只用低 4 位)。如果直接使用 key.hashCode() 作为哈希值,会导致:
哈希值的高位信息被完全忽略;
若不同 key 的低几位相同(但高位不同),会计算出相同的索引,导致哈希冲突。
而 h ^ (h >>> 16) 通过混合高低位,让高位信息影响低位,从而让索引计算更均匀,减少冲突。
-
null键的存储位置
由于哈希值为 0,null键会被存储在数组索引 0 的位置:// 索引计算为 (n-1) & 0 = 0 int index = (n - 1) & hash; // hash 为 0 时,index = 0 -
null键的唯一性
当插入第二个null键时,会触发key相等的判断(key == null && key == null),从而覆盖原有值:// putVal 方法中判断 key 相等的逻辑 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 若已存在相同 key(包括 null),则覆盖 value
结论:通过将 null 键的哈希值固定为 0,HashMap 实现了对 null 键的支持,且保证 null 键的唯一性。
特性 4:无序性——存储位置由哈希值决定
HashMap 的元素存储顺序与插入顺序无关,遍历结果是「哈希桶顺序 + 链表/树顺序」,这是因为存储位置由 key 的哈希值决定,而非插入时间。
源码证明:
-
无维护顺序的字段
HashMap中没有记录插入顺序的变量(如链表的前后指针),对比LinkedHashMap的Entry类:// LinkedHashMap 的 Entry 类(维护插入顺序) static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; // 记录前后节点,维护顺序 Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }而
HashMap的Node类只有next字段(用于解决冲突),没有顺序相关的指针。 -
遍历顺序由哈希桶决定
HashMap的遍历(如keySet()、values()、entrySet())是按数组索引顺序遍历哈希桶,再遍历每个桶中的链表/红黑树,与插入顺序无关:// keySet 迭代器的核心逻辑(简化) public final Iterator<K> iterator() { return new KeyIterator(); } final class KeyIterator extends HashIterator<K> { public final K next() { return nextNode().key; } } abstract class HashIterator<E> implements Iterator<E> { Node<K,V> next; // 下一个节点 Node<K,V> current; // 当前节点 int expectedModCount; // 用于快速失败 int index; // 当前数组索引 HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; // 从数组索引 0 开始,找到第一个非空桶 if (t != null && size > 0) { do {} while (index < t.length && (next = t[index++]) == null); } } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; // ... 省略快速失败检查 ... if ((next = (current = e).next) == null && (t = table) != null) { // 当前桶遍历完,继续下一个桶 do {} while (index < t.length && (next = t[index++]) == null); } return e; } }
结论:由于 HashMap 不维护插入顺序,且存储位置由哈希值决定,因此遍历结果是无序的。
特性 5:线程不安全——缺乏同步机制
HashMap 是非线程安全的,多线程并发修改(如同时 put 或 resize)可能导致数据错乱、死循环等问题,根源是其方法没有同步保护。
源码证明:
-
无同步修饰
put、get、resize等核心方法均未使用synchronized或其他同步机制,多个线程可同时修改table数组或size等变量:// put 方法没有同步修饰 public V put(K key, V value) { ... } // size 是普通变量,非 volatile transient int size; -
扩容时的线程安全问题
最经典的线程安全问题出现在resize方法(扩容)中。JDK 7 及之前的transfer方法在多线程扩容时,可能导致链表形成环(死循环);JDK 8 虽然修复了环的问题,但仍可能出现数据丢失:// JDK 8 resize 方法中迁移节点的逻辑(简化) 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; }多线程同时迁移节点时,可能导致节点被覆盖或丢失。
-
快速失败机制(fail-fast)
HashMap通过modCount变量检测并发修改:当迭代时发现modCount与预期值不一致,会抛出ConcurrentModificationException,这也从侧面证明其不支持并发修改:// 迭代器中的快速失败检查 if (modCount != expectedModCount) throw new ConcurrentModificationException();
modCount是实现 “快速失败”(fail-fast)机制的核心变量,用于记录集合结构性修改的次数(如put、remove、clear等改变元素数量的操作)。
当使用迭代器(如entrySet().iterator())遍历 HashMap 时,迭代器会先记录当前的modCount值(称为expectedModCount)。遍历过程中,每次访问下一个元素前,迭代器都会检查modCount是否与expectedModCount相等。
若不等,说明遍历期间集合被结构性修改(可能是其他线程修改,或当前线程在迭代中直接修改集合),此时迭代器会立即抛出ConcurrentModificationException,终止遍历。
这种机制的目的是及时暴露并发修改问题,避免迭代器继续操作已被篡改的集合,防止数据不一致或不可预期的行为。但它并非线程安全的解决方案,仅能检测修改并快速失败,无法保证遍历的原子性。
需注意:迭代器自身的 remove 方法会同步更新 expectedModCount,不会触发异常;而直接调用集合的 remove 则会导致失败。
结论:HashMap 未提供同步机制,多线程并发修改会导致数据问题,因此仅适用于单线程环境。
特性 6:动态扩容——resize 方法的实现
HashMap 会在元素数量超过阈值(threshold = 容量 * 负载因子)时自动扩容,新容量为旧容量的 2 倍,以保证哈希表的稀疏性,减少冲突。
源码证明:
-
扩容触发条件
putVal方法中,当元素数量size超过threshold时,调用resize()扩容:if (++size > threshold) resize(); -
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; // 1. 计算新容量和新阈值 if (oldCap > 0) { // 若旧容量已达最大(1<<30),则阈值设为 Integer.MAX_VALUE,不再扩容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 新容量 = 旧容量 * 2(左移 1 位) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 阈值也翻倍 } else if (oldThr > 0) // 初始化时指定了阈值(构造方法传入 initialCapacity) newCap = oldThr; else { // 无参构造,使用默认值(容量 16,阈值 16*0.75=12) newCap = DEFAULT_INITIAL_CAPACITY; // DEFAULT_INITIAL_CAPACITY = 16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // DEFAULT_LOAD_FACTOR = 0.75 } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE; } threshold = newThr; // 2. 创建新数组,并迁移旧节点 @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; // 帮助 GC 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 { // 链表节点迁移(根据哈希值分为两组) Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 哈希值与旧容量按位与,结果为 0 则留在原索引,否则迁移到新索引(j + oldCap) 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; }关键优化:链表迁移时通过
(e.hash & oldCap) == 0判断节点新位置,避免重新计算哈希,效率更高。
结论:通过 resize 方法,HashMap 实现了动态扩容,保证哈希表始终有足够空间,维持高效的查询和插入性能。
tableSizeFor 方法的目的及位运算解析
方法核心目的tableSizeFor 是 HashMap 中的一个核心工具方法,其作用是计算大于或等于指定整数 cap 的最小 2 的幂值。
在 HashMap 中,哈希表的容量(底层数组长度)必须是 2 的幂(如 16、32、64 等),这是为了通过 (n-1) & hash 高效计算数组索引(等价于取模运算但性能更优)。当用户指定初始容量(如 new HashMap(10))时,tableSizeFor 会将其调整为最接近的 2 的幂(如 16)。
核心代码与位运算解析
JDK 8 中 tableSizeFor 的实现如下:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
关键位运算步骤解析(以 cap = 10 为例):
-
初始调整:
n = cap - 1
将输入值减 1(10-1=9),避免cap本身已是 2 的幂时多扩容一次(如cap=16时,减 1 后后续操作不会改变值)。 -
位运算填充高位:
通过一系列无符号右移(>>>)和按位或(|)操作,将n从最高位的 1 开始,填充其后所有低位为 1:n = 9(二进制1001)n |= n >>> 1:右移 1 位得0100,或运算后1001 | 0100 = 1101(13)n |= n >>> 2:右移 2 位得0011,或运算后1101 | 0011 = 1111(15)- 后续
>>>4、>>>8、>>>16操作因高位已全为 1,结果不变。
-
最终调整:
n + 1
经过填充后,n是“比目标值小 1 的最大全 1 二进制数”,加 1 后得到最小的 2 的幂(15 + 1 = 16)。
位运算的意义
- 高效计算 2 的幂:通过位运算替代循环判断,时间复杂度为
O(1)(固定 5 次移位操作),性能远高于遍历。 - 覆盖所有整数范围:连续右移 1、2、4、8、16 位,可确保 32 位整数中从最高位 1 到最低位的所有位都被填充为 1,适用于任何
int类型的输入。 - 边界处理:最终判断若结果超过最大容量(
MAXIMUM_CAPACITY = 1<<30),则返回最大值,避免溢出。
tableSizeFor 通过巧妙的位运算,快速将任意整数转换为最接近的 2 的幂,为 HashMap 提供了高效的容量初始化机制,是哈希表实现中“性能优化”与“数学特性”结合的经典案例。
三、大局观:HashMap 的设计哲学
HashMap 的设计体现了「时间与空间的平衡艺术」:
- 哈希表的选择:数组提供
O(1)的随机访问能力,但需要连续内存;链表/红黑树解决哈希冲突,平衡插入和查询效率。 - 动态扩容机制:通过阈值(容量 * 负载因子)触发扩容,负载因子
0.75是对「空间利用率」和「冲突概率」的折中(负载因子越低,冲突越少但空间浪费越多)。 - 树化与反树化:当链表过长时转为红黑树(提高查询效率),过短时转回链表(降低维护成本),兼顾不同数据量场景。
这种设计让 HashMap 在大多数场景下都能提供接近 O(1) 的操作效率,成为 Java 中最常用的映射表实现。
四、典型使用场景
HashMap 适合以下场景:
-
快速键值查询
如缓存系统(通过key快速获取缓存值)、配置表(通过名称快速获取配置项)等。例如:// 缓存用户信息 Map<String, User> userCache = new HashMap<>(); userCache.put("uid1001", new User("张三", 20)); User user = userCache.get("uid1001"); // 快速查询 -
临时数据存储
如处理请求时存储中间结果(无需持久化,用完即弃),例如:// 处理订单时存储临时计算结果 Map<String, BigDecimal> orderStats = new HashMap<>(); orderStats.put("totalAmount", calculateTotal()); orderStats.put("discount", calculateDiscount()); -
去重或分组
利用key的唯一性,实现快速去重或分组,例如:// 对列表去重 List<String> list = Arrays.asList("a", "b", "a", "c"); Map<String, Boolean> uniqueMap = new HashMap<>(); for (String s : list) { uniqueMap.put(s, true); } List<String> uniqueList = new ArrayList<>(uniqueMap.keySet()); // ["a", "b", "c"] -
替代多参数传递
当方法需要传递多个参数时,可用HashMap封装,简化方法签名,例如:// 用 Map 传递查询参数 Map<String, Object> params = new HashMap<>(); params.put("pageNum", 1); params.put("pageSize", 10); params.put("keyword", "java"); List<Article> articles = articleDao.query(params);
五、注意事项与避坑指南
HashMap 虽易用,但使用不当可能导致性能问题或逻辑错误,需注意以下几点:
1. 线程安全问题:避免多线程并发修改
HashMap 线程不安全,多线程环境下应使用:
ConcurrentHashMap:专为并发设计,效率高于同步的HashMap;Collections.synchronizedMap(new HashMap<>()):通过同步包装器实现线程安全,但性能较低。
错误示例:
// 多线程同时 put 可能导致数据错乱
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
map.put(Thread.currentThread().getName() + j, j);
}
}).start();
}
正确示例:
Map<String, Integer> map = new ConcurrentHashMap<>(); // 线程安全
2. 初始容量设置:避免频繁扩容
HashMap 扩容时需要迁移所有节点,成本较高。若已知数据量,应在初始化时指定合适的容量,减少扩容次数:
// 已知约有 1000 个元素,初始容量设为 1000 / 0.75 ≈ 1334(向上取整)
Map<String, Object> map = new HashMap<>(1334);
公式:初始容量 = 预估元素数 / 负载因子(默认负载因子 0.75)。
3. 键的 hashCode 与 equals 方法:必须正确重写
HashMap 依赖 key 的 hashCode() 计算存储位置,依赖 equals() 判断 key 是否相同。若未正确重写,会导致:
hashCode()相同但equals()不同的key存储在同一位置(冲突);equals()相同但hashCode()不同的key被视为不同键(重复存储)。
正确重写示例:
class User {
private String id;
private String name;
// 重写 hashCode:结合关键字段计算
@Override
public int hashCode() {
return Objects.hash(id); // 以 id 作为唯一标识
}
// 重写 equals:关键字段相等则视为相同
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
}
4. 避免使用可变对象作为键
若 key 是可变对象(如自定义类、Date 等),修改 key 的字段会导致 hashCode() 变化,后续无法通过该 key 找到对应值:
class MutableKey {
private int value;
// 省略构造器、getter、setter、hashCode、equals
}
// 错误示例:修改 key 导致无法查询
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey(1);
map.put(key, "value");
key.setValue(2); // 修改 key 的字段,hashCode 变化
System.out.println(map.get(key)); // 输出 null(无法找到)
解决方案:使用不可变对象作为 key(如 String、Integer 等)。
5. 遍历方式选择:根据需求选高效方式
HashMap 有多种遍历方式,性能和适用场景不同:
entrySet():同时获取key和value,效率最高;keySet()+get(key):需二次查询,效率较低;values():仅需value时使用。
推荐方式:
// 高效遍历键值对
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 处理逻辑
}
六、总结
HashMap 是基于「数组 + 链表 + 红黑树」的哈希表实现,通过哈希值快速定位元素,平衡了查询与插入效率。其核心特性包括键值对存储、支持 null 键值、无序性、线程不安全、动态扩容等,这些特性都能在源码中找到明确实现。
在实际使用中,HashMap 适合单线程环境下的快速键值查询、临时数据存储等场景,但需注意线程安全、初始容量设置、键的方法重写等问题。理解 HashMap 的设计原理,不仅能帮助我们写出更高效的代码,更能体会到数据结构设计中「时间与空间平衡」的智慧。
Studying will never be ending.
▲如有纰漏,烦请指正~~
更多推荐
所有评论(0)