HashMap是一种Java开发过程当中使用频率很是高的容器,本文将对HashMap底层存储结构和源代码进行解读和分析,源代码依据的JDK的版本是JDK7,小版本是80,JDK7中各个小版本的HashMap源代码多是不一样的,这一点要注意。java
一般咱们说的哈希函数(英语:Hash function)又称散列算法、散列函数,是一种从任何一种数据中建立小的数字“指纹”的方法。散列函数把消息或数据压缩成【摘要】,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,从新建立一个叫作散列值的指纹。散列值一般用一个短的随机字母和数字组成的字符串来表明。算法
哈希表是一种能实现关联数组的抽象数据结构,能把不少【value】映射到不少【key】上。哈希函数的一个使用场景就是哈希表,哈希表被普遍用于快速搜索数据,它的时间复杂度是O(1)。编程
哈希函数的构造方法包括:除留余数法、随机数法、平方取中法、折叠法、直接定址法和数字分析法。这里就再也不对哈希函数进行展开解读了,有空会专门写一篇介绍哈希函数的总结。数组
有一种现象叫作哈希冲突,指的是,当不一样的数据用同一哈希函数计算出的值相同的场景。好的哈希函数在输入域中不多出现哈希冲突。在哈希表和数据处理中,不抑制冲突来区别数据,会使得数据记录更难找到。解决哈希冲突的方法一般有下面几种:安全
方法
|
方法描述
|
备注
|
|
开
放
定
址
法
|
线性探查法
|
当产生哈希冲突时,则去寻找下一个空位。从当前位置开始搜索,当搜索到最后一个位置时,再从哈希表表首开始依次搜索,直到搜索到空位为止。只要哈希表足够大,而且有空位确定能搜索到位置。
|
fi(key) = (f(key) + di) mod m,m表明哈希表的长度,di = m-1,
di的取值范围能够保证搜索完整个哈希表
|
平方探查法
|
当产生哈希冲突时,则去寻找下一个空位位置。从当前位置增长平方项,再对哈希表的长度取模。增长平方项的目的是不让关键字集中在同一个区域,避免不一样的关键字争夺同一位置。该方法并不能搜索全部的位置,一般能搜索哈希表一半的位置,若是在一半的位置都没有找到合适的空位,则表明此哈希表须要重建。
|
fi(key) = (f(key) + di) mod m,m表明哈希表的长度,di=1^2,-1^2,2^2,
-2^2....p^2,-p^2, p<=m/2
|
|
双散列函数探查法
|
当产生哈希冲突时,则去寻找下一个空位。在当前的位置基础上,增长一个由随机函数产生的数值。
|
fi(key) =(f(key) + di) mod m,m表明哈希表的长度,di由一个随机函数产生。
|
|
链地址法
|
基础是哈希表,哈希表的每个元素均可能加挂一个链表,也便是同义词存储在同一个列表中。
|
链地址法是HashMap解决哈希冲突使用的方法之一。JDK7彻底使用此方法,
在JDK8中使用混合的方式解决哈希冲突,当同一个链表的元素大于8的时候,
自动转化为红黑树,也防止HashMap查询元素时出现O(n)的可能。
|
|
再哈希法
|
同时准备多个哈希函数,当一个哈希函数得出的值出现冲突时,使用其余的哈希函数,直到获取到空位为止。
|
优势:不容易产生汇集,缺点时:增长了计算时间。
|
|
创建公共溢出区
|
取两个哈希表,例如表a和表b,当出现表a的下标冲突时,把该元素都移动到表b中。
|
package java.lang; public class Object { 。。。 public native int hashCode(); 。。。 }
hashCode方法是Java中全部类共有的方法,是一个原生态方法,参考源代码中的注释数据结构
This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the Java programming language。并发
翻译过来是:这一般经过将对象的内部地址转换为整数来实现,但Java编程语言不须要此实现技术。app
也就是说java中的类不复写Object中的hashCode方法的话,是调用本地系统的方法生成的一个整数值,hash值和内存地址有关系,可是它们并不相等。编程语言
常见的存储结构有顺序存储(数组)、链式存储(链表)、索引存储以及散列存储(哈希表),咱们介绍一下几类常见存储结构对于新增、删除和查找的性能状况。函数
一、数组
数组Java中最高效的数据物理存储结构了,它采用一段连续的存储单元存储数据。对于指定的下标,查找元素的时间复杂度是O(1);对于指定的值,查找元素须要遍历整个数组,逐一比较数组元素和给定值,因此时间复杂度是O(n),对于有序数组,能够采起二分查等方式,可将时间复杂度提高为O(logn)。通常的新增或者删除操做,涉及到数组元素的挪动,时间复杂度是O(n)。
二、链表
一种链式存储方式,不保证顺序性,逻辑上相邻的元素之间用指针所指定,它不是用一块连续的内存存储,逻辑上相连的物理位置不必定相邻。对于新增和删除操做,只处理节点的引用便可,时间复杂度是O(1);查找指定的节点,则须要循环整个链表,逐一比较节点的值和给定的值,时间复杂度是O(n)。
三、哈希表
新增 | 删除 | 查找 | |
数组 | O(1) | O(n) | O(n) |
链表 | O(1) | O(1) | O(n) |
哈希表 | O(1) | O(1) | O(1) |
哈希表为何具有如此高效的性能呢?
上面咱们已经介绍了,数组是最高效的数据存储物理结构,根据下标查找元素的时间复杂度是O(1)。哈希表的基础就是数组,在不考虑哈希冲突的状况下,经过一个特定的函数计算出要存储的元素在数组中的下标,只需一步便可实现新增、删除和查找操做。这个特定的函数就是哈希函数,哈希函数的好坏直接影响建立的哈希表的性能。一个优秀的哈希函数须要具有以下几个特性:
HashMap采用链地址法解决哈希冲突,极端状况下hashMap的查找元素的时间复杂度是O(n),也便是采用一个返回固定值的哈希算法,这样不一样的元素返回的哈希值是同样的,在某一个固定位置上,引入一个链表,存储全部的元素。
HashMap内部结构是由数组和链表组成,如图:
size hashmap中的kv组合的总数量,拿上图举例,size = 4(数组元素)+4(链表节点) = 9。
capacity 容量,hashmap中数组的长度,也称做桶的数量,默认值是DEFAULT_INITIAL_CAPACITY=16。拿上图举例,capacity=10。
loadFactor 装载因子,默认是0.75,此数值能够衡量hashmap满的程度。
threshold 扩容阀指,threshold = capacity * loadFactor ,当hashmap的size大于或者等于 threshold 时,hashmap将进行扩容。
MAXIMUM_CAPACITY HashMap的最大容量,1 << 30 = 230
HashMap有4个构造函数,下面这个函数是其余三个函数的基础。
/** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and load factor. * * @param initialCapacity the initial capacity * @param loadFactor the load factor * @throws IllegalArgumentException if the initial capacity is negative * or the load factor is nonpositive */ public HashMap(int initialCapacity, float loadFactor) { //容量小于0,抛出异常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //最大容量是2的30次幂 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //装载因子参数大于零 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; threshold = initialCapacity; //HashMap中这是一个空方法,LinkedHashMap则有逻辑 init(); }
从构造方法中能够看出,HashMap并未在new的时候就初始化数组,初始化数组是在put方法中进行的。
public V put(K key, V value) { if (table == EMPTY_TABLE) { //第一次存储数据时,进行数组的扩容 inflateTable(threshold); } if (key == null) //k=null时,放置在数组的第一个位置 return putForNullKey(value); //计算key的hashcode int hash = hash(key); //哈希表的秘密之所在,根据hashcode,计算此key在table中的存储位置 int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //若是key是同样,则覆盖原的值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; //钩子函数,hashmap并未实现 e.recordAccess(this); return oldValue; } } //迭代hashmap时,fast-fail依据此值是否抛出ConcurrentModificationException异常 modCount++; //新增元素 addEntry(hash, key, value, i); return null; }
HashMap非线程安全,就是说的这个方法未加锁。当覆盖原值的时候,会把原值返回;当是新增一个元素时,则返回null。
咱们接下来看一些inflateTable函数
private void inflateTable(int toSize) { // capacity大于等于toSize,而且是2的n次幂 int capacity = roundUpToPowerOf2(toSize); threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); //初始化数组 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
roundUpToPowerOf2函数的目的就是找到一个是2的次幂,而且是大于toSize,最接近toSize的正整数。它是怎么作到的呢?
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; //number非负数 //而且最大是2的30次幂 return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }
就是经过Integer.highestOneBit函数作到的,此方法的用意就是取正整数二进制的左边最高位的数字,而后再用这个数字的右边所有补0组成一个新的二进制数,这个二进制数的就是Integer.highestOneBit的结果。
例如number=15,
第一步:(15-1) << 1 = 14 * 21 = 28
第二步:28的二进制表示是 00011100
第三步:取 00011100,右边补0,组成新的二进制数 00010000
则Integer.highestOneBit((15-1) << 1) = 16,也便是大于15,而且最接近15的2的n次幂的整数是16。
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
此版本的HashMap哈希函数应用了大量的位运算,目的就是使得hashcode很是分散。
良好的哈希算法结合indexFor方法,使得存储的元素能均匀的分散在数组中。由于能直接索引到数组的下标值,因此HashMap的平均时间复杂度O(1)。
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
此方法至关于对h取模,可是经过位运算比取模运算效率更高。
void addEntry(int hash, K key, V value, int bucketIndex) { //数量大于等于阀值,而且发生了哈希碰撞 if ((size >= threshold) && (null != table[bucketIndex])) { //扩容hash表 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; //从新计算hash表的索引 bucketIndex = indexFor(hash, table.length); } //建立Entry,若是此数组位置上已经有数据了,则在此位置上产生链表。最新的元素存储在数组中,最新生成的Entry的next指向原来的Entry。 createEntry(hash, key, value, bucketIndex); }
上面介绍了HashMap第一次初始化数组的时候,经过roundUpToPowerOf2函数计算出数组的大小是2的n次幂,在addEntry的时候运行resize函数,将数组扩容到 2*table.length,这就决定了数组的大小必定是2的n次幂。
这样的设计的目的是减小哈希碰撞,使得要存储的元素能均匀的分布在数组中。下面咱们经过比较来证实这样设计的好处。
咱们假设要存储的元素的哈希值是[0,1,2...9]这10个数,当table.length = 16时,经过idnexFor函数计算出的索引值以下图所示:
咱们能够看到,要存储的元素的存储下标值很是均匀,而且没有产生任何哈希碰撞,此哈希表的时间复杂度是O(1)。下面咱们把table.length=15,示意图以下:
总共发生了5次碰撞,造成了5个链表,而且形成了table数组的空间浪费。
public V get(Object key) { if (key == null) //key是null的时候,直接在数组第一个位置取 return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } //取key的hashcode int hash = (key == null) ? 0 : hash(key); //根据key的hashcode,定位索引下标 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; //首先哈希值必须相等 //其次要不内存地址同样,要不就是equals if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }