参考:
https://blog.csdn.net/v123411739/article/details/78996181
https://blog.csdn.net/v123411739/article/details/78996181
https://csp1999.blog.csdn.net/article/details/117192375
建议先通读上面的博客,了解一些HashMap源码的知识
jdk8 和jdk7中HashMap实现有什么区别?
1 java8中的HashMap引入了红黑树和链表并存的结构存储数据,而java7中桶上存的是链表,这里注意,无论哪个版本的JDK,桶数组上的链表都是单链表,只能单向遍历
2 java7中的HashMap在多线程情况下添加数据再调用get方法会报无限循环的错误
java7 8两个版本的HashMap都不是线程安全的,因此sun认为这个问题不算是一个bug,本身就不该在多线程场景下操作HashMap,这里说下java7中的HashMap在多线程情况下会产生这个错误的原因。
参考这里疫苗:Java HashMap的死循环 | 酷 壳 - CoolShell
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
java7中HashMap扩容时,迁移数据是“头插法”,原链表的根部在插入后一般会是新链表的头部,当两个线程一起操作,B线程假如已经完成了迁移,链表从1 -> 2->3->4变成了4->3->2->1,而A线程的数据副本还是1 -> 2->3->4,它也要倒装链表到新桶数组中,一来二去成了环装链表,调get遍历链表自然报错
java8中将数据迁移改成了尾插法,避免了这个问题
HashMap是如何避免hash冲突的?
先要了解,HashMap存储数据是数组,元素(链表或者红黑树的节点)在数组上的位置是通过hash算法确定的。从初衷上来,元素key值计算出来的下标都不相同是最好的,这样数组中每个位置都是不同的元素,看看java8计算下标的方法
// 代码1
static final int hash(Object key) { // 计算key的hash值
int h;
// 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;
key的hash值对数组长度取模运算就可以获得相对均匀的hash分布了,计算机中&运算的效率是高于取模运算的,使用上面代码的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:
x mod 2^n = x & (2^n - 1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。
这里还有一个点,hash值不是直接与数组长度-1作&运算,而是先与自己的高16位进行异或运算再把得到的值参与运算,这是为了避免hash值高位变化频繁而低位变化较少时候,最后运算得到的数组下标会有很多一致的,导致冲突
为什么HashMap长度总是2的n次幂
HashMap的初始容量是16,每次扩容会比之前大一倍,基于上一个问题,我们知道HashMap容量一定是2的n次幂,但是HashMap明明可以通过构造方法来指定初始容量和扩容因子(默认0.75),我指定个容量为7不行么?
看这个方法
//在执行完这个方法后,会得到大于cap的最小的n次幂
//原理就是不断右移作或运算,低位全是1,高位全是0,最后加1后得到2^n
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^n
为什么 Map 桶中结点个数超过 8 才转为红黑树?
Because TreeNodes are about twice the size of regular nodes, we use them only when bins
contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too
small (due to removal or resizing) they are converted back to plain bins. In usages with
well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes,
the frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution)
with a parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance,
the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
翻译:因为树结点的大小大约是普通结点的两倍,所以我们只在箱子包含足够的结点时才使用树结点(参见TREEIFY_THRESHOLD)。
当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户 hashCode 时,很少使用树箱。
理想情况下,在随机哈希码下,箱子中结点的频率服从泊松分布。
默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预朗出现次数是(exp(-0.5)*pow(0.5, k) / factorial(k)
第一个值是:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
TreeNodes(树) 占用空间是普通 Nodes(链表) 的两倍,所以只有当 bin(bucket 桶) 包含足够多的结点时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESH〇LD 的值决定的。当 bin(bucket 桶) 中结点数变少时,又会转成普通的 bin(bucket 桶)。并且我们查看源码的时候发现,链表长度达到 8 就转成红黑树,当长度降到 6 就转成普通 bin(bucket 桶)。
这样就解释了为什么不是一开始就将其转换为 TreeNodes,而是需要一定结点数之后才转为 TreeNodes,说白了就是权衡空间和时间。数组长度不超过64不树化也是这个道理
这段内容还说到:当 hashCode 离散性很好的时候,树型 bin 用到的概率非常小,因为数据均匀分布在每个 bin 中,几乎不会有 bin 中链表长度会达到阈值。但是在随机 hashCode 下,离散性可能会变差,然而 jdk 又不能阻止用户实现这种不好的 hash 算法,因此就可能导致不均匀的数据分布。不理想情况下随机 hashCode 算法下所有 bin 中结点的分布频率会遵循泊松分布,我们可以看到,一个 bin 中链表长度达到 8 个元素的槪率为 0.00000006,几乎是不可能事件。所以,之所以选择 8,不是随便決定的,而是裉据概率统计决定的
说人话就是,树的内存开支要大于链表,而节点数到达8个事不太可能的事情,只有到了8个,才有花费内存去做树化的价值(你给三个元素整棵树,还不如链表来的快)
简述一下HashMap的扩容机制
当 HashMap 中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor 的默认值是 0.75。默认初始化长度是16,负载因子是0.75,所以默认扩容长度是12,HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位(2n二进制高位比n多个1),所以结点要么就在原来的位置,要么就被分配到 “原位置 + 旧容量” 这个位置,这意味着扩容后的hash分布至少是与扩容前相同的,或者是优于扩容前的。
把HashMap和HashTable作个比较吧(老生常谈)
1 HashMap线程不安全,HashTable通过加锁的方式实现了线程安全
2 HashMap 键 值都允许为null HashTable则都不允许
3 二者解决hash冲突方式不同
- HashMap:
index = hash & (tab.length – 1)
- HashTable:
index = (hash & 0x7FFFFFFF) % tab.lengt
HashMap长度初始化为16,HashTable为11,HashMap每次扩容是两倍,HashTable扩容后是原来长度n的2n+1,就是始终维持桶数组长度为奇数个
4 java8中HashMap是数据 + 链表 + 红黑树实现的,HashTable是数组+链表实现的
HashMap有哪些遍历方式
1 迭代器
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Object> mapEntry = iterator.next();
System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());
}
2 keySet entrySet
Set<String> keySet = map.keySet();
for (String str : keySet) {
System.out.println(str + "---" + map.get(str));
}
3 foreach
HashMap<String,String> map = new HashMap();
map.put("001", "zhangsan");
map.put("002", "lisi");
map.forEach((key, value) -> {
System.out.println(key + "---" + value);
});
推荐使用迭代器或者是foreach进行遍历
最后:
原本准备整理源码阅读的,但在收集资料的过程中发现源码有很多人已经做过了而且做的很好,有大量现成的学习资料了,而且为了看HashMap还研究了一周多的红黑树,敖丙的博客将红黑树和234 23树一起讲解给了我很大的帮助,但是到现在都没有发现关于红黑树的删除节点平衡操作的学习资料,偶尔查到一些也是很难理解。所以到这里,源码只作阅读,整理做类似笔记的博客,而源码的学习主要通过转载别的优秀博客来进行
关于HashMap,主要问点就是扩容机制 解决hash冲突的办法 以及红黑树的数据结构,偶尔可能问到java7中多线程下HashMap写入数据再get报错的问题