在1.7之前是一个数组加链表,数据节点是一个entry节点,是它的一个内部类(头插法) : 在它resize的时候,多线程时候线程A调到 代码Entry<K,V> next => e.next执行完这段代码,线程A挂起;然后线程B开始执行transfer方法,把里面的Entry进行了rehash,B完整的执行完整个扩容流程,接着线程A唤醒,这个过程中可能造成一个链表的循环
在1.8之后 把它变成了一个链表加数组加红黑树的这个一个结构 ,把原来的一个个Entry节电变成了一个个Node节点, put过程也做了优化 ,当链表超过8时,链表就转换为红黑树(尾插法)
// transfer
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
//1, 获取旧表的下一个元素
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
// 头插法
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex]; //头插法
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
Entry( int h, K k, V v, Entry<K,V> n) {
value = v;
next = n; //可以看出确实是把原本的链表直接链在了新建的Entry对象的后边,
key = k;
hash = h;
}
-- 1.8 尾插法
for (int binCount = 0; ; ++binCount) {
//e是p的下一个节点
if ((e = p.next) == null) {
//插入链表的尾部
p.next = newNode(hash, key, value, null);
//如果插入后链表长度大于8则转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果key在链表中已经存在,则退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
//如果key在链表中已经存在,则修改其原先的key值,并且返回老的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
HashMap 详解
1.7
1.8
get大致思路如下:
- bucket里的第一个节点,直接命中;
- 如果有冲突,则通过key.equals(k)去查找对应的entry
- 若为树,则在树中通过key.equals(k)查找,O(logn);
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
put函数大致的思路为:
- 对key的hashCode()做hash,然后再计算index;
- 如果没碰撞直接放到bucket里;
- 如果碰撞了,以链表的形式存在buckets后;
- 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
- 如果节点已经存在就替换old value(保证key的唯一性)
- 如果bucket满了(超过load factor*current capacity),就要resize。
计算hash时具体实现是这样的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
计算哈希的方法我们可知两点
- 当key为null的时候必定存放在第0位的哈希桶上
- 结合了hashCode(),并将其高16bit进行异或操作,使得高位都能参与计算
- table长度n为2的幂, 所以(n - 1) & hash 不容易碰撞,就算是发生了碰撞也用O(logn)的tree去做了
RESIZE的实现
当put时,如果发现目前的bucket占用程度已经超过了Load Factor所希望的比例,那么就会发生resize。然而又因为我们使用的是2次幂的扩展,所以元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
Fail-Fast 机制
我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了 map,那么将抛出 ConcurrentModificationException,这就是所谓 fail-fast 策略。
HashMap 常见问题
- 讲讲你对HashMap 的认识( 由点到面)
- HashMap 初始容量大小,为什么默认是16
- HashMap 的负载因子为什么默认为 0.75,扩容方式为什么设计成 2次幂
- 你知道hash的实现吗?为什么要这样实现?