HashMap详解:
1、底层实现概述
- hashmap底层是使用数组+链表+红黑树实现的,初始容量为16,默认的负载因子0.75,每次扩容为原来的两倍。
2、 什么是哈希表?什么是哈希冲突?hashMap的原理?
哈希表是基于数组的一种存储方式.它主要由哈希函数和数组构成。
当要存储一个数据的时候,首先用一个函数计算数据的地址,然后再将数据存进指定地址位置的数组里面。这个函数就是哈希函数,而这个数组就是哈希表。
哈希表的优势在于:相比于简单的数组以及链表,它能够根据元素本身在第一时间,也就是时间复杂度为0(1)内找到该元素的位置。这使得它在查询和删除、插入上会比数组和链表要快很多。因为他们的时间复杂度为o(n)。
**哈希冲突是指哈希函数算出来的地址被别的元素占用了,也就是,这个位置有人了。**好的哈希函数会尽量避免哈希冲突。而解决哈希冲突的办法之一就是开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法。
哈希函数用的就是链地址法,也就是数组+链表的方法,hashMap用的就是链地址法.。链地址法就是,当没有发生哈希冲突的时候hashmap主要只有数组。但是当发生冲突的时候,它会在哈希函数找到的当前数组内存地址位置下添加一条链表。
3、下边通过对比jdk1.7和jdk1.8来解析HashMap的数据结构
- jdk1.7 中使用个 Entry 数组来存储数据,用key的 hashcode 取模来决定key会被放到数组里的位置,如果hashcode 相同,或者 hashcode 取模后的结果相同( hash collision ),那么这些 key 会被定位到Entry 数组的同一个格子里,这些 key 会形成一个链表。在 hashcode 特别差的情况下,比方说所有key的 hashcode都相同,这个链表可能会很长,那么 put/get 操作都可能需要遍历这个链表,也就是说时间复杂度在最差情况下会退化到 O(n)
- jdk1.8 中使用一个 Node 数组来存储数据,但这个 Node 可能是链表结构,也可能是红黑树结构,如果插入的 key 的hashcode 相同,那么这些key也会被定位到 Node数组的同个格子里。如果同一个格子里的key不超过8个(默认阀值),使用链表结构存储。如果超过了8个,那么会调用 treeifyBin函数,将链表转换为红黑树。那么即使 hashcode 完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(logn)的开销也就是说put/get的操作的时间复杂度最差只有 O(log n)
4、HashMap中的hashcode的作用
因为需要他来读hashmap的数组位置来定位,如果依次使用equals方法比较key是否相同来确定当前数据是否已存储,效率会很低,通过比较hashCode值,效率会大大提高。如图:
5、HashMapd的数组长度为什么是2的n次幂
当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算的时候,会出现重复的数据,因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 ,这样,不管你的hashCode 值对应的该位,是0 还是1 ,最终得到的该位上的数肯定是0 ,这带来的问题就是HashMap 上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。如下图所示:
这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。(当然jdk 1.8已经在链表数据超过8个以后转换成了红黑树的操作,但那样也很容易造成它们之间的转换时机的提前到来)。
5、HashMap何时扩容以及它的扩容机制?
何时进行扩容?
HashMap使用的是懒加载,构造完HashMap对象后,进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table。当首次调用put方法时,HashMap会发现table为空然后调用resize方法进行初始化, 当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
具体是什么时候呢:
当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16 * 0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
7、HashMap的key一般用字符串,能用其他对象吗?
HashMap的key可以用其他对象,但是相较String对象而言所需要的条件较为苛刻。即使自定义对象内部重写了hashCode()方法和equals()方法,也没有String对象高效,因为String是不可变对象,其中有个hash变量,它可以缓存hashCode,避免重复计算hashCode,这样查找更快。
8、HashMap的key和value都能为null么?如果key能为null,那么它是怎么样查找值的?
HashMap的key和value都允许为null。
9、hash函数是如何实现的?为什么要先用hashcode的16位进行异或操作?为什么与的是length-1?
1)首先将元素hashcode值的高16位与低16位进行异或操作,作为hash值,然后与数组长度-1进行与操作,得到数组位置。
2)当数组长度比较小的时候,长度的高位全部为0,那么此时与hashcode进行与操作对结果改变影响不大,这样会增大hash碰撞的几率。所以要先用hashcode的前后16为进行异或操作。
3)因为hash的操作是index = hash & (length -1),此时为15也就是1111,这样得到的结果其实就是hash的后几位,只要hash分布均匀,那么index也就是分步均匀的。
10、HashMap是线程安全的吗?如何实现线程安全?
HashMap不是线程安全的,如果多个线程同时检测到元素的个数超过阈值(数组大小*负载因子),多个进程会同时对Node数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会复制给table,其它线程的都会丢失,并且各自线程put的数据也丢失。
11、实现线程安全的方式:
-
使用HashTable:HashTable使用synchronized来保证线程安全,但是所有线程竞争同一把锁,效率低 ConcurrentHashMap:使用锁分段技术,将数据分段存储,给每一段数据配一把锁,效率高。Java8中使用CAS算法(了解CAS算法请参考 Java并发编程之原子变量和CAS算法)
-
SynchronizedMap:调用synchronizedMap()方法后会返回一个SynchronizedMa类的对象,而在SynchronizedMap类中使用了synchronized同步关键字来保证对Map的操作是安全的
12、HashTable
底层结构:数组+链表,无论是key还是value都不能为null,线程安全。**实现线程安全的方式是在修改数据时锁住整个hashtable,**效率低,ConcurrentHashMap做了相关优化。
13、ConcurrentHashMap
- 底层结构 数组+链表,线程安全,效率高。
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。
ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。
14、为什么要转为红黑树?为什么不直接开始就使用红黑树?
1)因为当长度过长,遍历链表的时间也会原来越长,用红黑树可以减少遍历时间
2)如果一开始就使用红黑树,那么就要进行左旋,右旋,变色等操作,在元素个数较小的时候会消耗时间,并且遍历时间消耗与链表没什么区别
15、可不可以使用二叉树,不用红黑树?为什么阈值是8?
1)可以使用二叉树,但是使用二叉树可能会出现只有左子树或者右子树的情况,这样和链表没什么区别
2)阈值是8是因为泊松分布,单个hash槽中元素为8的概率小于百万分之一,所以选择7为分水岭,为7不做操作