目录
——谈谈你对HashMap的认识吧。
HashMap底层由 数组(也叫位桶)+链表/红黑树 实现,其中红黑树是jdk1.8引入的。【——jdk1.8为什么要引入红黑树?】【——HashMap什么时候用链表,什么时候用红黑树?】
HashMap首先维护了一个数组,数组中的每个元素都是一个Entry对象,每个Entry对象包含四个属性:[key、value、next、hash]。
其中key、value就是插入键值对的键和值,next是对另一个Entry对象的引用,默认为null,hash是这个Entry对象的哈希值。
向HashMap容器中插入Entry对象时,会先使用对象的key通过哈希函数得到一个hash值,然后使用hash值计算得到一个数组下标。【——哈希函数具体是怎么实现的?】【——怎么使用hash值计算数组下标?】如果得到的数组下标的位置为空,就插入;如果位置上已有其它Entry对象,说明发生了hash冲突(也叫hash碰撞),当发生hash冲突时,就把要插入的Entry对象和该位置上的其它Entry对象通过next属性连接起来形成链表。
jdk1.7及之前,采用头插入方式,即 将新来Entry对象的next属性设为对链表上首个Entry对象的引用。jdk1.8及之后,改为尾插入方式。【——为什么jdk1.8中链表的插入方式要改为尾插入?】
——jdk1.8为什么要引入红黑树?
jdk1.7及之前,HashMap的结构是 数组+链表。链表的优点是添加、删除元素很快,但查询效率比较低。引入红黑树是为了提高HashMap的查询效率。
红黑树是 平衡二叉查找树。
二叉查找树的规则是任意结点的左子树(如果有)上的所有结点的值均小于该结点的值,右子树(如果有)上的所有结点的值均大于该结点的值。
红黑树在二叉查找树的基础上做了平衡,保证每个结点的左子树和右子树的高度差最大为2,如果超过了就进行调平衡。
调平衡操作包括左旋、右旋和变色。
红黑树的任何不平衡问题都能在三次旋转之内解决。
红黑树每个结点的左右子树的高度差最大为2,说明红黑树不是严格的平衡二叉树(AVL)。
【实际上红黑树每个结点的左右子树上的黑色结点的高度差永远为0。】
为什么不用AVL树而采用红黑树?
AVL树调平衡的代价比较大。
——遍历链表和红黑树的时间复杂度各是多少?
遍历链表的时间复杂度是O(n),遍历红黑树的时间复杂度是O(logn)。
HashMap根据key查找的时间复杂度只取决于桶里面的数据结构。
——来手写一个红黑树。
戳下面网址练习:
https://rbtree.phpisfuture.com/
——HashMap什么时候用链表,什么时候用红黑树?
数组上某个下标位置上的结点数目增加到8个时,链表转换成红黑树;之后,结点数目降到6个时,红黑树转换成链表。
HashMap是为了提升查询效率才采用红黑树结构,但是,
- 当链表长度很小的时候,即使不转换成红黑树,查找速度就已经够用了;
- 链表转换成红黑树会消耗资源;
- 链表转换成红黑树之后,会占用较大的空间。
所以能用链表满足查询效率需求,就尽量避免转换成红黑树。
链表转换成红黑树的阈值为8,是因为在理想情况下,所有结点在数组内遵循泊松分布,在数组的某个下标上链表长度达到8的概率微乎其微,这就保证了绝大部分情况下,链表不会转换成红黑树。
红黑树转换成链表的阈值为6,是为了避免链表和红黑树之间频繁地来回转换。
——哈希函数具体是怎么实现的?
向HashMap容器中插入Entry对象时,会先使用对象的key调用一个native方法hashCode(),得到一个int类型的hashCode;然后将(32位的)hashCode右移16位,与hashCode本身做异或运算,得到hash值。
——为什么要进行hashCode的高低位异或操作?
为了让hash值更加不确定,降低hash冲突的概率。
——怎么使用hash值计算数组下标?
使用hash值和 [数组长度-1] 做位与运算,得到一个0到 [数组长度-1] 的数,就是插入位置的下标。
HashMap的数组长度一定是2的n次幂,所以 [数组长度-1] 换算成二进制的每一位都是1。
【反过来,这也就是为什么HashMap的数组长度必须是2的n次幂。】
——为什么jdk1.8中链表的插入方式要改为尾插入?
如果采用头插入方式,在并发场景下,扩容时可能会出现循环链表的情况,采用尾插入方式会避免这一情况发生。
——你知道HashMap的扩容机制吗?
新建的HashMap容器的容量为16,加载因子默认为0.75。【——为什么加载因子默认是0.75?】
当 HashMap容器中的元素数量>=容量*加载因子 时,HashMap会进行扩容。每次扩容HashMap的容量会扩大一倍(×2)。
jdk1.7及之前,扩容的核心思想是:使用一个容量更大的数组来代替原来的数组,将原数组内的元素拷贝到新数组当中。拷贝过程会遍历原数组内的元素,将元素依次插入到新数组当中。
jdk1.8及之后,只需要将原数组内 各元素的hash值与原数组长度 做位与运算,若结果为0,元素位置不变,若结果不为0,元素位置的下标变为 原位置下标+原数组长度。
经过数组扩容后,元素要么在原位置,要么在 原位置向右移动原数组长度 的位置。
——为什么加载因子默认是0.75?
当 HashMap容器中的元素数量>=容量*加载因子 时,HashMap进行扩容。
可以看出,加载因子越大,HashMap容器中的空间利用率越高,但相应的,hash冲突的概率越高;加载因子越小,空间利用率越低,hash冲突的概率越低。
加载因子默认是0.75是对空间利用率和hash冲突概率的折衷。
——再问你一些简单的问题。
——HashMap的key可以为null吗?
可以。
——那key为null的情况下,会把null存到什么位置?
存到数组下标为0的位置。
——HashMap的key是有序存放的吗?
不是。HashMap插入元素时是根据元素的hash值计算插入位置的,所以插入位置是确定的,但不是有序的。
——如果我想有序存放要怎么办?
使用LinkedHashMap或者TreeMap。
LinkedHashMap维护了一个双向链表,记录了结点的插入顺序。
TreeMap维护了一棵红黑树,遍历TreeMap可以按照自然顺序输出所有元素。
——如果要用HashMap存一万条数据,怎么做能提高效率?
预定义存储空间,以减少HashMap扩容次数。
预定义存储空间 = 数据量/加载因子 + 1。
减小负载因子,虽然降低了空间利用率,但是也减少了hash碰撞的概率。
——如何在高并发的情况下使用HashMap?
两种方案。
第一种方案是java.util包提供了包装类Collenctions,里面提供了包装方法synchronizedMap(Hashmap hashMap)。
第二种方案是java.util.concurrent包提供了HashMap的替代类ConcurrentHashMap类。
加油!(ง •_•)ง