【翻译】Java中HashMap的工作原理浅析
毫无疑问,
HashMap
是使用最为频繁的类型之一,也是面试最热门的主题之一。在讨论
HashMap
工作原理之前,我们需要理解其基本的概念。如果对基本概念还不够熟悉,请参见Java官方文档,
Java docs。此外,在进一步讨论之前,还强烈建议阅读
Working with hashCode and equals methods in Java。
一、一句话描述
如果有人提出这样的问题“HashMap
是如何工作的?”,最简单的回答是“基于哈希的基本原理”。因此,当我们详细分析HashMap
的工作原理时,我们至少要理解哈希的基本知识。
二、哈希是什么
简单地来说,哈希就是基于对象的属性进行运算,为对象分配一个独特的编码。一个合格的哈希函数需要遵循以下原则:
+ 重复调用同一个对象的哈希函数应该返回相同的哈希码。换句话说,object1.equals(object2)
方法返回true的两个对象的哈希码相同。
Info: Java中所有对象都继承了
hashCode()
方法的在Object
类中的默认实现。这个函数通过将对象的内部地址转换成整型来产生对象的哈希码,因此,所有不同的对象都会产生一个不同的哈希码。
三、关于Node
类
Map
的定义:保存键与值之间对应关系的对象。
因此,HashMap
需要一种保存键-值对数据的机制。在HashMap
中有一个内部的Node
类,定义如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
......//More code goes here
Node
类型包括key
和value
两个属性来存储键与值的映射。键被声明为final
,此外还有两个属性next
和hash
。下面我们来看看这两个属性的使用。
四、关于put()
方法
在深入分析put()
方法具体实现之前,我们有必要知道Node
类型的实例存储在一个数组中。HashMap
中数组变量定义如下:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
现在我们来看看put()
方法的具体实现:
/**
* 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 key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
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) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
hash
方法的实现:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
让我们按步骤来分析元素是如何被添加的:
1. 首先,计算键对象的哈希码。如果键值为null,哈希码为0,值将被保存在table[0]
中(null的哈希码为0);否则,调用键对象的hashCode()
方法,计算其哈希码。JDK的设计者考虑到hashCode()
方法可能会返回过大或过小的哈希码,从而通过对对象的哈希码进行运算来确保基于哈希码计算出的下标在数组的长度范围之内。
2. 下一步,获取table
的长度,如果有必要则扩展数组。
3. 然后是最重要的部分。正如我们所知的,两个不相等的对象(equals()
方法返回false
)可以拥有相同的哈希码,那么如何数组的同一个位置存储两个不相等对象,答案是使用哈希桶(Hash Bucket)和链表(或平衡树,Java 8)。上文我们提到过HashMap
的next
属性,这个属性通常指向链表中的下一个元素。
因此, 两个对象发生哈希冲突时,Node
将以链表的形式存储。当一个Node
需要存储在数组的指定位置时,HashMap
检查该位置是否已经存储Node
,如果该位置为空,则直接将新的Node
存储在这个位置。
如果上述的位置已经存储一个Node
对象,那么先判断键值对象是否相等,如果相等则覆盖原有的值;否则,判断当前Node
对象的next
是否为空或其键值对象是否与添加的对象相等,直接找到为空或相等的对象,如果为空,则新的Node
设置成该节点的下一个节点,如果相等,则用新的值替换原值。
当键值对象的哈希码冲突比较严重(很多不同的键值对象都拥有相同的哈希码)并超过指定的阈值(TREEIFY_THRESHOLD = 8)时,哈希桶将Node
链表转换成平衡树。这种方法用来改善大规模哈希码冲突带来的性能问题,将时间复杂度由O(n)优化为O(log n)。
五、关于get()
方法
上一部分内容让我们了解了HashMap
如果存储数据。下一个问题就是:HashMap
如何通过传一个键对象来get
值,如何去确定值对象?
答案其实已经由put()
方法决定了,get()
方法相应的处理逻辑与put()
相同。如果没有匹配的键值则返回空。让我们来看看代码实现:
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code (key==null ? k==null :
* key.equals(k))}, then this method returns {@code v}; otherwise
* it returns {@code null}. (There can be at most one such mapping.)
*
* <p>A return value of {@code null} does not <i>necessarily</i>
* indicate that the map contains no mapping for the key; it's also
* possible that the map explicitly maps the key to {@code null}.
* The {@link #containsKey containsKey} operation may be used to
* distinguish these two cases.
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
六、重点提示
- 存储
Node
对象的数据是名为table
的Node
类型的数组。 - 数组中的每一个位置作为一个哈希桶(Hash Bucket),它可以用链表或树的形式存储
Node
对象。 - 键对象的
hashCode()
方法用于计算Node
对象在数组中的位置。 - 键对象的
equals()
方法用来维持Map
中键的唯一性。 - 值对象的
hashCode()
和equals()
方法在get()
和put()
方法中没有使用。 - Null的哈希码为0,且空对象被存在
Node
数组的第一个位置。