摘要
HashMap是Java中重要的数据结构,HashMap用来存储键值对对象。HashMap查找元素效率非常高,所以使用频率非常的高,而这也归功于HashMap内部巧妙的存储结构和优秀的Hash算法。HashSet在功能实现上也是复用了HashMap的功能。接下来我将以JDK1.8的源码以及之前版本的源码来探究HashMap的原理以及JDk1.8中HashMap与之前版本的HashMap的区别。由于本文主要讨论HashMap所以对红黑树都是一笔带过,如果需要了解详细,可以参考其他红黑树相关的文章。
存储结构
在java 8中HashMap的一个主要的变化便是存储结构上的改变,之前版本的HashMap是采用数组 + 链表的方式作为存储结构,而Java 8中HashMap采用了数组 + 链表 + 红黑树的结构进行存储。
我们知道,哈希表(一般采用数组实现)根据不同的哈希值将对象存储在不同的桶中,而当两个具有相同哈希值需要存入哈希表中时,它们会存入同一个桶中,这种情况称为“冲突”。解决冲突有很多方式,这里我们不展开讨论,之前版本采用链表的方式解决冲突。但当HashMap中“冲突”很多时,在查找元素时需要遍历对应桶中的链表,大家知道,在链表中进行查找效率很低,平均时间复杂度是n。而在Java 8中做出了改进,当桶中链表长度超过8时,将链表转化成红黑树,红黑树在进行查找操作时的平均时间复杂度是log(n),相较于链表,效率提高了很多。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
//省略TreeNode函数部分
......
}
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;
}
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;
}
}
上述代码分别是HashMap中红黑树节点和链表节点。
重要字段
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
modCount : 主要用于多线程访问HashMap时Fail-fast,这里不展开讨论。
Capacity : 虽然HashMap中没有个这个字段,但它是HashMap中的重要盖帘capacity译为容量。capacity就是指HashMap中桶的数量,也就是table数组的长度。默认值为16。一般第一次扩容时会扩容到64,之后好像是2倍。总之,容量都是2的幂,这是个很重要的特性,之后会重点讨论。
threshold : 表示当HashMap的size大于threshold时会执行resize操作。
threshold=capacity*loadFactor
loadFactor : 译为装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。
构造器
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
HashMap提供了三个构造器,最主要的构造器是第一个,在第一个构造器中对初始容量和加载因子进行了限制和设置,虽然设置了初始容量,但在构造器中并未根据初始容量和初始化table[]数组,table[]数组真正初始化是在进行扩容时即调用resize()方法时初始化。
在构造器中将threshold设置成大于或等于初始容量initialCapacity且最接近initialCapacity的二次幂,这是为了在扩容时设置table[]数组大小也就是capacity做铺垫。因为之前我们说过capacity只能是2的幂次方。
接下来看tableSizeFor方法:
//Returns a power of two size for the given target capacity.
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
理解这个函数需要一些位运算的知识。这个理解有点困难,我举个例子说明,当我们传入2^29+1时,其二进制表示为0010,0000,0000,0000,0000,0000,0000,0001。函数的主要流程和每一步的结果如下:
int n = cap - 1;
cap = 0010,0000,0000,0000,0000,0000,0000,0001
n = 0010,0000,0000,0000,0000,0000,0000,0000
n |= n >>> 1; // 等价于 n = n | (n >>> 1)
n = 0010,0000,0000,0000,0000,0000,0000,0000
n >>> 1 = 0001,0000,0000,0000,0000,0000,0000,0000
n = 0011,0000,0000,0000,0000,0000,0000,0000
n |= n >>> 2; // 等价于 n = n | (n >>> 2)
n = 0011,0000,0000,0000,0000,0000,0000,0000
n >>> 2 = 0000,1100,0000,0000,0000,0000,0000,0000
n = 0011,1100,0000,0000,0000,0000,0000,0000
n |= n >>> 4; // 等价于 n = n | (n >>> 4)
n = 0011,1100,0000,0000,0000,0000,0000,0000
n >>> 4 = 0000,0011,1100,0000,0000,0000,0000,0000
n = 0011,1111,1100,0000,0000,0000,0000,0000
n |= n >>> 8; // 等价于 n = n | (n >>> 8)
n = 0011,1111,1100,0000,0000,0000,0000,0000
n >>> 8 = 0000,0000,0011,1111,1100,0000,0000,0000
n = 0011,1111,1111,1111,1100,0000,0000,0000
n |= n >>> 16; // 等价于 n = n | (n >>> 16)
n = 0011,1111,1111,1111,1100,0000,0000,0000
n = 0000,0000,0000,0000,0011,1111,1111,1111
n >>> 8 = 0011,1111,1111,1111,1111,1111,1111,1111
n + 1 = 0100,0000,0000,0000,0000,0000,0000,0000
注意最后返回的是n+1而并不是n。 如果n不是0,n必然存在一个最高位的1,在上例中是第30位是1。经过 n |= n >>> 1 后最高位后面的1位变成了1,经过n |= n >>> 2,之后的两位变成了1,这样不断进行,最终,最高的1位到最低位均变成了1,再通过加1操作,从而得到大于或等于cap的最接近cap的二次幂。这样经过5步位运算巧妙的得到了结果,效率很高,在java源码中也有其他地方有相同的方式实现高效的操作,可以参考我的另一篇文章http://blog.csdn.net/u013190513/article/details/70216730。
哈希算法
HashMap的所有操作都需要通过哈希值定位到元素,如何让不同的哈希值的对象均匀的分布到哈希表中减少冲突,是提高HashMap操作效率的重要课题。不同的哈希值可以放入相同的桶中,也可放入不同的桶中,但相同的哈希值必须放入同一个桶中。HashMap的哈希算法决定了HashMap的性能。
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
根据对象hashcode()确定最终存储位置的流程:
分为三部分 : 获取对象hashcode() 、 高位运算、取模运算
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
下面举例说明下,n为table的长度:
扩容机制
由于JAVA 8 中HashMap引入了红黑树,所以Java 8的 HashMap扩容机制与之前有一些不同,先来看看之前版本的扩容机制:
1 void resize(int newCapacity) { //传入新的容量
2 Entry[] oldTable = table; //引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; //HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
//头插法
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
扩容的逻辑非常简洁清楚,这里我们注意两个地方,由于扩容需要将元素转移到新哈希表上,在转移元素的时候调用了indexFor()方法来重新计算元素在数组中的位置;而且在转移元素时使用了头插法,将元素放在链表的头部,这样在扩容之后链表的顺序颠倒。
JAVA 8 扩容机制:
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) {
//当容量过大就不再调整,任由其冲突
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//主要部分, 将容量变为2倍,将threshold也调整为2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//初始化table数组
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 { // preserve order
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) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.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主要分为两个部分,第一部分:对容量和threshold进行扩容;第二部分:复制元素到新的哈希表。复制元素会先判断节点是TreeNode(红黑树节点)还是链表节点Node,如果是红黑树节点,由于同一个桶中的元素会可能分到其他的桶中,所以桶中元素会减少,当少于6时会退化成链表。
未扩容前存于同一个桶中的元素可以具有不同的哈希值,只是它们根据哈希值得到的存储位置相同。所以扩容之后,由于哈希表长度变化,根据哈希算法,它们可能分到不同的桶中。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,经过扩容之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。结合代码可以发现确实如此。
看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图。
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
Put方法
1.7版本 :
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
1.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<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;
}
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。并且由于在Jdk1.7中HashMap扩容时会将链表倒置,所以有可能导致死循环。具体情况参看http://blog.csdn.net/qq_27093465/article/details/52207135
小结
JDK1.8和JDK1.7中HashMap有所区别(这里主要讨论一些大的变化):
- 引入了红黑树,提高了性能效率。
- 更改了扩容机制中复制链表元素部分,避免了重新通过哈希值计算位置的操作。
- 更改了扩容机制中复制链表元素部分,将头插法复制元素改为尾接法
扩容是个复杂的过程,所以如果能够估算元素数量,应该给定初始容量避免扩容过程。
致谢
本文虽是本人原创,但其中很多内容借鉴了他人的优秀文章,在此致谢