(1) 谈谈对HashSet原理的认识?
HashSet在存储元素的时候会调用对象的hashCode方法计算存储索引位置;如果其索引位置存在元素(哈希碰撞),则该位置上所有元素进行equals比较:如果该位置没有其他元素或者比较的结果都是false,则存进去,否则就不存
总结: 元素按照哈希值来找位置,故无序,通过hashcode和equals保证不重复存储。
因此: 我们在往HashSet集合中存储元素的时候,应该正确重写Object类的hashCode和equals方法.否则会出现不可预知的错误.
(2) 说说HashSet和HashMap的区别?
从实质上来说,HashSet是一个Map对象的包装,只是Map的Value为Object的固定对象,Set只利用了Map的Key,具体来说区别如下:
- HashMap实现了Map接口,而HashSet实现了Set接口;
- HashMap存储键值对,而HashSet仅仅存储对象
- HashSet使用put()方法,HashSet使用add();
- HashMap使用键(key)对象来计算HashCode,而HashSet使用成员对象计算hashCode;
(3) 下面两种遍历方式有什么区别?
Map map = new HashMap();
Iterator it = map.entrySet().iterator();
while(it.hasNext() ){
Map.Entry entry = (Map.Entry)it.next();
Object key = entry.getKey();
Object value = entry.getValue();
}
//第二种
Map map = new HashMap();
Iterator it = map.keySet().iterator();
while(it.hasNext()){
Object key = it.next();
object val = map.get(key);
}
第一种效率高且推荐使用.
HashMap这两种遍历分别对keySet 和 entrySet进行迭代,
-> 对于keySet实质上是遍历两次,一次是转Iterator迭代器,一次是从hashMap中取出key对应的value(通过key的hashCode和equals索引;
-> 而entrySet()只遍历的一次,他把key和value都放到了Entry中)
(4) 为什么说HashMap中String,Integer这样的包装类适合作为key键,即为什么使用它们可以减少哈希碰撞?
因为String,Integer 包装类都是final类型的.具有不可变性,而且已经重写了equals和hashCode方法.不可变性保证了计算hashCode()后,键值得唯一性和缓存特性,不会出现放入和获取哈希码不同的情况且读取哈希值的高效性.(官方实现的equals()和hashCode()严格遵守相关规范也是一重保障)
(5) 简单说说HashMap底层原理?
当我们往HashMap中put()元素,先根据key的hash值得到这个Entry在数组中的位置(下标),然后把这个Entry元素放到对应的位置(index)上,如果这个Entry元素所在的位置上已经有了其它元素,则以链表的方式放在这个位置上.
新加入的元素放在链头(因为最初创造HashMap的人认为新加入的元素更可能被用到);
从HashMap中get()的话,Entry元素先计算key的hashCode,找到对应index的Entry,然后通过key的equals()方法在对应位置的链表中匹配对应需要的Entry元素.
所以HashMap的数据结构是数组+链表的结合.
此外: HashMap中的key 和 value 都可以为null,key为null的键值对永远放在table[0]为头结点的链表中.
这么设计的原因:
数组的存储空间是连续的,占用内存严重,故而空间复杂度大,但是二分法查找时间复杂度小(O(1)),所以寻址容易而插入和删除困难.
链表存储空间区间离散,占用内存比较宽松,故而空间复杂度小,但是时间复杂度大(O(N)),所以寻址困难而插入和删除容易;
所以产生一种新的数据结构叫做哈希表.既满足数据的查找方便,同时不用占用太多的内容空间,使用时间什么方便,同时不占用太多的内容空间,使用也十分方便,哈希表有多种实现方法,hashMap采用的是链表的数组实现方式.
特别说明 :
JDK1.8 开始HashMap的实现原理编程了数组+链表+红黑树的结构,数组的链表部分基本没变,红黑树为了解决哈希碰撞后索引链表效率的问题,所以当链表的节点大于8个的时候将链表换成红黑树;
区别:
- JDK 1.8之前,碰撞节点会在链表头部插入,而JDK1.8开始碰撞节点会在链表尾部插入,对于扩容操作后的节点转移JDK1.8以前后链表顺序会倒置.而JDK1.8中依然保持原序.
(6) 简单说说你对HashMap构造方法中initialCapacity(初始容量),loadFactory(加载因子)的理解?
这两个参数对于HashMap来说很重要,直接从一定程度决定HashMap的性能问题.initialCapacity初始长度,不过特别注意:table数组的长度虽然依赖initialCapacity,但是每次都会通过roundUpToPowerOf2(initialCapacity) 方法来保证2的幂次.
-> loadFactor 加载因子是哈希表在其容量自动增加之前可以达到多满的一种饱和度百分比,其衡量了一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高;
-> 散列当前饱和度的计算为当前HashMap中Entry的存储个数除以当前table数组桶长度,因此当哈希表中Entry的数量超过了loadFactory加载因子乘以当前table数组桶长度时就会触发扩容操作.(当使用容量大于3/4,则扩容)对于使用链表法的散列表来说,查找一个元素的平均时间是(1+a),因此如果负载因子越大,则对空间的利用越充分,从而导致查找效率的降低;如果负载因子太小则散列表的数据过于稀疏,从而造成浪费.系统默认负载因子为0.75,一般情况下无需修改.
因此,如果哈希数组很大则较差的Hash算法分布也会比较分散,如果哈希桶数组很小即使好的Hash算法也会出现较多的碰撞,所以就需要好的Hash算法和扩容机制,也就是initialCapacity(初始容量)、loadFactor(加载因子)的作用.
(7) JDK1.7中HashMap什么情况下会发生扩容?如何扩容?
HashMap 中默认的负载因子为 0.75,默认情况下第一次扩容阀值是 12(16 * 0.75),故第一次存储第 13 个键值对时就会触发扩容机制变为原来数组长度的二倍,以后每次扩容都类似计算机制;这也就是为什么 HashMap 在数组大小不变的情况下存放键值对越多查找的效率就会变低(因为 hash 碰撞会使数组变链表),而通过扩容就可以一定程度的平衡查找效率(尽量散列数组化)的原因所在。
//JDK1.7扩容最核心的方法,newTable为新容量数组大小
void transfer(HashMapEntry[] newTable){
//新容量数组桶大小为旧的table的2倍
int newCapacity = newTable.length;
//遍历旧的数组桶table
for(HashMapEntry<K,V> e : table){
while(null != e){
//取当前这个索引位上单项链表的下一个元素
HashMapEntry<K,V> next = e.next;
//重新依据hash值计算元素在扩容后的数组中的索引位置
int i = indexFor(e.hash,newCapacity);
//将数组的i匀速复制给当前链表元素的下一个节点
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
(8) JDK1.8 中HashMap什么情况下会发生扩容?如何扩容?
final Node <K,V>[] resize() {
Node <K,V>[] oldTab = table;
//记住扩容前的数组长度和最大容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if(oldCap > 0) {
//超过数组在java中最大容量就无能为力了,冲突就只能冲突
if(oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//长度和最大容量都扩容为原来的二倍
else
if((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
// double threshold
}......
......
//更新新的最大容量为扩容计算后的最大容量
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;
//如果老数组对应索引上有元素则取出链表头元素放在e中
if((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果老数组j下标处只有一个元素则直接计算新数组中位置放置
if(e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else
if(e instanceof TreeNode)
//如果是树结构进行单独处理
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else{
// preserve order
//能进来说明数组索引j位置上存在哈希冲突的链表结构
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循环处理数组索引j位置上哈希冲突的链表中每个元素
do
{
next = e.next;
//判断key的hash值与老数组长度与操作后结果决定元素是放在原索引处还是新索引
if((e.hash & oldCap) == 0) {
//放在原索引处的建立新链表
if(loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else{
//放在新索引(原索引+oldCap)处的建立新链表
if(hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
}
while((e = next) != null);
if(loTail != null) {
//放入原索引处
loTail.next = null;
newTab[j] = loHead;
}
if(hiTail != null) {
//放入新索引处
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
补充:
- 因为 hash 值本来就是随机性的,所以 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈西冲突的元素再随机的分布到不同的索引去,这算是 JDK1.8 的一个优化点。
- 在 JDK1.7 中扩容操作时哈西冲突的数组索引处的旧链表元素扩容到新数组时如果扩容后索引位置在新数组的索引位置与原数组中索引位置相同,则链表元素会发生倒置(即如上面图1,原来链表头扩容后变为尾巴);而在 JDK1.8 中不会出现链表倒置现象。
- 其次,由于 JDK1.7 中发生哈西冲突时仅仅采用了链表结构存储冲突元素,所以扩容时仅仅是重新计算其存储位置而已,而 JDK1.8 中为了性能在同一索引处发生哈西冲突到一定程度时链表结构会转换为红黑数结构存储冲突元素,故在扩容时如果当前索引中元素结构是红黑树且元素个数小于链表还原阈值(哈西冲突程度常量)时就会把树形结构缩小或直接还原为链表结构(其实现就是上面代码片段中的 split() 方法)。