简介
HashMap是Java程序员使用频率最高的用于映射(键、值对)处理的数据类型,它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null,且HashMap不是线程安全的类,可以使用ConcurrentHashMap和Collections的SynchronizedMap方法使HashMap具有线程安全的能力。在JDK1.8中对HashMap底层的实现进行了优化,引入红黑树、扩容优化等。那就重新认识一下JDK1.8的HashMap,来看看对其做了什么优化。
存储结构
要搞清楚HashMap,首先需要知道HashMap是什么,即它的存储结构;其次弄明白它能干什么,即它的功能如何实现。我们都知道HashMap使用哈希表来存储的数据的,根据key的哈希值进行存储的,但是不同的key之间可能存在相同的哈希值,这样就会产生冲突;哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中的HashMap采用了链地址法来解决哈希冲突,简单来说就是数组加链表的结合。在每个数组元素上都一个链表结构,当存放数据的时候如果产生了哈希冲突,先得到数组下标,把数据放在对应下标元素的链表上。这里我们思考一个问题,即使哈希算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树;当链表长度太长(默认超过7)时,链表就转换为红黑树,如下图所示。
重要字段
HashMap是根据key的哈希值进行存取的,那HashMap的性能和哈希算法的好坏有着直接的关系,哈希算法计算结果越分散均匀,哈希碰撞的概率就越小,map的存取效率就会越高。当然,也和哈希数组的大小有关系,如果哈希数组很大,即使较差的哈希算法也会比较分散,如果哈希数组较小,即使较好的哈希算法也会出现较多的碰撞,所以就需要权衡空间和时间成本,找到比较平衡的值。
JDK1.8版本也是权衡了时间、空间成本以及效率,对之前的版本做出了很多优化;不仅对数据结构进行了优化,除此之外还对扩容进行的优化,大大的提高的HashMap的性能。下面我们通过源码来一起看一下具体的实现。
我们来看一下HashMap中比较重要的几个属性。
1 | java复制代码//默认的初始容量,必须是2的幂次方. |
HashMap中的属性还是比较好理解的。其实到这里会有一个疑问,为什么默认的哈希桶数组table的长度为16,而且长度必须为2的n次方呢?
这里我们先说一下为什么哈希数组的长度是2的n次方。
其实不管是在JDK1.7还是JDK1.8中,计算key索引位置都是通过hash & (length-1)计算得来的。
我们应该知道 hash % length 等价于 hash & (length - 1),在length是2的n次方时
1 | java复制代码假如有一个key的哈希值二进制如下:这里我们就只看低位。 |
为什么不用 hash % length 计算索引位,要使用 hash & (length -1)来计算呢?计算机底层是二进制数进行计算和存储,&是接近计算机底层运算,相比于% 运算效率上应该会快。
那为什么length必须是2的n次方呢?
1 | java复制代码hahsCode 0010 0011 0010 1111 |
1 | java复制代码hahsCode 0010 1110 1110 1100 |
其实我们可以发现,当哈希数组的长度为2的n次方时,length - 1的二进制码全都是1,这样的话索引的位置,完全依赖于hash值的低位值,而且产生冲突的几率要比容量不是2的n次方的概率要低,索引位就完全依赖于哈希值的低位,所以只要哈希值分布均匀,那产生冲突的概率就会低很多,故而length是2的n次方更优。
其次,当length为2的n次方时,也方便做扩容,JDK1.8在扩容算法上也进行了优化,使用的方法也非常巧妙。会在扩容方法的时候讲到。
功能实现
确定索引位置
不管是增加、删除、查找,都需要定位到哈希桶的数组位置,前面也说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用再遍历链表查询,大大优化了查询效率。
tableSizeFor()这个方法,就是保证在HashMap()进行初始化的时候,哈希桶数组的大小永远是2^n。
1 | java复制代码static final int tableSizeFor(int cap) { |
1 | java复制代码//JDK1.8的Hash算法 |
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,相比于JDK1.7来说,JDK1.8降低了哈希函数扰动的次数,也算是优化了hash算法。这么做可以在HashMap容量较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
1 | java复制代码假如有一个key的哈希值二进制如下 |
put方法
从网上找到了一个流程图,感觉很不错,就直接拿过来用了,嘿嘿….画的也是比较清楚的。看着流程图,再结合源码一看就明白。
1 | java复制代码final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
get方法
get方法相对于put方法可能简单一点,通过源码一看就能明白。废话不多说,直接上代码看一下吧。
1 | java复制代码 public V get(Object key) { |
扩容机制(resize)
我个人认为HashMap的扩容算法是整个HashMap中的精髓部分,使用的方法也十分巧妙,不得不佩服,大佬还是大佬。
1 | java复制代码final Node<K,V>[] resize() { |
在源码中有这么一段(e.hash & oldCap) == 0,怎么理解这个呢,我们通过下面的来看一下。
1 | java复制代码假设扩容之前 数组大小为16 |
我们可以发现,代码中利用的是 hash & oldCap(key的哈希值 & 扩容之前的哈希桶长度),而不是利用hash & newCap- 1 ,为什么要这样做呢?
通过上面的情况我们应该可以看出,原本冲突的两个key,在扩容以后的位置是由新增的那一个bit位来决定的。所以直接用 hash & 扩容之前的容量来判断新增的那个bit位是0 还是 1 ,就可以知道是在原来的位置上,还是在 原来位置+oldCap 位置上。
1 | java复制代码哈希冲突的两个key,在扩容到32之后 |
通过上面我们也能看到,原来在同一个位置上的两个key,通过扩容之后的位置要不在原来的位置上,要不在oldCap+原位置上。这样不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。同时也是更加充分的说明了,为什么HashMap的容量必须是2的n次方了。
知道HashTable的同学应该也知道,HashTable默认的容量是11,每次扩容之后的容量是length*2+1。HashTable在设计上,想通过保证哈希桶数组为质数,来尽可能保证哈希散列均匀。这里也就不对HashTable的容量为什么是质数进行展开,感兴趣的同学可以查一下资料,为什么对质数求模会比对合数求模的哈希散列效果要好,可以参考blog.csdn.net/liuqiyao_01…
HashMap保证哈希数组的大小为2的n次方,这个设计确实非常的巧妙,利用2的n次方 - 1 二进制码全为1这个特性,在扩容的时候,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
总结
- 扩容是一个特别耗性能的操作,所以当我们在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
- HashMap是线程不安全的,不要在并发的环境中使用HashMap,使用ConcurrentHashMap或者SynchronizedMap
- 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改。
本文转载自: 掘金