最近靠了些关于HashMap的相关内容,觉得有必要梳理一下了。
1、HashMap概述
HashMap是常用的一个集合类,它是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
2、HashMap数据结构
HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合,如下图
在JDK1.7中的代码
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
...
}
可以看出,HashMap中维护了一个Entry为元素的table,transient修饰表示不参与序列化。每个Entry元素存储了指向下一个元素的引用,构成了链表。
在JDK1.8中,用Node代替了Entry。实现类似
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
3、HashMap常用方法
1)put方法实现
JDK1.7中的代码
public V put(K key, V value) {
// HashMap允许存放null键和null值。
// 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。
if (key == null)
return putForNullKey(value);
// 根据key的keyCode重新计算hash值。
int hash = hash(key.hashCode());
// 搜索指定hash值在对应table中的索引。
int i = indexFor(hash, table.length);
// 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
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;
}
}
// 如果i索引处的Entry为null,表明此处还没有Entry。
modCount++;
// 将key、value添加到i索引处。
addEntry(hash, key, value, i);
return null;
}
从源码可以看出,大致过程是,当我们向HashMap中put一个元素时,首先判断key是否为null,不为null则根据key的hashCode,重新获得hash值,根据hash值通过indexFor方法获取元素对应哈希桶的索引,遍历哈希桶中的元素,如果存在元素与key的hash值相同以及key相同,则更新原entry的value值;如果不存在相同的key,则将新元素从头部插入。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。
看一下重hash的方法:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
此算法加入了高位计算,防止低位不变,高位变化时,造成的hash冲突。
在hashmap中,我们希望元素尽可能的离散均匀的分布到每一个hash桶中,因此,这边给出了一个indexFor方法:
static int indexFor(int h, int length) {
return h & (length-1);
}
这段代码使用 & 运算代替取模,效率更高。
再来看一眼addEntry方法,
void addEntry(int hash, K key, V value, int bucketIndex) {
// 获取指定 bucketIndex 索引处的 Entry
Entry<K,V> e = table[bucketIndex];
// 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 如果 Map 中的 key-value 对的数量超过了极限
if (size++ >= threshold)
// 把 table 对象的长度扩充到原来的2倍。
resize(2 * table.length);
}
很明显,这边代码做的事情就是从头插入新元素;如果size超过了阈值threshold,就调用resize方法扩容两倍。
JDK1.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) {
//onlyIfAbsent用于控制是否有权修改存在的key的值
//evict 该表是否再创建模式
// 初始化tab用于储存原table引用,n为table长度,i为key所在的hash索引,p为tab[i]处的第一个Node或null
Node<K,V>[] tab; Node<K,V> p; int n, i;
// HashMap初始化,默认大小是16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果哈希桶内没有Node节点,直接添加
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//e用作遍历
Node<K,V> e; K k;
//如果第一个Node与key的hash值相等且key相等,将p的引用给e,用于之后修改
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//红黑树、jdk1.8新增
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;
}
jdk1.8中新增了红黑树,大致过程类似,不过,jdk1.8中的hash方法做了修改
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2)get方法实现
jdk1.7中的实现
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
这段代码很容易理解,首先根据key的hashCode计算hash值,根据hash值确定桶的位置,然后遍历。
jdk1.8中的实现
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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;
}
jdk1.8中的过程基本类似,值得注意的是,它总是判断是否为第一个节点,如果不是才进行遍历,提高了代码执行效率
3)resize方法实现
resize 顾名思义就是扩容的意思,那么,问题来了
HashMap是怎么扩容的?
扩容多少?
已有元素怎么处理的?
jdk1.7中,resize()方法在当size 超过 threshold(阈值)= loadFactor 默认0.75 *capacity 时触发,扩容大小为oldCap<<1,即原容量的两倍
JDK1.7中的代码
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
这边代码还是很好理解的,方法的核心是transfer,这里实现了元素的转移,直接看代码
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
大致过程是:src保存旧的数组引用,遍历src,取得第一个Entry元素e=src[j]
,释放src[j]位置的引用,然后遍历链表,用next保存e.next的引用,对于链表的每个元素,都通过indexFor重新找到桶的位置i,将新索引第一个位置的引用给e.next,然后再把e的引用放到桶第一个元素位置,即实现了从头插入的效果。
乍一看,哇,还不错耶,可是,其实这段代码会导致一个问题,如果一个链表上所有元素,重hash之后的位置和原先位置相同,那么,这个链表的顺序就会颠倒。
那怎么办呢?观察jdk1.7中,哈希桶位置计算的方法h & (length-1);
,h为key的hash值,当resize触发时,length变成了oldLength<<1,即向左移动了一位,length-1实际上就是在高位多了1,与hash进行&运算后,实际上除最高位可能会有0,1的变化外,低位是不变的。可以理解为,有些元素扩容后,哈希桶的位置不变,而有些元素则变为原来位置i+oldLength处的哈希桶了。如果不懂,可以看以下两张图:
为此,jdk1.8的resize代码进行了改进:
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;
}
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"})
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;
}
看到这么长段源码,内心是崩溃的,什么鬼啊。。这代码好长好长啊!!
我们来简单分析一下过程:
1、前面一段其实是用来初始化以及超过最大阈值时的操作,扩容操作和jdk1.7类似,可以简略看下
2、从第30行开始为元素转移方法,首先遍历oldTab,若哈希桶内存在元素,即e=oldTab[j]!==null,则需要转移,先将oldTab[j]的引用置为null
3、当哈希桶内存在一个元素的情况下,即e.next==null,使用和jdk1.7一样的方法将e放到新的table中,哈希桶位置获取方法为e.hash & (newCap - 1)
4、若e是红黑树、则进行红黑树的split操作,这暂时不讲,因为不太懂- -
5、40行开始为链表多元素情况时的转移操作,定义了loHead,hiHead用来保存低位与高位链表的头结点,loTail,hiTail用来保存低位与高位链表的尾结点,这边高位和低位指的是需要换哈希索引以及不需要换哈希索引的情况。next存下一个操作的节点引用。
6、如何判断是否需要换哈希索引?这边使用了一个很巧妙的方法,e.hash & oldCap
方法,仔细想一个,因为oldCap是2的倍数,所以在对应的高位处是1,其余都是0,与hash做&操作后,如果 == 0 说明 不需要换哈希桶索引。
7、之后的操作便是,若头结点为null,则将e的引用给头结点,同时将e的引用给尾结点;之后若不变换则将e的引用给loTail的next,(其实同时修改了loTail的引用的next),并且将e的引用给loTail,这时候loTail又指向lo链表的尾结点;hi链表同样操作。
8、将loHead引用给newTab[j],hiHead给newTab[j+oldCap],完成一个链表转移
来看一下买家秀。。
好了,贴了太多源码,有点累了。。其他方法待续。。