1. HashMap的底层数据结构
- HashMap底层是数组+链表+红黑树的结构,其中红黑树是JDK1.8加入的,数组中的每个位置存放的是节点,1.7中叫Entry,1.8中叫Node。每一个节点都会保存自身的hash值、key、value、以及next指针。
- 在put操作时,将元素的key的hashCode 值的高16位和低16位异或得到key的 hash 值,然后将key的 hash 值和数组长度 length -1 的值进行与运算来得到元素所在桶的位置。如果出现冲突,即多个元素对应到一个桶,就采用拉链法,将他们组织成链表放在桶中,1.7采用的是头插法,1.8采用的是尾插法。当链表元素个数>=8,链表就会转成红黑树。<=6时红黑树又会还原成链表。当数组中元素的个数超过数组容量✖️负载因子时,就会进行2倍扩容。
- HashMap默认加载因子是0.75f,默认初始容量是16
2. HashMap的put的过程
- 首先通过元素的key的 hashCode 值的高16位和低16位的异或得到 hash 值,hash 值和 length -1 进行与操作,得到桶的位置。
- 如果桶中没有节点,就将元素插入桶中;如果有节点,就需要和桶中链表的节点进行比较,看是否相同(判断相等的方法是:首先判断两个key的hash值是否相等,由于 hash 值是通过 hashCode 高低16位异或得来的,所以此处需要重写 hashCode 方法来保证相同的对象返回相同的 hash 值,不同的对象返回不同的 hash 值,如果相等再通过equals方法判断key是否相等,所以还需要重写equals方法。两个操作都相等,则判定相同)。如果相同,就对这个节点进行覆盖,如果不同,就以尾插法的方式插入到链表尾部。
- 如果链表长度>=8,就把链表转成红黑树,如果链表长度≤6,就把红黑树还原成链表。(中间的差值7可以防止链表和树之间频繁的转换)
3. HashMap的get的过程
首先将元素 hashCode 值的高16位和低16位的异或得到 hash 值,hash 值和 length -1 进行与操作,得到桶的位置。如果桶为空,就直接返回 null。否则就判断桶的第一个节点是否为要查询的 key,是的话就返回 value 。如果不是,就判断桶中节点是红黑树还是链表,并按照各自的方法来查找。(getNode()和getTreeNode())
4. HashMap、HashTable、ConcurrentHashMap 的区别?
- 结构:三者底层结构都是数组+链表,其中HashMap和ConcurrentHashMap在1.8又引入了红黑树的结构,并且采用尾插法。
- 并发安全性:HashMap不安全,他的方法都没有加锁,在多线程环境下无法保证上一秒 put 的值,在下一秒 get 的时候还是原值。HashTable和ConcurrentHashMap是线程安全的,HashTable实现线程安全的方式是使用synchronized对整个HashTable加锁,效率比较低;ConcurrentHashMap 1.7是把整个Map分为若干个Segment,通过ReentrantLock对每个Segment单独加锁,1.8是通过 Synchronized 和 CAS对每个Node进行加锁,效率比较高。
- 存值:HashMap的key和value可以为null,HashTable和ConcurrentHashMap的key和value都不能为null。解答
- 初始容量:HashMap和ConcurrentHashMap的默认初始容量是16,采取两倍扩容方式;HashTable的默认初始容量是11,2倍+1的扩容方式。
5. 为什么HashTable和ConcurrentHashMap的key和value都不能为null
value不能为null:
在多线程的环境中,当通过get(k)方法获取到的是null时,无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。系统无法判断出这种模糊不清的情况 假如线程1调用m.contains(key)返回true,然后在调用m.get(key),这时的m可能已经不同了。因为线程2可能在线程1调用m.contains(key)时,删除了key节点,这样就会导致线程1得到的结果不明确,产生多线程安全问题,因此,Hashmap和ConcurrentHashMap的key和value不能为null。
HashMap允许key和value为null,在单线程时,调用contains()和get()不会出现问题。
HashTable和ConcurrentHashMap需要保证多线程的安全性,HashMap不需要保证,他是为单线程情况服务的。
6. HashMap1.7和1.8的区别?
- 1.7 采用Entry数组和链表结构,如果hash函数特别差会导致链表很长,查找时间复杂度就是O(n);1.8对这个进行了改进,采用Node数组+链表+红黑树结构,当链表元素≥8时,就转换成红黑树结构。最差时间复杂度也是O(logn)
- 1.7采用头插法插入链表中,1.8采用尾插法插入链表中
- 1.8 将hash 值的运算改为通过 hashCode 高低16位异或来得到,让高低位数据都参与到 hash 值的计算中,这样可以减少碰撞的几率
7. 为什么阀值是8?
因为泊松分布,在负载因子默认为0.75时,单个桶内元素个数为8的概率小于百万分之一。所以将7作为一个分水岭,等于7时不转换,大于等于8时才进行转换,小于等于6时就退化为链表。
8. 为什么链表转红黑树?
- 红黑树是二叉排序树,查找效率比链表高,所以当链表达到一定长度时,要转化成红黑树。
9. 为什么不直接用红黑树? 而选择先用链表,再转红黑树?
因为红黑树需要进行左旋、右旋、变色这些操作来保持平衡,新增节点的效率比较慢。当桶中元素个数小于8时,链表结构已经能保证查询性能了。当元素个数大于8时,此时就需要红黑树来加快查询速度。因此,如果一开始就用红黑树结构,元素太少,增删效率又比较慢,性能比较低。
10. 不用红黑树,用二叉查找树可以么?
可以,但是二叉查找树在特殊情况下会变成一条线性结构,比如只有左子树,这就跟原来使用的链表结构一样了,遍历查找会很慢。
11. 为什么1.7用头插法,1.8改成了尾插法?
1.7中采用头插法,是因为作者认为后插入的节点被访问的可能性大一些,所以用了头插法可以提高检索效率。但是后来发现头插法可能会在多线程环境中形成死循环,所以在1.8就使用的尾插法。
12. 为什么头插法会形成死循环?尾插法就不会?(扩容为什么会发生死锁)
- HashMap是线程不安全的,多个线程可以同时对hashMap进行扩容。
- 比如说一个桶中放着A->B这样一个链表,扩容时如果采用头插法,链表就会倒转变成B->A,在多线程环境中,如果多个线程一起扩容,就可能会出现环形链表,从而造成死循环。具体原理
- 但是如果使用尾插法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
- 依然存在的问题:
- 虽然1.8 尾插法避免了扩容时可能出现死循环的问题,但HashMap在多线程的环境下还是不安全的,因为 HashMap 的源码中,put 和 get 方法都没有加同步锁,在多线程的环境下,就可能出现数据丢失,所以线程安全依然无法保证。
13. 为什么HashMap要用数组和链表的结构?
数组通过随机访问来快速确定桶的位置,利用元素的key的hash值对数组长度取模得到; 链表插入删除快,而且可以解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。
14. 为什么HashMap不用ArrayList?
hashMap的数组容量是2的n次方,做取模运算的效率高,而ArrayList的扩容机制是1.5倍的扩容,容量不一定能满足2的n次方的要求。
15. hashCode 怎么对应桶的位置?
- 将key的hashCode 的高16位和低16位进行异或得到 hash 值
- 再将得到的hash值和数组长度-1进行与操作,就得到数组下标的位置了
index = hash & (Length - 1)
16. 为什么要高16位和低16位异或操作?
因为数组的长度 length 比较小时它的二进制高位都是0,与 hashCode 进行与运算的话,无论 hashCode 的高位怎么变,对运算的结果都不会产生影响,从而导致碰撞的几率会变大。于是将 hashCode 的高16位和低16位异或,让高低位数据也可以参与到Hash的计算中,可以减少hash碰撞。(同时位运算也不会有太大的开销)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
17. HashMap什么时候扩容,扩容过程?
- 当HashMap的数组Size 超过 容量✖️负载因子时进行扩容(默认负载因子是0.75)
- 扩容过程:首先创建一个新的Entry空数组,长度是原数组的2倍。然后遍历原数组,把所有的Entry重新hash到新数组。之所以要重新hash而不是直接复制,是因为数组容量改变,桶的位置也会改变。index = hash &(新Length - 1)
18. HashMap的主要参数都有哪些?
- 初始化容量为16,这个16是以位运算的形式写的,因为位运算效率高。
- 最大容量 2的30 ,也是以位运算的形式写的,之所以定义这么大是为了防止频繁的扩容。
- 负载因子 0.75f。
- 阈值8,大于等于8时链表会转化为红黑树
- 阈值6,小于等于6时会退化回链表
19. 负载因子为什么是0.75?
表示数组的稀疏程度,越大,数组存放的 entry 就越多,也就越稠密,太大会导致查找效率变低;越小,数组存放的 entry 就越少,也就越稀疏,太小会导致数组利用率低。
节点出现的频率在hash桶中遵循泊松分布,当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个桶的链表长度超过8个是几乎不可能的。
20. HashMap默认长度为什么设置初始化为16?
HashMap的容量是2的n次方,作者认为16是一个折中、使用较多的值,所以设置成16。
21. HashMap为什么容量是2的n次方?
- 因为HashMap计算hash值后,要用hash%length来确定桶的位置,由于取模的消耗较大,所以采用hash&(length-1)这个方法,而hash&(length-1)==hash%length成立的前提是当length是2的幂次方。
- 因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。 所以保证容积是2的n次方,是为了保证hash值和(length-1)之间进行按位与操作时,每一位与的都是1 ,也就是和1111…111进行与运算。这样就可以保证均匀分布而且不同位置不碰撞了。
22. 你一般用什么作为HashMap的key?
- key可以为null,hash值为0,放在数组的第一个位置。
- 一般用Integer、String这种不可变类当HashMap的key,String最为常用。原因有两点:
(1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算,这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。所以HashMap中的键往往都使用的字符串。
(2)因为获取对象的时候要用到equals()和hashCode()方法,那么重写这两个方法是非常重要的,而Integer和String这些类已经很规范的重写了hashCode()以及equals()方法。
23. 用可变类当HashMap的key有什么问题
hashcode可能发生改变,导致put进去的值,无法get出来。
24. HashMap如何转成线程安全的?
- 替换成Hashtable,Hashtable通过对整个表上锁实现线程安全,效率比较低。
- 使用Collections类的synchronizedMap方法将hashMap包装一下。
- 使用ConcurrentHashMap,它使用分段锁来保证线程安全