终于要开篇写HashMap了,作为集合届的头把交椅,HashMap不可不谓为响当当的,其代码也让很多人望而生畏,但其实仔细琢磨一下,其复杂度并没有特别的让人害怕(起码对比ConcurrentHashMap而言),因此,让我们走近来近距离瞧一瞧这个大名鼎鼎的HashMap吧。
鉴于我本地安装的版本是1.8的,因此,分析1.8版本的是HashMap。最后会分析1.7和1.8的有什么区别。
首先,HashMap使用链地址法来解决hash冲突的问题,HashMap1.8使用的是数组+链表/红黑树。
注:虽然是源码解析,但是并不是所有的源码都会涉及到,只涉及到经常使用的那些。
首先看看HashMap的类关系图,了解一下它的继承关系和实现的接口。
再来看看一些一些常量和变量,以及构造方法和hash方法。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认的初始化容量,即默认的数组大小,为16.该值必须是2的次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大的容量,为2的30次方。
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树形化的临界值。
static final int TREEIFY_THRESHOLD = 8;
// 树形转回链表的临界值。
static final int UNTREEIFY_THRESHOLD = 6;
// 如果没达到这个容量,会先扩容,而不是树形化。这样避免调整大小和树形化冲突。
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 变量
**/
// HashMap的数组定义
transient Node<K,V>[] table;
// 做遍历时使用。
transient Set<Map.Entry<K,V>> entrySet;
// HashMap大小。
transient int size;
// HashMap结构改变的次数。
transient int modCount;
// 表示size大于它的时候会进行扩容操作。
int threshold;
// 负载因子。
final float loadFactor;
// 参数为初始容量和负载因子的构造函数。
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;
// 将容量调整为大于参数的最小2次方
this.threshold = tableSizeFor(initialCapacity);
}
// 参数为初始容量的构造方法。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 无参构造函数,会将负载因子设为默认的。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 参数为Map类型的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// hashMap自带的hash函数。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先讲解一些常量,可以看出来,HashMap容量大小默认是16,且必须是2的次方,这个原因后续会说。负载因子为0.75,也就是说HashMap中的元素达到容量的0.75就扩容,如16*0.75=12,那么容量使用达到12就会扩容。因此这个值太小了容易导致扩容频繁,非常消耗性能,太大了容易导致哈希冲突概率变大,链表变长,这样的话查找效率就低了。总之值的大小的优缺点是对立的,0.75是官方认为一个较为平衡的值。
至于变量,注释已经给出了相应的解释。
最后看下构造函数和hash方法,在所有的构造函数中,都会设置负载因子和初始化容量,如果用户没有给,那么就使用默认的,其中,初始化容量,即使用户给了非2的次方数,也会使用tableSize方法调整过来,比如传11,容量并不会就是11,而是16,传17,就会是32。始终保持2的次方。至于hash方法,又叫扰动函数,它能使hash的值分布的更随机,避免hash冲突太频繁。
这次就不列增删改查了,直接从方法出发。
// 调用了下面的方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 真正执行put的方法
*/
// onlyIfAbsent为true时,不会改变已经存在的值,也就是说,只有key不存在时才会put。
// evict不用关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果数组为null,或是长度为0,就进行扩容。
// 这意味着第一次put时就会进行扩容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果key对应的那个位置为空,那么直接创建一个node放置便可。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { // 否则,说明有hash冲突了。
Node<K,V> e; K k;
// p是这个数组的第一个节点,如果p和要插入的数据是一个值,那么将p赋值给e
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) {
// 如果没找到,就新建一个node节点,放在p后面。可以看出,这是尾插。
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;
}
}
// 如果e不为null,也就是说要插入的键值对中的key是存在。
if (e != null) { // existing mapping for key
// 将旧的值取出
V oldValue = e.value;
// 如果onlyIfAbsent为false,或者旧值为null,将新的值覆盖
// (有关onlyIfAbsent的地方,方法开头已经写了)
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 这个不重要。
afterNodeAccess(e);
return oldValue;
}
}
// 如果新增了node节点,就会modCount自增
++modCount;
// 同时由于新增node,size也会自增,自增后超过阈值,也需要扩容。
if (++size > threshold)
resize();
// 这个也不重要。
afterNodeInsertion(evict);
return null;
}
put方法还是比较好理解的。笼统概括一下。首先,如果是第一次put元素,那么就会先扩容,达到默认的16或者用户自定义的大小。然后使用(n - 1) & hash进行取模运算,这个与运算和hash % n的效果是一样的,同时由于是位操作,因此会比%快。取模运算后看key的hash值是在数组的哪个位置,如果该位置上没有元素,那么直接新建一个node元素放在该位置上。否则说明数组上已经有元素了,那么首先判断该key是不是头结点,如果是,将头节点赋给e。如果不是,且头节点是红黑树的节点,那么走红黑树的查找。否则是链表,遍历链表,如果找到了,将节点赋给e。如果遍历了还是没有,就新建node节点放在链表尾部,此时,如果新增的元素刚好是第8个节点,那么树形化。最后,如果e的值不为null,说明key已经在map中存在了,覆盖然后返回旧值就行了,当然,**onlyIfAbsent为true,且有旧值时是不能覆盖的。**否则,要插入的键值对是新增的,那么增加
modCount,同时增加size,如果大于阈值,就扩容。
我们现在看看树形化的代码,这里有一个需要引起注意的地方。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
里面具体如何树形化的我就不解释了,但是可以看到该方法的开头, 如果数组为空,或者数组长度小于MIN_TREEIFY_CAPACITY(即64),那么都会先扩容。
也就是说!!! 扩容的时机其实并不仅仅是数组大小大于阈值才会扩容,在树形化时如果数组大小没有达到64,也是会先扩容的!!!
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 旧的容量,如果是第一次扩容,旧容量就是0
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倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值也翻倍。
newThr = oldThr << 1; // double threshold
}
// 说明是第一次扩容,且使用了带参的构造方法,将阈值赋给新容量。
else if (oldThr > 0)
newCap = oldThr;
else { // 说明是第一次扩容,使用的是无参的构造方法,那么使用系统默认的值
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;
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;
// 如果该位置是红黑树,提一句,迁移数据之后,长度小于UNTREEIFY_THRESHOLD(即6),那么就会转回为链表
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-while是将数组分为两个链表,一个是与旧容量相与为1,一个是为0
do {
next = e.next;
// 如果e的hash值与旧容量进行与运算后还是为0
if ((e.hash & oldCap) == 0) {
// 如果loTail 为null,那么loHead指向e.
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 如果进行与运算为1
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 如果相与为0,那么待在原位置。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 如果为1,则迁去新位置,这个新位置是原位置+原容量。
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容也是比较好理解的,记录旧的容量和旧的阈值。如果是第一次扩容,那么将容量设为默认的或者用户传来的。如果不是,将容量扩容至原来的两倍,同时,在这里可以看到阈值 = 负载因子 * 容量。
如果不是第一次扩容,需要进行元素迁移。如果是链表的迁移,在扩容中只用判断原来的 hash 值与原容量按位与操作是 0 或 1 就行,0 的话索引就不变,1 的话索引变成原索引加上扩容前数组,这里为什么是这样解释一下,新容量的大小是原大小的两倍,之前当key判断自己应该在数组中的哪个位置时,使用的是(n - 1) & hash,这里的n是指数组大小。那么元素迁移要判断自己位置时,也就是(新容量 - 1) & hash,新容量-1和旧容量-1在二进制中只是多了最高位上的1,而这个1就是旧容量上的1,因此只要与旧容量进行&运算就行。
可能文字解释的比较绕口。使用实例解释一下吧。
// 假设hash值是10101。
// oldSize是 10000.
// newSize就是 100000.
// (oldSize - 1) & hash = 01111 & 10101 = 00101.
// (newSize - 1) & hash = 011111 & 10101 = 10101.
// 可以看出newSize - 1比oldSize - 1只是多了1,而这个1的位置就是oldSize的1的位置,其他并没有变
// 因此在newSize是oldSize两倍的情况下,(newSize - 1) & hash与oldSize & hash的结果是一样的
// 调用下面的方法
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;
// 如果数组不为null,且长度不为空,且key的hash对应的数组位置不为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果第一个元素就是,直接返回第一个元素
if (first.hash == hash &&
((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);
}
}
// 没找到就返回null。
return null;
}
get方法对比put和resize方法还是很简单的。这里不解释了。
在这里插入代码片
由于不会写1.7版本的hashMap源码,因此这里说一下两者之间的区别。
HashTable其实现在很少用了,但还是提一下主要的区别吧。
以上,是关于HashMap 1.8的全部内容。
谢谢各位的观看。本人才疏学浅,如有错误之处,欢迎指正,共同进步。