HashMap源码解析
关于HashMap的源码解析网上貌似不少了,因此不根据知识点进行汇总,而是根据可能问到的问题一步一步深入吧,这样也便于八股哈哈。
目录
版本的区别
JDK1.7和JDK1.8中HashMap的区别
简单答:
JDK1.7 | JDK1.8 | |
---|---|---|
数据结构 | 数组+链表 | 数组+链表/红黑树 |
链表插入方法 | 头插 | 尾插 |
数组初始化 | 调用inflateTable()初始化一个数组 | 直接在第一次插入时resize |
扩容时机 | 插入前扩容 | 插入后扩容 |
扩容策略 | 2倍+rehash | 数组容量小于64则2倍,如果数组大小>64且链表长度>7则转为红黑树 |
hash计算 | 直接使用key的hashcode | hashcode(key)^hashcode(key)>>>16 |
为啥引入红黑树?
哈希碰撞难以避免,极端情况下HashMap的查找时间复杂度也是O(n),如果采用红黑树可以降到O(logn)
与其他容器的区别
HashMap和HashTable的异同
同: 都是key-value
异:
- HashMap可以保存key为null的键值对,始终存在数组下标为0的地方,HashTable存不了key为null的键值对
- HashMap是线程不安全的,HashTable对每个方法加上了synchronized
- HashMap实现的是AbstractMap接口,而HashTable实现的是Dictionary接口
- 迭代器:HashMap使用的是fail-fast的迭代器,通过比较modCount来判断该HashMap有没有在迭代的时候被修改,如果modCount不一致则抛出异常;HashTable不是fail-fast的,因为线程安全
- 初始值和扩容方式:HashMap初始大小为16,每次扩容2倍;HashTable初始大小11,每次扩容为2n+1
- key-value的hash算法不同,hashmap是自定义算法,HashTable是采用key自带的hashcode()。
和ConcurrentHashMap的异同
这个见ConcurrentHashMap的源码分析部分吧,有点复杂
源码细节
简单讲讲插入过程
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.判断数组是否初始化,未初始化则直接resize
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.数组对应位置为空,直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果最终找到了相同的key的node,则由e保存
Node<K,V> e; K k;
// 3.数组对应位置存在链表,首先比较头结点
// 3.1. 首先比较hash,hash不等则key一定不会相等,hash相同key可能相同
// 3.2. hash相同再使用equals进行比较
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 头结点的key不相同,开始对链表进行查找
// 4.1. 如果已经树化了,则采用红黑树的插入方式
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 4.2. 还是链表,则逐个开始匹配
else {
// binCount用来计数
for (int binCount = 0; ; ++binCount) {
// 4.2.1 遍历到链表末尾还没找到,则直接进行尾插,并检查是否需要树化
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 4.2.2 对链表中的每一个节点进行匹配,规则与之前一致
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 5. 如果找到了key相同的节点,修改节点的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 6. 修改结束后进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
大致流程如注释,较为简单,记住关键点:
- 使用的是尾插:避免多线程下的循环链表问题
- 第一次插入的时候进行数组的初始化
- 判断key相同的顺序:hash->equals,本质就是理解hashcode()和equals()之间的关系
- treeify的条件:数组容量>64且链表长度>=8,注意到数组容量是在
treeifyBin
方法中检查的,如果小于64同样是调用resize
方法 - 插入后判断扩容:如果是插入之前就扩容,扩容的部分可能会不使用,因此产生浪费
为啥JDK1.8计算数组索引时是用按位与?
为啥HashMap数组大小是2的n次幂?
答:
本质上来说,HashMap计算数组索引的方法是使用取模的方法,即MOD。但是取模具有如下的缺点:
- 计算慢
- 负数取模是负数
但是很巧的是对于 2 n 2^n 2n进行取模运算有如下性质:
x % 2 n = = x ∧ ( 2 n − 1 ) x \% 2^n == x \land (2^n-1) x%2n==x∧(2n−1)
这个公示巧妙的将模运算变成了按位与运算,解决了模运算的缺点,因此最终计算下标的公式就是hash ^ (n-1)
,n也因此一般取2的n次幂。
get方法是如何实现的?
和插入其实差不多,本质也是找相同的key的Node,找不到返回null,略略略
讲讲扩容?
JDK1.7
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; //把next保存下来
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //重新计算该节点的hash,并重新计算下标
e.next = newTable[i]; //进行头插
newTable[i] = e;
e = next; //移动到下一个节点
}
}
}
可见JDK1.7中较为简单,就是根据新的容量创建一个新的数组,然后将原数组中的所有entry重新计算一遍hash然后插入进去。
但注意的是:
- 使用头插法的结果:插入新的数组的顺序和原来数组上链表的顺序相反。
- 注意:这种逆序的扩容方式在多线程时有可能出现环形链表,出现环形链表的原因大概是这样的:线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。
JDK1.8
本质是一样的,但是实现方法略微复杂,略过
为啥JDK1.8变成尾插法?
JDK1.7及之前使用的是头插法,这在并发的时候会产生循环链表的问题,具体是在resize()的时候进行rehash()从而发生的。具体原因如下:
状态①: 线程A、B同时对HashMap进行扩容,且同时获取了节点1准备进行rehash的计算
状态②: 线程A率先完成了rehash的计算,假设节点1和2巧合地又计算出相同的索引,很明显根据头插法,此时2应该作为第一个节点。注意线程B获取到的还是节点1,即他等下先对节点1进行rehash的计算。
状态③: 线程B进行如下操作,首先rehash,得到的索引肯定还是相同的,因此进行头插,头插的步骤如下:
e.next = newTable[i]; //进行头插
newTable[i] = e;
e = next; //移动到下一个节点
因此这一步的结果应该如下:
很明显此时形成了环形链表,在get的时候因为要对链表进行遍历,可能会造成死循环。尾插法的话则不会有这个问题。
平时在使用HashMap时一般使用什么类型的元素作为Key?
一般采用不可变类型,如Integer,String。如果对该类型进行了修改一般是重新new一个对象,此时hashcode也会相应的变化。不可变类型天然就是线程安全的,=。