解析HashMap(一)
一、什么是HashMap
oracle文档中的解释:
Hash table based implementation of the
Mapinterface. This implementation provides all of the optional map operations, and permitsnullvalues and thenullkey. (TheHashMapclass is roughly equivalent toHashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.
我们可以得到几个重要的性质:
1.基于哈希表的Map接口的实现。
2.它的key和value允许出现null。
3.非同步。
4.不保证映射顺序(插入顺序),也不保证该顺序不随时间的改变。
二、哈希表(散列表)
1.什么是哈希表?
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,也就是我们常说的哈希函数,使得每个关键字key都有一个对应的存储位置f(key)。而使用散列技术将记录存储在一块连续的存储在一块连续的存储空间中,这快连续的空间就是散列表/哈希表。
2.哈希函数的构造方法:
有两个原则:
a)方便计算;
b)散列地址分布均匀。
常见的方法:2.1 直接定地址法、2.2 数字分析法、2.3 平方取中法、2.4 折叠法、2.5 除留余数法、2.6 随机数法。
3.哈希冲突
哈希冲突是指,当key1 ≠ key2时,出现了f(key1) ≠ f(key2)。
常见的解决办法有:开放定地址法、再散列函数法、链地址法(这也是HasnMap用来解决哈希冲突的办法)、公共溢出区法。
三、基于JDK 1.7源码解析
1.数据结构
在JDK 7中HashMap是由数组+链表实现数据存储的,综合两者的特性(数组寻址容易,插入删除困难;链表插入删除容易,寻址困难)实现了寻址容易,插入删除也容易。
我们可以把数组看成一排桶连接起来,所有hash到同一个桶中的key,他们对应的结点是通过链表连接起来。
2.重要属性:
1 | //内部数组的默认初始容量,作为hashmap的初始容量,是2的4次方(16) |
其中我们要着重讨论一下size、capacity、loadFactor、threshold这几个变量。
capacity就是当前可以容纳的最大数量,size表示当前实际“KV对”数量。需要注意的是,capacity的大小为2的幂,当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值(第一个比他大的2的幂)当做初始容量。
HashMap有扩容机制,就是当达到扩容条件时会进行扩容,从16扩容到32、64、128…
那么,这个扩容条件指的是什么呢?
HashMap的扩容条件就是当HashMap中的元素个数size超过临界值threshold时就会自动扩容。
公式为:threshold = loadFactor * capacity。
内部类Entry:
1 | //hashmap中每一个键值对都是存在Entry对象中,entry还存储了自己的hash值等信息 |
3.构造方法
我们在这里只介绍传入容量和负载因子的构造方法。
1 | public HashMap(int initialCapacity, float loadFactor) { |
我们需要注意,HashMap在new后并不会立即分配数组,而是第一次put时初始化,类似ArrayList在第一次add时分配空间。
4.put()方法
1 | /** |
inflateTable(threshold)方法:
该方法是新建一个空的内部数组。
1 | /** |
roundUpToPowerOf2(int number)方法:
找到number的最小的2的幂。
1 | private static int roundUpToPowerOf2(int number) { |
Integer.highestOneBit(int i)方法:
用来获取最左边的bit(其余位为0)所代表的值。
1 | /* |
hash(Object key)方法
在HashMap中元素找到存储位置需要经过:Key->hashCode->hash->index。hash值是通过key自己的hashcode来得到的。
我们知道一般hashcode的位数比数组的长度要大,进行&操作时,往往只能低位进行运算,这样就会增加哈希冲突,所以在hash()中通过移位和异或操作加入高位计算。
1 | final int hash(Object k) { |
indexFor(int h, int length)方法:
返回hash值的索引,采用除模取余法,h & (length-1)操作等价于hash % length操作, 但&操作性能更优,不需要转成十进制。这时,就与容量要是2的幂有关系,之后会详细解答。
1 | static int indexFor(int h, int length) { |
用位运算来代替取模运算,除了性能问题以外,还解决了负数问题。由于
hashcode是int类型,有正有负,负数取模比较麻烦。使用二进制的位运算,不管hashcode是正还是负,length-1为正,则其二进制第一位为0,做按位&运算,结果一定为正。
addEntry(int hash, K key, V value, int bucketIndex)
查看是否需要扩容,然后插入新节点,注意新节点插入在链表头。
1 | /** |
整个put的过程以及当中用到的几个重要的方法讲解结束,接下来我们顺着讲解扩容过程。
5.resize()
我们上面学习了数组扩容的条件,数组扩容的大小,由于默认initHashSeedAsNeeded内开关都是关闭状态,所以一般情况下transfer不需要进行rehash,能减少一部分开销。
我们现在详细看一下其源码的实现:
1 | /** |
transfer(Entry[] newTable, boolean rehash)
将现有数组中的内容重新通过hash计算存入新的数组。
采用了单链表头插入方式,同一个位置上的新元素总会被放在链表的头部位置。
1 | /** |
四、总结:
简单介绍了关于HashMap、哈希表相关知识,以及基于JDK1.7分析其源码,减少了其中几个主要的方法。
(JDK1.7)
HashMap在new后不会立即分配数组空间,而是在第一次put时,通过inflateTable方法,同时第一次初始化容量也是在这时。HashMap的数组大小一定是2的幂,初始默认大小为16,如果自己new的时候传入的容量大小不是2的幂,会通过roundUpToPowerOf2方法算出大于指定容量的最下2的幂。扩容条件是
size >= capacity * loadFactor,扩容大小为原容量的2倍。HashMap处理哈希碰撞使用链地址法,使用头插法。之后会带来《解析HashMap(二)——基于JDK1.8》。