参考链接
https://www.bilibili.com/video/BV1FE411t7M7?spm_id_from=333.999.0.0
https://www.html.cn/qa/other/20583.html
https://blog.csdn.net/weixin_41105242/article/details/106972635
https://blog.csdn.net/fan2012huan/article/details/51087722
https://segmentfault.com/q/1010000012304833
…等等等等
- 本文其中有一个邪门问题(entrySet和keySet方法的生命周期),作者花了十几个小时,查遍了国内外很多资料最后才总结的
- 与其他面试题相比,本文能解决的疑惑(第3个大标题):
- idea在debug时偷偷调用toString方法,在HashMap追源码时产生的疑惑
- HashMap的entrySet()和keySet()方法真的创建了一个存满数据的set对象吗?
- 内部类:EntrySet类没有构造器,那它是怎么初始化的呢?
- 为什么集合框架中一定要利用好迭代器iterator()来获取入口?
- entrySet().size()获取的值是该set的实际大小吗?
- …
1.数据结构
- 1.7中是:数组+单链表——头插法(多线程写可能死循环)
1.8中是:数组+单链表——尾插法 + 红黑树 - 数组默认初始大小16,负载因子默认0.75(泊松分布下的折中最佳值)
JDK1.7:数组+单向链表
Q:为什么使用单向链表而不使用双向链表?
插入新数据or查询的时候需要遍历相同桶中的所有元素,来确保和相同hash值的所有元素调用equals方法进行比对
Q:头插法会出现什么问题?
并发下:同时扩容可能出现死循环:
JDK1.8:数组+单向链表+红黑树
Q:为什么选择红黑树
- 红黑树是一个绝对平衡的二叉树,插入、删除、查询的效果都比较平衡;
- 单向链表过长则会导致效率降低
- 而选择二叉树则可能在极端情况下成为链表;
Q:单链表什么时候转红黑树
- 当同时满足:链表长度>=8 且 底层数组长度>=64
- 底层数组长度不足64时,选择扩容来解决链表过长问题;
Q:JDK1.8使用尾插法
- 防止了头插法在并发扩容场景下可能出现的死循环
Q:底层数组的元素存储的是值还是节点?
底层数组本身上面存储的是Node implements Map.Entry节点,有一个next指针指向下链表or红黑树的root节点
2.源码解析
2.1 成员变量
内部类
-
JDK7中使用的是Map.Entry存储键值对, JDK8中Node实际上只是Entry的一个套壳
-
TreeNode就是红黑树的节点类
成员变量
约定前面的数组结构的每一个格格称为桶
约定桶后面存放的每一个数据称为bin
bin这个术语来自于JDK 1.8的HashMap注释。
由图可知:
- 实现了序列化机制
- 1<<<4 位左移4,相当于2^4 = 16,默认底层数组容量是16
- 1<<<30 位左移30,相当于2^30 ,即最大底层数组容量
- 默认加载因子:0.75,这个数是泊松分布下的折中最佳值:假设底层数组长度是默认值16,当桶数量 >= 16*0.75 = 12时,则扩容
- 单链表最大长度=8 , 超过8则扩容 or 转红黑树
- 当底层数组长>=64时,才会选择生成红黑树,否则扩容
- table即底层数组本身存储的是Node<K,V>节点,链表or红黑树的root节点只是被Node.next指向而已,他们本身不存储在table数组中
- entrySet:获取所有元素的set集合(具体过程在后面有)
- size:注意区分capacity(数组长度),
size
是实际k-v对的个数;capacity
是数组长度;threshold
= capacity * loadFactory是扩容临界域 - modCount:记录HashMap被修改的次数,Put、Clear等修改操作都会++modCount。并且各种操作都会判断modCount的值是否改变,以在并发场景下抛出异常。
- threshold:扩容临界域,桶数量超过该值则扩容
- loadFactor:负载因子(加载因子):默认0.75,也可在构造函数中设置。0.75并不是统计学上的最佳,这跟不同的计算机也有关,而是JDK取了一个折中的值。
2.2 常用方法
put方法
put方法实际调用的是putVal:这个方法会计算hash值、++modCount计数器、哈希碰撞的处理(链表or红黑树)、判断添加之后是否需要扩容
Q:这里为什么是元素总数size
> threshold 而不是 已使用的桶的数量 > threshold扩容边界值
呢?
补:size、threshold、桶的使用个数
- 首先,JDK源码中并没有给出桶的使用个数这个filed,只给出了threshold临界阈:例如initCapacity = 16 ,那么threshold = 12 。
- size是所有元素的个数,查看JDK源码的put方法可知:只要size>桶临界阈threshold,就会进行扩容resize()。
- 因此可能存在一种
极端情况
:16个桶(capacity=16),极端的哈希碰撞只使用了两个桶(一个桶7个元素,另一个桶6个元素),但是size > 12 ,扩容。
hash值计算
我们在put方法中可以看到,调用了hash()方法,这个哈希方法首先计算出 key 的 hashCode 赋值给 h,然后与 h 无符号右移 16 位后的二进制进行按位异或得到最后的 hash 值
补:为什么capacity必须是2^n
- 首先为了提高性能,JDK中大量使用了
位运算
,比如在计算hash值的时候,利用按位与hash&(cap-1),一个数如7(0111)的cap-1就是(0110)。那么最后一位数的按位与始终是0 - 而一个数如8(1000)的cap-1就是(0111),低位都是1,按位与不容易出现哈希碰撞
- 总之:2的n次幂 - 1 的二进制码
低位全部都是1
,于hash值相与
时不会改变hash的低位值,因此减少了碰撞的概率
补:JDK如何做到扩容时保持capacity=2^n?
底层也是用了位运算,效果就是:扩容后的是最接近的2^n
扩容时调用的resize()方法
- 扩容是一个非常耗费资源的操作,并且在JDK7版本需要对所有元素进行hash值重新计算
- JDK8引入了新的算法rehash(这不是一个JDK方法,这是一个算法),在rehash算法下,可以让一部分元素待在原索引处,另一部分元素 索引 += 新增的容量数
remove删除
- 如果删除后该桶处的红黑树节点<=8,则红黑树转为单链表
- 删除也是先算hash定位,然后遍历红黑树or单链表
同理get()方法也是先算hash定位,后在桶中遍历
2.3遍历的方法
一般情况下都是使用迭代器,迭代器的效率最高,阿里开发手册指出:第三种方法会遍历两次
2.4迭代器原理
可以自己写一个,然后跟着debug
Set set = hs.entrySet();//重点关注entrySet怎么来的
Iterator iterator = set.iterator();
while (iterator.hasNext()){
Map.Entry next = (Map.Entry) iterator.next();
next.getKey();
next.getValue();
}
iterator.next()方法
- 调用next()方法相当于调用Iterator接口中的nextNode()方法
- 在HashIterator类中查看nextNode()方法
2.5 entrySet()、keySet()是如何获取Set的?
- 总所周知HashMap的底层是数组+链表+红黑树
- 并且put方法中并没有同时构造一个entrySet、keySet
- 那么hashMap.entrySet()为何就能直接获取entry键值对的Set集合的呢?
我们以keySet()方法为例:
调用keySet()方法会 new一个keySet类对象
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();//1.调用keySet()方法时就new一个KeySet对象
keySet = ks;
}
return ks;
}
//2.但是KeySet类中并没有一个构造方法来使得keySet被赋值
final class KeySet extends AbstractSet<K> {
//3.有的也只是重写了size()方法,返回的是当前hashMap的size
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
//4.并且初始化时也没有调用迭代器
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
public final Spliterator<K> spliterator() {
return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
//5.更没有调用forEach方法
public final void forEach(Consumer<? super K> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
然后我们去KeySet的父类,发现只有一个空参的空构造器,那么entrySet()和keySet()方法在调用的时候,到底是如何赋值的呢?
3.entrySet()和keySet()详解
- 前面已经提到,entrySet()和keySet()方法都是返回一个set对象,并且在debug时也能看到set的值和size;但是源码中并没有利用构造器对其初始化。
- 在此文中以entrySet()举例,来阐述entrySet和keySet的生命周期
3.1测试代码示例,并提出问题
HashMap hs = new HashMap();
hs.put("啊啊", "2");
hs.put("2", "2");
hs.put("3", "2");
hs.put("4", "2");
hs.put("5", "2");
hs.put("6", "2");
hs.put("7", "2");
hs.put("8", "2");
hs.put("9", "2");
Set entrySet = hs.entrySet();//1.获取entrySet对象
System.out.println(entrySet );//2.结果:正确输出了上面put的内容
Object[] objects = entrySet .toArray();
System.out.println(objects[3]);//3.结果:正确打印了5=2
Iterator iterator = entrySet .iterator();//4.获取迭代器
while (iterator.hasNext()){//5.迭代器遍历
Map.Entry next = (Map.Entry) iterator.next();
next.getKey();
next.getValue();
}
- 如果在idea中使用debug对
Set entrySet = hs.entrySet()
打断点,你会发现即便是没有运行到第二步,debug信息栏中已然出现了该set全部的内容。 - 跟着debug一直step into进行分析,发现并没有任何一个方法对entrySet对象进行过赋值,那么hashMap对象中的内容是如何进入到entrySet中的呢?
3.1 debug分析
new EntrySet()
- 没有找到构造函数
- AbstractSet中只有一个空参且空函数体的方法
第一个结论:entrySet是空对象
entrySet只是new出来了,但它是一个没有内容的空对象。
3.2第二个问题:entrySet对象调用方法时为何有值?
既然 Set entrySet = hs.entrySet()
出来的entrySet 对象是个空对象,那为何System.out.println(entrySet );
Object[] objects = entrySet .toArray();
Iterator iterator = entrySet .iterator()
对entrySet对象进行调用方法的时候却能正确的执行呢?
3.2 源码分析
toString方法
System.out.println(entrySet );
显然调用的是toString方法;
- 在2.5中的源码可知entrySet对象继承于
AbstractSet
类,间接实现了AbstractCollection
接口 - 重写的toString()方法调用了
this.iterator()
即EntrySet类的iterator()方法进行输出
iterator方法
-
我们发现HashMap中的内部类EntrySet中重写了iterator()方法,实际调用的是EntryIterator对象
-
EntryIterator类继承于HashIterator类 :nextNode方法是HashIterator中的方法
-
跟进nextNode方法,框体内容是指针的变化的判断细节,不是本论题的重点
由此可知:
这个EntrySet类重写的iterator()方法可以使得指针正确得指向下一个节点…
toArray方法
- 测试代码中也正确输出了toArray()的结果,同样的,这个方法也是重写在其父类
AbstractCollection
中的 - 同理,其底层实现还是调用了iterator(),利用HashIterator类中的nextNode()方法对指针进行下移
size、clear方法
这两个方法很简单,就是直接用的当前HashMap对象的属性和方法
hashMap.entrySet().size()的大小并不是真正这个entrySet对象的大小,而是调用了size属性的fake size
3.3小总结
- entrySet()和keySet()都是懒汉式,调用方法,new对象的时候并没有对其进行赋值
- 而是在使用hashMap.entrySet().toString()、hashMap.entrySet(). toArray() 等方法的时候才
调用iterator()来获取迭代器
- EntrySet类和KeySet类也都没有构造器能进行初始化
- 不得不说HashMap的设计十分精巧,entrySet()表面上获取了一个set对象,实际上这个set对象是空的,几乎所有的方法都是先直接获取迭代器入口,性能大幅提升
3.4关于idea在debug时的问题
- DEA在debug时,当debug到某个对象时,会调用对象的toString()方法,用来在debug界面显示对象信息。
- IDEA调用toString()方法时,即使在toString()方法中设置了断点,该断点也不会被触发,也就是说,开发者多数情况下不会知道toString()方法被调用了。
- 多数情况下调用一下toString()方法没有什么问题,但是也有例外,
比如重写了toString()方法的类,随意的调用toString()方法会导致未知的问题
。本案例就是因为重写toString()方法而产生了问题