HashMap
HashMap基础问题
HashMap内部节点是有序的吗?
- 是无序的,根据hash值随机插入
重写equals方法需同时重写hashCode方法?
-
当用其中的一个作为键保存到HashMap、HashTable或 HashSet中,再以“相等的”另一个作为键查找他们的时候,则根本找不到。
-
为了保证当两个对象通过equals()方法比较相等时,那么他们的hashCode值也一定要保证相等。
HashMap 什么时候开辟bucket数组占用内存?
-
在HashMap第一次put的时候,无论Java 8还是Java 7都是这样实现的。
-
两个版本的实现中,桶数组的大小都是2的正整数幂。
HashMap如何处理key为null的键值对?
-
HashMap允许put key为null的键值对
-
这样的键值对都放到了桶数组的第 0个桶中。
为什么加载因子是0.75?
扩容因子表示哈希表中元素的填充程度
-
值越大,元素越多,虽然空间利用率较高,但是哈希冲突概率也会增加;
-
值越小,元素越少,虽然哈希冲突概率降低,但是内存空间浪费较多,增加扩容的频率。
这个值是根据空间利用率和冲突概率之间的一个平衡,通过泊松分布算法得到的一个折中的值。
HashMap默认值都是什么,什么时候扩容?
- 若没有自定义容量和因子,则默认---->HashMap的容量默认16 ,扩容因子0.75
threshold 计算公式:capacity * loadFactor
即使指定的大小不是2的整数次幂,那么 Hash 会选择大于该数字的第一个 2 的幂作为容量(3->4、7->8、9->16)。
-
扩容:当容器中的HashMap总数>=threshold 时,创建一个新的Entry空数组,长度是原数组的2倍。 遍历原Entry数组,把所有的Entry重新Hash到新数组。长度扩大以后,Hash的位置也随之改变。
-
Hash的位置也随之改变:
由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1
扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001
1111。因为是& 运算,1和任何数 &
都是它本身,那就分二种情况,原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
关于设置 HashMap 的初始化容量大小
- (需要存储的元素个数/0.75(默认))+1
Jdk 并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个 2 的幂。
HashMap进阶问题
为何HashMap的数组长度一定是2的次幂?
-
可以通过(length - 1) & key.hash()这样的位运算快速寻址
-
当桶数组长度为2的正整数幂时,扩容迁移的时候不需要再重新通过哈希定位新的位置了。
同一个桶中的元素均匀的散列到新的桶中,具体一点就是同一个桶中的元素在扩容后一半留在原先的桶中,一半放到了新的桶中。
:: 如果在即将扩容的那个位上key.hash()的二进制值为0,则扩容后在桶中的地址不变,否则,扩容后的最高位变为了1,新的地址也可以快速计算出来newIndex = oldCap + oldIndex;
java8之后为啥改为尾部插入呢?
- 1.7是头插法,在并发场景下导致链表成环的问题。而在1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
JDK8相比于JDK7有优化吗?为什么要做这几点优化?
- hash()做了优化,1.7做了4次移位4次异或,1.8改成了1次(多了用处也不大)
- 数组+链表改成了数组+链表+红黑树;
- 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的 后继节点,1.8遍历链表,将元素放置到链表的最后;
- 扩容的时候1.7需要对原数组中的元素进行重新 hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
-
防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
-
因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;
A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环
HashMap的工作原理(存,取)
A.存储对象
将K/V键值传给put()方法:
①、计算K的哈希值—hashcode(),调用hash()扰乱,indexFor()进一步处理来获取实际的存储位置,得数组下标;
②、如果发现当前的桶数组为null,则调用resize()方法进行初始化
- 如果K的hash值在HashMap中不存在,则执行插入,若存在,则发生哈希碰撞,采用了链地址法处理哈希碰撞;
- 如果K的hash值在HashMap中存在,链表中数据一一比对,若有true,则更新键值对;
- 如果K的hash值在HashMap中存在,链表中数据一一比对,都是false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式),注意如果链表超度超过TREEIFY_THRESHOLD默认是8,则将链表转换为树结构。
③、put完后调整数组大小,如果HashMap的总数超过threshold就要resize。
B.获取对象
将K传给get()方法:
- ①、计算K的hash值,从而获取该键值所在数组下标;
- ②、遍历该下标下的链表or红黑树,equals()方法查找。
hashCode是定位的,定位存储位置;
equals是定性的,比较两者是否相等。
如何解决Hash冲突
解决hash冲突的方法有四个:链地址法、开放地址法、再哈希法和建立公共溢出区法
1.链地址法
(HashMap即是采用了链地址法)
就是数组链表红黑树里的链表
2.开放地址法
发生冲突,继续寻找下一块未被占用的存储地址
3.再哈希法
同时构造多个不同的哈希函数,当哈希地址发生冲突时,再用下一个哈希函数计算……,直到冲突不再产生。
4.建立公共溢出区法
将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
有关hash()的问题
HashMap 中 hash ( )扰乱是怎么实现的?还有哪些hash函数的实现方式?
-
得到的hashCode转化为32位二进制,低16bit和高16bit做了一个异或
-
由于计算出来的哈希值都要与length - 1做&运算得到数组下标,那就意味着计算出来的hash值只有低位有效,这样会加大碰撞几率,因此让高16位与低16位做异或,让低位保留部分高位信息,且异或保证了对象的hashCode的32位值只要有一位发生改变,整个hash()返回值就会改变,减少哈希碰撞。
-
还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。
hash函数能不能直接用key的hashcode?
因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。
int值范围为-2147483648~2147483647, 前后加起来大概40亿的映射空间。
只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。
- 但问题是一个40亿长度的数组,内存是放不下的。
什么才是一个好的hash函数呢?
-
计算出来的哈希值足够散列,能够有效减少哈希碰撞
-
本身能够快速计算得出,因为HashMap每次调用get和put的时候都会调用hash方法
红黑树有关问题
桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
-
当同一个桶中的元素数量大于等于8的时候元素中的链表转换为红黑树,变为红黑树的目的是为了高效的查询
-
反之,当桶中的元素数量小于等于6的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗
Java 8中为什么要引进红黑树,是为了解决什么场景的问题?
-
引入红黑树是为了避免hash性能急剧下降,引起HashMap的读写性能急剧下降的场景
-
正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的 hashCode方法,可以保证HashMap的读写复杂度不会低于O(logn)
HashMap与其它集合
HashMap与HashTable的区别
-
HashTable:线程安全集合类,所有方法都加了Synchronized锁,性能要差于HashMap,底层是数组+链表,链表主要用来解决哈希冲突,初始容量默认11,不支持使用null作为key
-
HashMap:线程不安全的集合类,底层是数组+链表+红黑树(Jdk1.8新引入的),初始容量默认16,支持使用null作为key,会把它作为 0来存储
有什么线程安全的类代替么?
Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
-
HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,并发度很低,最多同时允许一个线程访问。
-
Collections.synchronizedMap是使用 Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
-
ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。
一般都会使用HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景使用的是ConcurrentHashMap。
你知道ConcurrentHashMap的分段锁的实现原理吗?
-
ConcurrentHashMap的分段锁实现原理是通过将哈希表分成若干个Segment,并为每个Segment单独加锁来实现并发访问控制的。
-
Segment锁是可重入的,即同一个线程可以多次获取同一个Segment的锁而不会造成死锁。
-
此外,ConcurrentHashMap还使用了CAS(Compare and Swap)算法来实现对Segment的修改操作,这种算法可以在不阻塞其他线程的情况下完成操作,从而提高了并发性能。
-
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。 线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
有没有有序的Map?
LinkedHashMap 和 TreeMap
LinkedHashMap怎么实现有序的?
- LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。