1.HashMap的数据结构
在JDK1.7和JDK1.8有所差别:
在JDK1.7中,由“数组+链表”组成,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8中,由“数组+链表+红黑树”组成。当链表过长,则会严重影响HashMap的性能,红黑树搜索时间复杂度是O(logn),而链表则是糟糕的O(n)。因此JDK1.8对数据结构做了进一步的优化,引入了红黑树,链表和红黑树达到一定条件会进行转换。
当链表超过8且数据量超过64才会转换红黑树。
将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
2.Hash冲突的解决方案是什么?
2.1插入方式
解决Hash冲突的方法有开放定址法、再哈希法、链地址法、建立公共溢出区。
HashMap中采取的是链地址法。
JDK1.7时,哈希冲突时采取的是头插法。
JDK1.8是,哈希冲突时采取的是尾插法。
- 开放定址法也称为再散列法,基本思想就是,如果pH(key)出现冲突时,则以p为基础,再次hash,p1H§,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址p1,所以只能在删除的节点上做标记,而不能真正删除节点。
- 再哈希法(双重散列、多重散列),提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2==H2(key1),直到没有冲突为止。这样做虽然不一产生堆集,但增加了计算的时间。
- 链地址法(拉链法):将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
- 建立公共溢出区:将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
2.2为什么在解决hash冲突时,不直接使用红黑树?而选择先用链表,再转红黑树?
因为红黑树需要进行左旋、右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,此时做查询操作,链表已经能够保证性能。当元素大于8个的时候,红黑树搜索时间复杂度是O(logn),而链表是O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就使用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
3.HashMap的扩容机制
3.1HashMap的默认加载因子是多少?为什么是0.75,不是0.6或者0.8
int threshold;//容纳键值对的最大值
final float loadFactor;//负载因子
int modCount;
int size;
threshold = length*loadFactor
默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下:
- 如果内存空间很多而对时间效率要求很高,可以降低负载因子loadfactor的值
- 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
3.2HashMap中的key的存储索引是怎样计算的?
首先根据key的值计算出HashCode的值,然后根据HashCode值计算出hash值,最后通过hash&(length-1)计算得到存储的位置。
3.3HashMap的put方法流程
以JDK1.8为例:
- 首先根据key的值计算hash值,找出该元素在数组中存储的下标。
- 如果数组是空的,则调用resize进程初始化
- 如果没有哈希冲突直接放在对应的数组下标里
- 如果冲突了,且key已经存在,就覆盖掉value
- 如果冲突后,key不存在,并且发现该节点是红黑树,就将这个节点挂在树上、
- 如果冲突后是链表,判断该链表是否大于8,如果大于8并且数组容量小于64,就进行数组扩容;如果链表节点大于8并且数组的容量大于64,则将这个结构转换为红黑树;
3.4HashMap的扩容方式
HashMap在容量超过负载因子所定义的容量之后,就会扩容。Java里的数组是无法自动扩容的,方法是将HashMap的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。
//JDK1.7
void resize(int newCapacity){
//传入新的容量
//引用扩容前的Entry数组
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//扩容前的数组大小如果已经达到最大值2^30了
if(oldCapacity==MAXIMUM_CAPACITY){
//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
threshold = Integer.MAX_VALUE;
return;
}
//初始化一个新的Entry数组
Entry[]newTable = new Entry[newCapacity];
//将数据转移到新的Entry数组里
transfer(newTable);
//HashMap的table属性引用新的Entry数组
tbale = newTable;
//修改阈值
threshold = (int)(newCapacity*loadFactor);
}
//这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里
void transfer(Entry[]newTable){
//src引用了旧的Entry数组
Entry[]src = table;
int newCapacity = newTable.length;
for(int j = 0;j<src.length;j++){
//遍历旧的Entry数组
//取得旧Entry数组中的每个元素
Entry<K,V> e = src[j];
if(e!=null){
//是否旧数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
src[j] = null;
do{
Entry<K,V> next = e.next;
//重新计算每个元素在数组中的位置
int i = indexFor(e.hash,newCapacity);
//标记[1]
e.next = newTable[i];
//将元素放在数组上
newTable[i] = e;
//访问下一个Entry链上的元素
e = next;
}while(e!=null);
}
}
}
//newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)。
JDK1.8做了两处优化:
- resize之后,元素的位置在原来的位置,或者原来的位置+oldCap(原来哈希表的长度)。不需要像JDK1.7那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,如果是0索引没变,是1的话索引变成原索引+oldCap。这个设计非常巧妙,省去了重新计算hash值的时间。
- JDK1.7时候出现哈希冲突的时候会采用头插法,而JDK1.8会采用尾插法。
3.5一般用什么作为HashMap的key
一般用Integer、String这种不变类当HashMap当key,而且String最为常用。
- 因为字符串是不可变的,所以它创建的时候hashcode就被缓存了,不需要重新计算,这就是HashMap中的键往往都是用字符串的原因。
- 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,所以这些类已经很规范的重写了hashCode()以及equals()方法。
4.HashMap的线程安全性
- 多线程下扩容死循环。
JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能会导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持原本的顺序,不会出现环形链表的情况。 - 多线程的put可能导致元素的丢失。
多线程同时执行put操作,如果计算出来的索引位置是相同的那会造成前一个key被后一个key覆盖,从而导致元素的丢失。在JDK1.7和1.8中都存在。 - put和get并发时,可能导致get为nul。
线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题JDK1.7和JDK1.8中都存在。
解决方案:
1、使用ConcurrentHashMap
2、使用Collections.synchonizedMap(Map<K,v> m)方法将HashMap变成一个线程安全的map。
5.为什么是2的幂次方
- 效率:
取余操作%如果除数是2的幂次方等价于其除数减一的与&操作。
也就是hash%length==hash&(length-1)的前提是length是2的n次方。
并且采用二进制为操作&,相对于%能够提高运算效率。 - 均匀分布
6.为什么重写equals()必须重写HashCode()
equals()本质是比较两个对象的地址值。如果重写了equals()方法,那么就可以达到比较两个不同地址值的对象具有完全相同的内容。
而HashCode()本质上是对对象地址值的hash运算,相同的地址值会出现相同的hash值,而不同对象会出现不同的哈希值。
因为两个对象相等的话,他们的哈希值肯定是相同的。
所以需要对HashCode方法进行重写。