HashMap是Java集合类框架中比较常用的一个集合类(Collection)。深入的了解HashMap的API以及底层实现,对于开发者而言,在开发过程中衡量程序性能等因素实现程序时十分有帮助。本文将从源码的角度分析HashMap的主要API接口实现,同时,将要源码实现的角度对比HashMap在Java 7和Java 8中底层数据结构的改变。
本文将从HashMap的定义,数据结构,主要API接口的实现对比以及总结等几个方面加以组织叙述。
- 第一部分: HashMap的定义
- 第二部分: HashMap的底层数据结构
- 第三部分: HashMap主要API底层实现在两个版本之间的对比
HashMap本质上想通过借助Hash函数以及映射机制(Key-Value)实现一个增加和删除性能俱佳的数据结构,其中在添加元素是速度快于链表,删除元素时速度快于数组,而综合效率高于二者。在Java的类继承体系中,HashMap继承实现了AbstractMap抽象类并且实现了Map接口。其中,我们说,Map接口定义了Key-Value的映射规则,抽象类AbstractMap实现类其定义的基本通用方法,尽可能减少了后续类实现该接口所需的工作。下列代码展示了两个版本中该类定义的不同以及部分主要方法的源代码实现(仅提供了我们关注的重点):
//HashMap类定义
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
/** 构造函数 */
public HashMap();
public HashMap(int initialCapacity);
public HashMap(int initialCapacity, float loadFactor)
/** Java 8 中存储结构和内部类的定义 begin */
transient Node[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry,?> e = (Map.Entry,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
/** Java 8 中存储结构和内部类的定义 end */
/** Java 7 中存储结构和内部类的定义 begin */
ransient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap m) {
}
}
/** Java 7 中存储结构和内部类的定义 begin */
/** 共有的基本API */
public V put(K key, V value){
...
}
public V get(Object key){
...
}
}
在源码定义部分,我们发现Java8定义实现的嵌套类Node
在实现HashMap时,Java7和Java8两个版本之间底层数据结构之间的区别,如下图所示:
由上图可以看出,Java 7中实现HashMap是逻辑结构上,当发生冲突是,发生冲突的索引节点元素存储逻辑上会形成链表结构。也就是说,极端情况下,Hash表会退化成链表结构。这对HashMap的效率影响极大。相比于此,Java 8中则做出了一定的改变。当同一索引位置(Key)因为冲突,元素数量达到一定阈值时,链表的逻辑结构会发生变化,转换成红黑树。此时,查询操作的时间复杂度就会由O(n)降低为O(nlogn)。
从上图以及源代码中可以看出,本质上HashMap底层维护了一个桶数组+链表/红黑树的逻辑结构。在Java 7为桶数组+链表的底层逻辑结构,其中桶数组每个索引位存储的都是Entry为节点的链表首节点。相比于Java 7而言,Java 8则是桶数组+链表/红黑树的底层逻辑结构,其中桶数组每个索引位存储的都是Node为节点的红黑树根节点或链表的首节点。
对于HashMap类而言,我们需要关注的两个主要方法是put()方法和get()方法。下面,将从源码的角度分析这两个方法在两个版本中的实现。
存储方法 —— V put(Key key, V value)
- 在Java 7版本中:
public V put(K key, V value) {
// 如果 key == null,则调用putForNullKey()方法,将key为null的Entry保存到table数组的第一个桶位置 ==> 这极有可能造成Hash链表退化链表
if (key == null)
return putForNullKey(value);
int hash = hash(key); //计算key的hash值
int i = indexFor(hash, table.length); //寻找hash在table中的位置
// 遍历table数组中第i个位置的链表数组,查找key的位置
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
//如果存在相同的key值,则用新值替换,并返回旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//将key-value添加到table的第i个桶上
addEntry(hash, key, value, i);
return null;
}
从put方法的源代码,我们可以清晰的看到HashMap在逻辑上保存数据的过程:首先,判断key-value的key值是否为null,如果为null,则将元素都放在了table[0]中;否则,计算key的hash值,然后根据hash值查找到key在table数组中存储的桶索引;如果table中某个位置存在该元素,则通过判断是否存在相同的key来判断是否存在相同的key-value,如果存在则替换,否则将元素保存在链表头部(最先插入的元素保存在链表尾部)。需要注重注意的几点:
- 相同元素的互斥 :这里的相同指的是key-value各部对应相同,而源码中的链表遍历则是为了解决重复,以防查询时出现key值的重复,保证了key-value的唯一性。
- 空间和时间的优化 :这里的优化实质上是对HashMap的table表而言的,其本质是Hash表的查询效率和空间利用率。我们知道Hash表散列函数的均匀性、散列冲突的处理方式以及装填因子都会影响散列表的性能。保证在特定长度空间内部均匀散列,我们常用的方法就是模运算。但是,取模运算的消耗极大,因此,Java 7中对于取模运算采取了优化,代码如下:
static int indexFor(int h, int length) {
return h & (length-1);
}
采用这种方式是有原因的:HashMap底层table数组的长度总为2^。当table数组的长度为2^n时,我们可以保证 h&(length-1)相当于对h的取模运算,并且计算速度比直接进行取模运算快得多。这里是HashMap在速度上的一个优化。除此之外,这种计算方式与table表长度为2^n配合完成均匀分布存入table表中数据和充分利用空间的共功能。下面对其详细的分析:
假设table.length为16和15, h为5, 6, 7,则计算结果如下图:
当table.length = 15时,h = 6和 7计算结果相同,也就是他们发生冲突会被放到同一个同位置形成链表结构。这样就会导致查询效率降低。下面,我们给出table.length = 15时的更多实例:
从表中可以看到,0~15一共发生了8次碰撞,其中1,3,5, 7, 9,11,13,15桶位置没有数据记录,空间浪费极大。这是因为他们在与14进行&运算时,得到的结果最后一位永远都是0, 也就是说 0001、0011、0101、0111、1001、1011、1101、1111这几个桶位置处是不可能存储数据的,这就造成了存储桶位置减少,进一步增加碰撞几率,从而导致查询速度慢。而当table.length = 16时,table.length – 1 = 15 即1111,进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以说当table.length = 2^n时,不同的hash值发生碰撞的概率比较小, 这样就会使得数据在table数组中分布较均匀, 查询速度也较快。
在这里,有一个内部私有方法需要额外注意:
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) {
Entry e = table[bucketIndex];
// 将新创建的Entry放到bucketIndex处,并且让新的Entry指向该位置上原来的Entry
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
从上述两个方法中我们可以得出结论,新增加的元素永远是放在链表的头部的。这是一个非常优雅的操作,系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。对于扩容问题,同样是需要着重考虑的,这里采用了一般的倍增法扩容。
- Java 8版本中
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node[] tab; Node p; int n, i;
// 如果table中没有数据,则重定义容量到默认
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果该位置上不存在元素,则创建新的Node节点并存入
if ((p = tab[i = (n - 1) & hash]) == null) //这里替代了 Java 7中的 indexFor()方法,同时,hash()方法也有相应的改变。
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
//如果通过计算得到的key与链表中元素有所重复,则直接用心的替换旧的
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果该桶位置上是一棵树,则执行树查找方案,并将元素存入
else if (p instanceof TreeNode)
e = ((TreeNode)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;
}
从上述源代码中,我们可以看出,Java 8的底层逻辑结构采用了单链表和红黑树结合的方式,这样提高了查询效率,查询时间复杂度从单链表的O(n)降低为O(logn)。
读取方法 —— V get(Object key)
- Java 7
public V get(Object key) {
if (key == null)
return getForNullKey(); //如果key为空,则查找table[0]中的第一个key为null的Entry
Entry entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key); //查找key对应的桶索引
// 遍历单链表查找对应的Entry
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //这里的比较代表的各种情况
return e;
}
return null;
}
- Java 8
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node getNode(int hash, Object key) {
Node[] tab; Node 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)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;
}
两个版本之间的不同点在于Java 8多了在树结构上的查找。
总结
首先,Java 7和Java 8在实现HashMap数据结构时其构造目标并没有发生改变:构造一个在进行元素增删改查时综合效率优越的数据结构,只是在实现细节上发生了些许变化,Java 7采用位桶散列表+单链表的逻辑结构,而Java 8则做了些许改进采用位桶列表+单链表/红黑树的逻辑结构。
参考
哈希表
JDK 1.7.src
JDK 1.8.src