HashMap
HashMap是什么?
HashMap是基于哈希表的 Map 接口的实现。HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。
- HashMap可以接受null键值和值,而HashTable则不能
- HashMap是非synchronized
- HashMap很快
- HashMap储存的是键值对
jdk1.8相对于1.7底层实现发生了一些改变。 1.8主要优化减少了Hash冲突 ,提高哈希表的存、取效率。 底层数据结构不一样,1.7是数组+链表 ,1.8则是数组+链表+红黑树结构(当数组长度大于64且链表长度大于8时,转为红黑树),红黑树是为了优化 Hash 表链表过长导致时间复杂度增加的问题。
1、链表长度大于8时转换为红黑树,小于6时退化为链表,中间数是为了过度使用,防止链表与红黑树之间频繁的转换,造成效率低下。
2、hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换。
3、hashMap的红黑树不一定小于6的时候才会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD进行转换。
HashMap的工作原理
HashMap是基于hashing的原理,我们使用put(key, value)
存储对象到HashMap中,使用get(key)
从HashMap中获取对象。当我们给put()
方法传递键和值时,先对Key调用hashCode
方法,来计算hash值,返回的hash值用来找bucket对象,来放entry键值对。
下面是HashMap中最主要的两个方法,get(key)
和put(key, value)
get(key)
方法:
1 | public V get(Object key) { |
如果key为null,则直接从哈希表的第一个位置table[0]对应的链表上查找。记住,key为null的键值对永远都放在以table[0]为头结点的链表中,当然不一定是存放在头结点table[0]中。如果key不为null,则先求的key的hash值,根据hash值找到在table中的索引,在该索引对应的单链表中查找是否有键值对的key与目标key相等,有就返回对应的value,没有则返回null。
put(key, value)
方法:
1 | public V put(K key, V value) { |
如果key为null,则将其添加到table[0]对应的链表中,如果key不为null,则同样先求出key的hash值,根据hash值得出在table中的索引,而后遍历对应的单链表,如果单链表中存在与目标key相等的键值对,则将新的value覆盖旧的value,且将旧的value返回,如果找不到与目标key相等的键值对,或者该单链表为空,则将该键值对插入到单链表的头结点位置(每次新插入的节点都是放在头结点的位置)。
HashMap的hash算法
1 | static final int hash(Object key) { |
首先 h = key.hashCode()是key对象的一个hashCode,每个不同的对象其哈希值都不相同,其实底层是对象的内存地址的散列值,所以最开始的h是key对应的一个整数类型的哈希值。h >>> 16的意思是将h无符号右移16位,然后高位补0,然后再与(h = key.hashCode()) 异或运算得到最终的h值,
为什么是异或运算呢?
使用异或运算就能够得到更好散列性,减少哈希冲突。
运算的结果趋向于得到小的值,或运算的结果趋向于得到大的值,异或运算的结果大小值比较均匀分散,这就是我们想要的结果,这也解释了为什么要用异或运算,因为通过异或运算得到的h值会更加分散,进而 h & (length-1)得到的index也会更加分散,哈希冲突也就更少。
HashMap解决哈希冲突(哈希碰撞)
哈希冲突是由于哈希算法被计算的数据是无限的,而计算后的结果范围是有限的,所以总会存在不同的数据计算后得到的值是一样的,那将会存在同一个位置,就会出现哈希冲突。
1.开放地址法:也称线性探测法,就是从发生冲突的那个位置,按照一定次序从Hash表中找到一个空闲的位置, 把发生冲突的元素存入到这个位置。而在java种ThreadLocal就用到了线性探测法,来解决Hash冲突。
2.链式寻址法:通过单向链表的方式来解决哈希冲突,Hashmap就是用了这个方法。(但会存在链表过长增加遍历时间)
3.再哈希法:key通过某个哈希函数计算得到冲突的时候,再次使用哈希函数的方法对key哈希一直运算直到不产生冲突为止 (耗时间,性能会有影响)
4.建立公共溢出区:就是把Hash表分为基本表和溢出表两个部分,凡是存在冲突的元素,一律放到溢出表
HashMap底层是通过链式寻址法来解决hash冲突的。
哈希表是由数组+链表组成的,因为hashcode
相同,所以它们的bucket
位置相同,‘碰撞’会发生。因为HashMap
使用LinkedList
存储对象,这个Entry会存储在LinkedList
中,这个存储的位置一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。
两个键的hashcode相同,如何获取值对象?
HashMap会使用键对象的hashcode
找到bucket位置,找到bucket
位置之后,会调用keys.equals()
方法去找到LinkedList中正确的节点,最终找到要找的值对象。从HashMap中get元素时,首先计算key的hashCode
,找到数组中对应位置的某一元素,然后通过key的equals
方法在对应位置的链表中找到需要的元素。
HashMap初始容量
HashMap的初始容量默认为16。
new HashMap()之后并不是16,真正创建一个16长度的数组是在第一次put的时候,做一次判断,如果长度为0或者为null的话,就做一次扩容,数组也是在这个时候初始化的。
负载因子
HashMap的负载因子默认为0.75。
1 | static final float DEFAULT_LOAD_FACTOR = 0.75f; |
在 HashMap 中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。
默认为0.75的原因(泊松分布):
先看HashMap的底层数据结构
1、负载因子是1.0
数据一开始是保存在数组里,当发生了Hash碰撞的时候,就是在这个数据节点上,生出一个链表,当链表长度达到一定长度的时候,就会把链表转化为红黑树。
当负载因子是1.0时,也就意味着,只有当数组的8个值(这个图表示了8个)全部填充了,才会发生扩容。这就带来了很大的问题,因为Hash冲突时避免不了的。
后果:当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
因此一句话总结就是负载因子过大,虽然空间利用率上去了,但是时间效率降低了。
2、负载因子是0.5
后果:负载因子是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。
但是,此时空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。
总之,就是负载因子太小,虽然时间效率提升了,但是空间利用率降低了。
3、负载因子0.75
时间和空间的权衡,负载因子是0.75的时,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
HashMap扩容机制
HashMap的容量是2的幂次方。
HashMap的初始容量是2的n次幂,扩容也是2倍的形式进行扩容,是因为容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低!
比如new HashMap<>(3)
,给定HashMap一个初始容量3,HashMap在构造器初始化时会自动调用下面的tableSizeFor(int cap)
方法将容量默认转为2的n次幂,也就是4,换言之,若初始容量给定为7,则容量会变为8。
1 | /** |
扩容的时机:
前面提到的公式临界值(threshold) = 负载因子(loadFactor) 容量(capacity)。当HashMap中的数组长度大于临界值时便会发生扩容,例如HashMap的初始容量为16,数组长度就会在大于160.75(默认负载因子数)=12的时候进行扩容,也就是第13次put的时候发生扩容。