1. 概述
HashMap
是 Java 集合框架中用于存储键值对(key-value pairs)的类。它基于哈希表实现,通过计算键的哈希值来确定键值对在数组中的存储位置,从而实现快速的查找和存取。
2. 用途
HashMap
广泛应用于各种需要快速根据键查找值的场景,如缓存系统、数据库查询结果的临时存储、对象映射等。
3. 基本特性
- 键值对存储:
HashMap
存储的是键值对(key-value pair)的映射关系。 - 无序性:不保证映射的顺序,特别是它不保证该顺序恒久不变。
- 非同步:
HashMap
不是线程安全的,如果需要在多线程环境下使用,可以考虑使用Collections.synchronizedMap()
方法进行包装,或者使用ConcurrentHashMap
。 - 可存储 null 键和 null 值:HashMap 允许一个 null 键和多个 null 值。
4. 数据结构
HashMap
的数据结构主要由数组和链表(或红黑树)组成。数组用于存储桶(bucket),每个桶中可能包含链表或红黑树,用于存储具有相同哈希值的键值对。
- JDK1.7(数组、链表)
- JDK1.8(数组、链表、红黑树)
5. 底层实现原理
HashMap
的底层实现原理基于哈希表。当插入一个键值对时,HashMap
首先会计算键的哈希值,然后根据哈希值和数组长度通过某种算法(通常是除留余数法)计算出一个索引值,这个索引值就是键值对在数组中的存储位置。如果同一个位置(即同一个桶)上已存在其他键值对,则会将它们通过链表(或红黑树)的形式连接起来。
5.1 哈希函数和索引计算
- 当向
HashMa
p中添加一个键值对时,HashMap
首先使用哈希函数(通常是hashCode()
方法)计算键的哈希值。然后,它使用这个哈希值和数组长度来计算在数组中的索引位置(通常使用模运算或位运算)。 - 在Java 8及更高版本中,索引计算通常使用位运算,这样可以提高性能。具体地,它使用
hash & (length - 1)
来计算索引,其中hash
是键的哈希值,length
是数组的长度。
5.2 扩容机制
- 当
HashMap
中的元素数量(键值对的数量)超过某个阈值(通常是数组长度乘以加载因子)时,HashMap
会进行扩容。扩容的过程包括创建一个新的、更大的数组,并将原始数组中的所有元素重新哈希并插入到新的数组中。 - 在Java 8及更高版本中,当扩容时,数组的大小会翻倍,但加载因子保持不变。重新哈希和重新插入的过程可能会导致一些键值对的顺序发生变化,因此
HashMap
不是有序的。
5.3 负载因子(加载因子)
负载因子(Load Factor)是HashMap
中的一个重要概念,它决定了数组的填充程度,并影响了HashMap
的性能和空间利用率。
- 负载因子是一个介于0(不包括)和1(不包括)之间的浮点数,用来衡量
HashMap
数组的充满程度。它的定义是:负载因子 = 元素个数 / 数组大小。这里的元素个数是指HashMap
中实际存储的键值对数量,数组大小是指数组的长度。 - 负载因子的大小对
HashMap
的性能和空间利用率有着直接的影响。具体来说,负载因子越大,HashMap
的数据密度越大,发生碰撞的几率也越高,数组中的链表越容易变长,这会导致查询或插入时比较次数增多,性能会下降。相反,负载因子越小,就越容易触发扩容,数据密度也越小,发生碰撞的几率也就越小,数组中链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是,过小的负载因子会浪费一定的内存空间,并且经常扩容也会影响性能。 - 在Java中,
HashMap
的默认负载因子为0.75。这个值在时间和空间成本之间提供了一个很好的折中方案。较高的负载因子值会减少空间开销,但会增加查找成本(在HashMap
类的大多数操作中都得到体现,包括get和put)。因此,在设置HashMap
的初始容量和负载因子时,需要根据实际需求进行权衡。
5.4 链表转红黑树
- 在Java 8中,当某个桶中的链表长度超过8时,链表会转换为红黑树,以提高查找性能。这是因为当链表较长时,查找的时间复杂度会接近O(n),而红黑树的查找时间复杂度为O(log n)。但是,如果红黑树的节点数量少于6个,则又会将红黑树转换回链表,以节省内存。
5.5 线程安全性
HashMap
不是线程安全的。这意味着如果有多个线程同时修改HashMap
(例如,同时添加、删除或更新键值对),则可能会导致数据不一致或其他不可预测的行为。如果需要线程安全的HashMap
,可以使用Collections.synchronizedMap()
方法将其包装为线程安全的Map,或者使用ConcurrentHashMap
类。
6. 源码分析
由于 HashMap
的源码较长且复杂,这里我将提供一个简化的、用于说明其内部工作原理的伪代码示例,并辅以一些实际源码中的关键片段。
伪代码示例
// 伪代码示例,不代表真实的 HashMap 实现
public class HashMap<K, V> {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
private Entry<K, V>[] table; // 存储桶的数组
private int size; // 存储的键值对数量
private int threshold; // 扩容阈值
private float loadFactor; // 加载因子
// 构造方法
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
// 省略其他构造方法...
// 计算哈希值的方法(简化版)
static int hash(Object key) {
// 实际应用中更复杂,包括无符号右移等
return key.hashCode();
}
// 根据哈希值和数组长度计算索引值
static int indexFor(int h, int length) {
// 实际应用中使用位运算提高性能
return h & (length - 1);
}
// 插入键值对的方法(简化版)
public V put(K key, V value) {
// 省略空键处理、扩容检查等
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
if (e.hash == hash && e.key.equals(key)) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
// 省略扩容逻辑,直接添加新节点
table[i] = new Entry<>(hash, key, value, table[i]);
size++;
return null;
}
// 省略 get、remove、resize 等方法...
// 内部类表示键值对节点
static class Entry<K, V> {
final int hash;
final K key;
V value;
Entry<K, V> next; // 指向链表中的下一个节点
// 省略构造方法和其他方法...
}
}
实际源码片段
以下是 Java 8 中 HashMap
类的一些关键源码片段,用于展示其真实实现中的一部分细节:
// 真正的 hash 方法(包含了一些位运算和扰动函数)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 根据哈希值和数组长度计算索引值(位运算)
static int indexFor(int h, int length) {
return h & (length-1);
}
// Node 是 HashMap 内部用于存储键值对的节点类(在 Java 8 中引入了 Node 和 TreeNode)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// 省略构造方法和其他方法...
}
// 扩容方法(简化版)
void resize() {
// 省略部分细节...
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 扩容为原来的两倍
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
newCap = oldCap << 1;
newThr = oldThr << 1; // double threshold
} else if (oldThr > 0)
// 省略基于阈值的初始化容量逻辑...
// 省略数组创建和元素迁移逻辑...
table = newTab;
threshold = newThr;
}
// 省略其他方法和内部类...
- 请注意,由于 Java 8 引入了红黑树来优化链表过长的情况,并且内部实现细节可能因不同版本的 Java
7. 优缺点
- 优点
- 快速查找:基于哈希表实现,提供了 O(1) 的平均查找时间复杂度。
- 支持 null 键和 null 值:
HashMap
允许存储 null 键和 null 值。 - 动态扩容:当键值对数量超过扩容阈值时,HashMap 会自动进行扩容,以保持性能。
- 缺点
- 非线程安全:
HashMap
不是线程安全的,在多线程环境下使用可能导致数据不一致。 - 哈希冲突:当多个键的哈希值相同时,会发生哈希冲突,导致查找效率下降。虽然
HashMap
通过链表或红黑树解决了这个问题,但在极端情况下仍可能影响性能。
- 非线程安全:
8. 注意事项
- 合理设置初始容量和加载因子:初始容量和加载因子会影响
HashMap
的性能和内存消耗。需要根据实际情况进行设置。 - 避免使用可变对象作为键:如果键是可变的,并且在插入到
HashMap
后被修改,那么可能导致哈希冲突和查找错误。 - 注意线程安全:在多线程环境下使用
HashMap
时,需要注意线程安全问题。可以使用ConcurrentHashMap
或在访问HashMap
9. HashMap 在 JDK1.8 和 JDK1.7 的区别
HashMap
在Java 1.7和1.8之间存在一些显著的区别,这些区别主要体现在数据结构、扩容机制、并发性能和线程安全性等方面。以下是详细的分析:
- 数据结构
- Java 1.7中的HashMap:底层结构主要由数组和链表组成,称为“链表散列”或“拉链法”。每一个数组元素(桶)都可以包含一个链表,链表中的每个节点都存储一个键值对(Entry)。
- Java 1.8中的HashMap:引入了红黑树的概念。当某个桶中的链表长度超过8并且数组的总容量大于64时,链表会转化为红黑树。这种结构的好处是提高了在链表较长时的查找性能。
- 扩容机制
- Java 1.7中的
HashMap
:在扩容时,会创建一个新的数组,并将所有的元素重新哈希并分配到新的数组中。这种方式在元素较多时会导致性能下降。 - Java 1.8中的
HashMap
:在扩容时,不再重新分配元素,而是采用了“移位”的方式来重新定位元素的位置。具体地,它使用位运算(如hash & (length - 1)
)来计算新的索引位置,从而提高了性能。
- Java 1.7中的
- 并发性能
- Java 1.7中的
HashMap
:在并发环境下,多个线程同时对HashMap
进行操作时可能会导致链表出现环形结构,进而导致死循环等问题。这是因为它在扩容或重新哈希时,没有考虑并发修改的情况。 - Java 1.8中的
HashMap
:在并发环境下使用了更加高效的锁机制(如CAS
操作和同步块),解决了Java 1.7中可能出现的问题。这使得它在并发环境下的性能更加稳定和高效。
- Java 1.7中的
- 线程安全性
- 无论是Java 1.7还是Java 1.8,
HashMap
本身都不是线程安全的。这意味着如果有多个线程同时修改HashMap,可能会导致数据不一致或其他不可预测的行为。如果需要线程安全的HashMap
,可以使用Collections.synchronizedMap()
方法将其包装为线程安全的Map,或者使用ConcurrentHashMap
类。
- 无论是Java 1.7还是Java 1.8,
总的来说,Java 1.8中的HashMap
在数据结构、扩容机制、并发性能和线程安全性等方面都有所改进和优化,使得它在实际应用中更加稳定和高效。
10. 讨论:如果你来设计 HashMap 会怎么设计
如果我来设计 HashMap
,我会考虑以下几点:
- 优化哈希函数:设计更高效的哈希函数,以减少哈希冲突并提高查找效率。
- 支持并发访问:提供线程安全的实现,或者提供内置的并发控制机制,以方便在多线程环境下使用。
- 动态调整加载因子:根据使用情况动态调整加载因子,以优化性能和内存消耗。
- 提供自定义桶结构:允许用户自定义桶的存储结构
11. 总结
HashMap
以其高效的查询、插入和删除操作以及灵活的配置选项,成为Java开发中不可或缺的数据结构之一。通过合理设置初始容量和负载因子,可以进一步优化HashMap
的性能和空间利用率。同时,在多线程环境下使用时需要注意线程安全问题。