HashMap 在不同的 JDK 版本下的实现是不同的,在 JDK 7 时,HashMap 底层是通过数组 + 链表实现的;而在 JDK 8 时,HashMap 底层是通过数组 + 链表或红黑树实现的。
JDK7
内部结构
HashMap 类内部有一个数组 table,它存储着所有的 Entry(JDK 7 中是 Node)对象,Node 中包含了键、值、哈希值、以及指向下一个节点的指针。哈希冲突会导致多个 Node 对象存在于同一个数组索引位置。
transient Node<K,V>[] table;
在 JDK 7 中,Node 类的定义如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
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;
}
}
hash: 是当前节点的哈希值,基于key计算得出。key: 存储当前节点的键。value: 存储当前节点的值。next: 用于指向同一哈希桶中下一个节点,形成链表结构。
插入操作
插入元素时,HashMap 会根据键的哈希值计算出数组的索引位置,然后将元素插入到该位置。如果该位置已经有元素(即发生哈希冲突),则通过链表的形式将新元素追加到现有链表的末尾。
public V put(K key, V value) {
// 计算键的哈希值
int hash = hash(key);
int i = indexFor(hash, table.length);
// 遍历链表,如果键相同则更新值,否则添加新节点
for (Node<K,V> e = table[i]; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
// 如果链表没有该键,则插入新节点
modCount++;
addEntry(hash, key, value, i);
return null;
}
在 put() 方法中,首先通过 hash() 计算键的哈希值,然后通过 indexFor(hash, table.length) 计算出数组索引 i。如果当前位置已经有元素(即链表),就遍历链表判断是否有相同的键。如果有相同的键,则更新值;如果没有,则在链表末尾添加新节点。
计算哈希值和数组索引
final int hash(Object key) {
int h = key.hashCode();
return h ^ (h >>> 16);
}
hash() 方法是用来计算键的哈希值的,首先通过 key.hashCode() 获取哈希值,然后进行异或运算 h ^ (h >>> 16),这是为了降低哈希冲突的概率。
然后通过 indexFor() 方法将哈希值映射到数组索引上:
static final int indexFor(int h, int length) {
return h & (length - 1);
}
indexFor() 通过与数组长度减一(即 length - 1)做位与操作,得到数组的索引。
添加节点到数组
如果该位置没有元素,直接通过 addEntry() 方法添加新节点。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Node<K,V> e = table[bucketIndex];
table[bucketIndex] = new Node<>(hash, key, value, e);
size++;
}
createEntry() 方法会创建一个新的 Node 节点,并将其插入到数组指定索引位置的链表头部。
查找操作
查找操作也会基于键的哈希值计算出数组索引,然后通过链表遍历来寻找指定的键。
public V get(Object key) {
if (size == 0) {
return null;
}
int hash = hash(key);
int i = indexFor(hash, table.length);
// 遍历链表,查找键值对
for (Node<K,V> e = table[i]; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
return e.value;
}
}
return null;
}
在 get() 方法中,首先根据键的哈希值找到对应的数组索引,然后遍历链表,检查每个节点的键是否与传入的 key 相同。
扩容机制
当哈希表中的元素个数超过阈值(threshold)时,HashMap 会进行扩容,通常是将数组的大小扩大为原来的两倍,重新计算每个元素的数组索引并插入。
void resize(int newCapacity) {
Node<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Node<K,V>[] newTable = new Node[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int) (newCapacity * LOAD_FACTOR);
}
resize() 方法会将当前 table 数组的大小扩大为 newCapacity,并将所有现有的节点重新分配到新的数组位置。
总结
- 数组 + 链表:
HashMap使用一个数组来存储Node,每个Node保存一个键值对。当多个键映射到相同的数组位置时,这些键值对通过链表存储。 - 哈希值计算:通过
key.hashCode()来计算哈希值,并使用indexFor方法将其映射到数组索引上。 - 冲突处理:哈希冲突通过链表来解决,新的节点被插入到链表头部。
- 扩容机制:当元素数量超过阈值时,
HashMap会扩容并重新分配元素。
JDK8
在 JDK 1.8 中,HashMap 底层的实现机制在 JDK 7 的基础上做了优化,特别是在处理哈希冲突时引入了 红黑树,以提高链表长度过长时的性能。具体来说,当一个哈希桶中链表的长度超过一定阈值时,HashMap 会将链表转换为红黑树,从而避免性能退化为 O(n)。
基本结构
在 JDK 1.8 中,HashMap 内部使用一个数组 table 来存储哈希桶,每个桶可能包含一个链表或一个红黑树。每个桶存储的是一个 Node(JDK 7 中是 Entry,JDK 8 中是 Node)对象。
transient Node<K,V>[] table;
在 JDK8 中,Node 类的定义如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// 红黑树特有的字段
TreeNode<K,V> parent; // 红黑树的父节点
int height; // 红黑树的高度(用来判断树的深度)
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
hash: 当前节点的哈希值。key、value: 当前节点存储的键值对。next: 链表中下一个节点的指针。parent: 红黑树节点的父节点(如果是红黑树节点)。height: 红黑树节点的高度。
插入操作
put() 方法负责将新的键值对插入到 HashMap 中。首先根据键的哈希值计算出索引,如果该索引处有其他元素,则判断当前桶是链表还是红黑树,并根据不同的情况进行插入。
public V put(K key, V value) {
if (key == null) {
return putForNullKey(value);
}
int hash = hash(key);
int i = indexFor(hash, table.length);
Node<K,V> e = table[i];
if (e == null) {
table[i] = new Node<>(hash, key, value, null);
size++;
return null;
}
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
if (e instanceof TreeNode) {
TreeNode<K,V> p = (TreeNode<K,V>)e;
if (p.putTreeVal(hash, key, value)) {
return null;
}
}
// 插入链表
for (Node<K,V> q = e; q != null; q = q.next) {
if (q.hash == hash && (q.key == key || key.equals(q.key))) {
V oldValue = q.value;
q.value = value;
return oldValue;
}
}
// 如果链表过长,转化为红黑树
table[i] = newNode(hash, key, value, e);
size++;
if (size > threshold) {
resize();
}
return null;
}
在 put() 方法中,首先根据 hash() 和 indexFor() 计算出桶的索引,然后判断桶中是否存在元素。
- 如果桶为空,则直接插入新节点。
- 如果桶中已经存在元素,检查键是否相同,若相同则更新值并返回。
- 如果桶中的元素是
TreeNode,则将其作为红黑树节点处理,调用putTreeVal()方法将新节点插入红黑树中。 - 如果桶中的元素是链表,则遍历链表插入新节点。
putTreeVal() 方法
当链表长度超过阈值时,putTreeVal() 方法会将链表转换为红黑树,优化查找和插入性能。
final boolean putTreeVal(int hash, K key, V value) {
if (parent != null) {
throw new IllegalStateException("Should not put value in treeNode");
}
TreeNode<K,V> root = (TreeNode<K,V>)this;
if (root == null) {
this.parent = null;
this.red = false;
}
// 红黑树插入算法
TreeNode<K,V> p = root.putTreeNode(hash, key, value);
if (p != null) {
return true;
}
return false;
}
查找操作
get() 方法用于查找一个键对应的值。在 JDK 1.8 中,首先根据哈希值找到对应的桶,然后判断该桶的元素是链表还是红黑树。如果是链表,则遍历链表;如果是红黑树,则在树中查找。
public V get(Object key) {
if (key == null) {
return getForNullKey();
}
int hash = hash(key);
int i = indexFor(hash, table.length);
Node<K,V> e = table[i];
if (e == null) {
return null;
}
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
return e.value;
}
// 如果是红黑树
if (e instanceof TreeNode) {
return ((TreeNode<K,V>)e).getTreeNode(hash, key);
}
// 否则,遍历链表
for (Node<K,V> p = e; p != null; p = p.next) {
if (p.hash == hash && (p.key == key || key.equals(p.key))) {
return p.value;
}
}
return null;
}
如果桶中是 TreeNode,则调用红黑树的查找方法 getTreeNode()。
如果桶中是链表,则遍历链表查找对应的键。
红黑树的转换和调整
当链表长度超过一定阈值时(默认为 8),HashMap 会将链表转换为红黑树,避免查找性能退化为 O(n)。
void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b = table[index];
if (b instanceof TreeNode) {
return;
}
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p = new TreeNode<>(e.hash, e.key, e.value, null, null);
if (tl == null) {
hd = p;
} else {
tl.next = p;
}
tl = p;
}
table[index] = hd;
}
treeifyBin() 方法会将链表转换为红黑树,它首先创建一个链表上的每个节点的红黑树节点(TreeNode),然后将它们组成红黑树。
扩容机制
当 HashMap 中的元素数量超过阈值时,数组会扩容。扩容时,所有元素会重新计算哈希值并放入新的数组中。
void resize() {
Node<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
int newCapacity = oldCapacity << 1;
Node<K,V>[] newTable = new Node[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
在扩容时,resize() 会将哈希表的容量加倍,并将所有元素重新分配到新的表中。
总结
在 JDK 1.8 中,HashMap 的底层实现使用 数组 + 链表 + 红黑树 结构,关键点如下:
- 链表:当桶中元素较少时,使用链表来解决哈希冲突。
- 红黑树:当链表长度超过阈值时,链表会转化为红黑树,以提高查找、插入和删除操作的效率。
- 扩容机制:当元素数量超过阈值时,哈希表会扩容,并将所有元素重新分配到新的哈希桶中。
小结
HashMap 在 JDK 1.7 时,是通过数组 + 链表实现的,而在 JDK 1.8 时,HashMap 是通过数组 + 链表或红黑树实现的。在 JDK 1.8 之后,如果链表的数量大于阈值(默认为 8),并且数组长度大于 64 时,为了查询效率会将链表升级为红黑树,但当红黑树的节点小于等于 6 时,为了节省内存空间会将红黑树退化为链表。
欢迎关注公众号:“全栈开发指南针”
这里是技术潮流的风向标,也是你代码旅程的导航仪!🚀
Let’s code and have fun! 🎉