前言
除了基本的数组,还有其他高级的数据结构,用于更复杂的数据存储和检索需求。其中,HashMap
是 Java 集合框架中的一部分,用于存储键值对(key-value pairs)。HashMap
允许我们通过键来快速查找和检索值,类似于字典或关联数组的概念。HashMap
在实际编程中广泛应用于各种场景,包括缓存、数据库索引、数据存储等。
HashMap
和 HashTable
HashMap
和 HashTable
都是键值对存储的数据结构,它们可以用于快速查找和检索数据。虽然它们在用法上很相似,但也存在一些重要的区别:
-
HashMap
:HashMap
是 Java 集合框架中的一部分,它允许空键(key)和空值(value),并且是非线程安全的。HashMap
使用了哈希表的数据结构,能够在常数时间内查找元素。 -
HashTable
:HashTable
也是键值对存储的数据结构,但它不允许空键和空值,而且是线程安全的。HashTable
使用了类似于HashMap
的哈希表实现,但在多线程环境下性能更好。
示例代码 - 使用 HashMap
让我们看一下如何使用 HashMap
存储和检索数据:
// 创建一个 HashMap
HashMap<String, Integer> scores = new HashMap<>();// 插入键值对
scores.put("Alice", 95);
scores.put("Bob", 88);
scores.put("Charlie", 92);// 检索值
int aliceScore = scores.get("Alice"); // 获取 Alice 的成绩
HashMap
底层代码解析
理解 HashMap
内部工作原理的关键部分是研究其核心方法,如 resize
、treeifyBin
和 putVal
。以下是这些方法的简要源码示例和解释:
1. resize
方法
resize
方法用于对 HashMap
进行扩容。当键值对数量达到阈值时,调用 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; // 新容量和新阈值if (oldCap > 0) { // 如果旧容量大于0if (oldCap >= MAXIMUM_CAPACITY) { // 如果旧容量已经达到最大值threshold = Integer.MAX_VALUE; // 阈值设置为最大值,禁止再次扩容return oldTab; // 返回旧的桶数组} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // 扩容为原容量的两倍,并更新新阈值} else if (oldThr > 0) // 如果旧的阈值大于0newCap = oldThr; // 使用阈值作为新容量else { // 如果没有指定容量和阈值,使用默认值newCap = DEFAULT_INITIAL_CAPACITY; // 默认初始容量newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 默认阈值}if (newThr == 0) { // 如果新阈值为0float ft = (float)newCap * loadFactor; // 计算新阈值newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE); // 如果未超过最大容量,使用计算值,否则使用最大值}threshold = newThr; // 更新阈值@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; // 清空旧桶位置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;if ((e.hash & oldCap) == 0) { // 如果哈希码对旧容量求与为0if (loTail == null)loHead = e; // 放入低位链表头部elseloTail.next = e; // 放入低位链表尾部loTail = e; // 更新低位链表尾节点}else { // 否则放入高位链表if (hiTail == null)hiHead = e;elsehiTail.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; // 返回新的桶数组
}
resize
方法首先计算新的容量和阈值。- 如果旧表(
oldTab
)不为空,它将遍历旧表的桶,根据哈希值重新分配节点到新表(newTab
)中。 - 如果节点的数量超过一定阈值,它可能会将链表转化为红黑树,以提高性能。
2. treeifyBin
方法
treeifyBin
方法用于将链表转换为红黑树,以提高查找性能。该方法通常在链表长度超过8时被调用。
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);}
}
treeifyBin
方法首先检查表的容量,如果小于MIN_TREEIFY_CAPACITY
,则会调用resize
方法进行扩容。- 如果表的容量足够大,它将遍历桶中的链表,将每个节点替换为红黑树节点,并构建红黑树。
3. pubVal
方法
putVal
方法是 HashMap
中用于向哈希表中添加键值对的核心方法。
/*** Associates the specified value with the specified key in this map.* If the map previously contained a mapping for the key, the old* value is replaced.** @param hash hash for key* @param key the key* @param value the value to put* @param onlyIfAbsent if true, don't change existing value* @param evict if false, the table is in creation mode.* @return previous value, or null if none*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {Node<K,V>[] tab; // 声明桶数组Node<K,V> p; // 声明当前节点int n, i; // n为桶数组的长度,i为计算出的槽位索引// 如果桶数组为空或长度为0,需要初始化//判断tab是不是为空,如果为空,则将容量进行初始化,也就是说,初始换操作不是在new HashMap()的时候进行的,而是在第一次put的时候进行的if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 计算槽位索引:初始化操作以后,根据当前key的哈希值算出最终命中到哪个桶上去,并且这个桶上如果没有元素的话,则直接new一个新节点放进去if ((p = tab[i = (n - 1) & hash]) == null) // 如果当前槽位为空tab[i] = newNode(hash, key, value, null); // 直接创建新节点并放入槽位else {Node<K,V> e; K k;// 先判断一下这个桶里的第一个Node元素的key是不是和即将要存的key值相同,如果相同,则把当前桶里第一个Node元素赋值给e,这个else的最下边进行了判断,如果e!=null就执行把新value进行替换的操作if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p; // 如果找到相同键的节点,将e指向该节点else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {for (int binCount = 0; ; ++binCount) {// 如果遍历到链表尾部仍未找到相同键的节点,将新节点添加到链表尾部if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);// 如果链表长度达到阈值,将链表转化为红黑树if (binCount >= TREEIFY_THRESHOLD - 1)treeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break; // 如果找到相同键的节点,退出循环p = e;}}/*** 只要e不为空,说明要插入的key已经存在了,覆盖旧的value值,然后返回原来oldValue* 因为只是替换了旧的value值,并没有插入新的元素,所以不需要下边的扩容判断,直接* return掉*/if (e != null) { // 如果找到相同键的节点V oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value; // 替换值afterNodeAccess(e);return oldValue; // 返回旧值}}++modCount;/*** 判断容量是否已经到了需要扩充的阈值了,如果到了,则进行扩充* 如果上一步已经判断key是存在的,只是替换了value值,并没有插入新的元素,所以不需要判断* 扩容,不会走这一步的*/if (++size > threshold)resize(); // 如果超过阈值,进行扩容afterNodeInsertion(evict);return null; // 返回null表示插入成功
}
putVal
方法用于将键值对放入HashMap
中。此方法涵盖了查找、替换、插入和扩容等关键操作,是HashMap
内部工作的核心部分。
注意事项
在使用 HashMap
时,有一些重要的注意事项和最佳实践,以确保正确性和性能。以下是一些关键的注意事项:
-
线程安全性:
HashMap
不是线程安全的数据结构,如果在多线程环境下使用,需要考虑采取适当的同步机制,或者使用线程安全的替代品,如ConcurrentHashMap
。 -
键的不可变性:
HashMap
中的键应该是不可变的对象。如果键发生了变化,可能导致无法正常获取或删除值。 -
哈希冲突:哈希冲突是指不同的键映射到相同的哈希桶。为了处理冲突,
HashMap
使用链表或红黑树。为了获得良好的性能,尽量避免大量哈希冲突,可以考虑使用良好的哈希函数或合适的数据分布。 -
哈希函数重写:如果自定义对象作为键,应该确保重写了
hashCode()
和equals()
方法,以确保正确的哈希和相等性比较。 -
初始化容量和负载因子:在创建
HashMap
时,可以指定初始容量和负载因子。根据预期的键值对数量,选择适当的初始容量可以提高性能。负载因子是用于触发扩容的阈值,通常选择合适的默认值即可。 -
遍历:在遍历
HashMap
时,尽量不要修改其结构(添加或删除键值对),否则可能会导致不确定的行为或异常。 -
Null 键和 Null 值:
HashMap
允许键和值为 null。但要小心处理 null 键,以防止NullPointerException
。 -
性能考虑:
HashMap
在查找操作上有很好的性能,但在插入和删除操作上可能会有较差的性能,特别是在存在大量哈希冲突时。在需要频繁插入和删除的场景中,可以考虑使用LinkedHashMap
或ConcurrentHashMap
。 -
扩容代价:
HashMap
在达到负载因子阈值时会自动进行扩容,这涉及到重新分配键值对到新的桶数组。频繁的扩容操作可能会影响性能,因此应根据应用的需求选择适当的初始容量和负载因子。 -
equals 和 hashCode 方法:如果自定义对象作为键,确保正确实现了
equals
和hashCode
方法,以便正确地比较和查找键。
总的来说,HashMap 是一个非常有用的数据结构,但在使用时需要谨慎考虑上述注意事项,以确保其正确性和性能。根据应用的需求,还可以考虑使用其他实现了特定场景需求的 Map 接口的实现类,如 ConcurrentHashMap
、LinkedHashMap
等。
JDK1.7和1.8对比
HashMap
在 JDK 1.7 和 JDK 1.8 中都有存在,但在 JDK 1.8 中进行了一些重要的改进。以下是 JDK 1.7 和 JDK 1.8 中 HashMap
的主要区别:
-
数据结构:
-
JDK 1.7:JDK 1.7 中的
HashMap
使用数组 + 链表的数据结构。具体说,它使用数组存储桶(buckets
),每个桶存储一个链表。这意味着当多个键映射到同一个桶时,它们会在同一个链表上存储。 -
JDK 1.8:JDK 1.8 中的
HashMap
在链表长度达到一定阈值(8)时,将链表转化为红黑树。这一改进在处理大量键值对时提高了查找性能,因为红黑树的查找时间复杂度为 O(log n)。
-
-
哈希冲突解决:
-
JDK 1.7:JDK 1.7 使用链表来解决哈希冲突。当多个键映射到同一个桶时,它们会形成一个链表,需要遍历链表来查找。
-
JDK 1.8:JDK 1.8 在链表长度达到一定阈值时,会将链表转化为红黑树,这大大提高了处理长链表的性能。
-
-
并发性能:
-
JDK 1.7:JDK 1.7 中的
HashMap
不是线程安全的,如果多个线程同时操作一个HashMap
,可能会导致数据不一致或死锁等问题。为了在多线程环境下使用HashMap
,需要自行添加同步机制。 -
JDK 1.8:JDK 1.8 中的
HashMap
在处理并发操作时进行了优化。它引入了更高效的锁机制,例如分段锁和 CAS 操作,以提高并发性能。此外,JDK 1.8 还引入了ConcurrentHashMap
类,专门用于高并发环境。
-
-
迭代性能:
-
JDK 1.7:JDK 1.7 中的
HashMap
在迭代时性能较差,因为即使没有哈希冲突,它也需要遍历整个桶数组,包括空桶。 -
JDK 1.8:JDK 1.8 中的
HashMap
在迭代时性能得到了提升,特别是在没有哈希冲突的情况下。这是由于它使用了更好的数据结构和算法来加速迭代。
-
-
空间利用:
-
JDK 1.7:JDK 1.7 中的
HashMap
对空间的利用不是很高,因为桶的数量必须是 2 的幂次方,可能会导致浪费空间。 -
JDK 1.8:JDK 1.8 中的
HashMap
在一定程度上改进了空间利用,通过采用树结构来存储哈希冲突的键值对,减少了空间浪费。
-
总的来说,JDK 1.8 中的 HashMap
在性能和并发性能上有重大改进,特别是在处理大量数据和高并发访问时表现更优越。因此,如果使用 Java 8 或更高版本,通常建议使用 JDK 1.8 中的 HashMap
实现。但要注意,如果需要在多线程环境中使用 HashMap
,最好考虑使用 ConcurrentHashMap
或其他线程安全的数据结构。
结语:
本文已经提供了有关 HashMap
的深入信息,包括其底层代码和注意事项。下一章将继续探讨 HashTable,以便能够全面了解这两个重要的数据结构。
如果您有任何疑问、建议或更正,请随时都可以留言或私信作者,我们将非常乐意为您提供帮助并改进文章。期待在下一章中继续分享有价值的知识。