目录
名词解释
函数
- Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值(hashCode)。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
散列的目的,在于尽量分散数据的存储位置,使数据散列在不同的哈希桶(bucket)中。hash函数用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
不懂?,MD5就是一种hash算法。不管给多长的字符串,都能计算出固定长度的字符串。内部使用各种方法尽量使散列值均匀,不容易出现重复。所以叫“散列”。
直接定址法,数字分析法,折叠法,随机数法、直接取余法、乘法取整法、平方取中法和除留余数法等等。
hashCode:
- 是一串固定长度的整型的数字,hashCode可以由hash函数生成。由hashCode可以得到对象在hash表中的位置。
应用场景
数据库分表:
- 例如要将用户表分成100个表,可以根据userid散列出0~100之间的数字。因为散列值比较均匀的特性,使得个表数据量比较均匀。最好是2的整数次幂个表,这样散列值可以按位与计算,快。hashmap中就是这样确定索引的。
MD5
数据结构
hashtable,hashmap(java)
相关定义
- entry:就是hashtable的键值对。hashtable底层数组中,最终存储的是entry,里面还包括其他数据,下面有具体定义代码。
- 桶(bucket):就是底层数组的每个元素就是一个桶。
相比其他数据结构
- 数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
- 线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
- 二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
- 哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),哈希表的主干就是数组。
哈希冲突(哈希碰撞)
- 定义:不同的key计算出来的散列值相同。导致不同entry要放入数组相同索引位置。
- 解决办法
-
开放寻址法:发生冲突,另找位置。比如查看数组下一个索引位置是否空着,空着就放这里,不空就继续寻找下一块未被占用的存储地址。
-
再散列函数法
-
拉链法:
- 数组元素entry保存了一个next指针,指向碰撞的entry (java hashTable采用这种方式)
- 问题:链表过长
如果这里的链表长度大于等于8的话,链表就会转换成树结构,当然如果长度小于等于6的话,就会还原链表。以此来解决链表过长导致的性能问题。
为啥是小于等于6啊,咋不是7嘞😂。这样设计是因为中间有个7作为一个差值,来避免频繁的进行树和链表的转换,因为转换频繁也是影响性能的啊。
-
数组+二叉树:不知道这种属不属于拉链法。
-
HashMap实现原理
- HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。
HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类,代码如下。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
所以,HashMap的整体结构如下图。
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
再来看看addEntry的实现。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,所以扩容相对来说是个耗资源的操作。
- 初始大小
创建hashmap的时候,无论你设定的初始大小是多少,内部通过roundUpToPowerOf2(toSize)可以确保初始大小capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32. - 确定存储位置
/**
* 返回数组下标
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
h:hash()结果,计算出来的尽量散列的数字
length:hashtable或hashmap的长度。length的长度一定是2的x次幂。所以length-1,二进制是0…01…1.低位全是1.
如右图,其实计算结果可以理解为,取h的低n-1位。使值在0到length-1的范围之内,得到索引位置。
所以最终存储位置的确定流程如右图
扩容
- HashMap中的数组容量默认是16(初始容量),当hashMap中的数据量一步步增大,超过阀值(加载因子,默认值是0.75)时,会自动扩展。这个数据量是entry的数量,而不是指数组中有多少桶(元素)有数据。
具体判断逻辑
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* The next size value at which to resize (capacity * load factor).
* @serial
*/
int threshold;
...
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
扩容的条件除了达到阈值,还需要当前出现碰撞。如果没碰撞,此时没有必要扩容。
扩展的过程是:创建一个新的HashMap,将原hashMap中的数据添加到新的hashMap中,这中间涉及到重新计算元素在数组中的位置索引,所以会非常耗时。所以在适应过程中,最好提前预估一下数据量,在hashMap初始化的时候,设置合适的初始容量和加载因子。
当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。
如果数组进行扩容,数组长度发生变化,而存储位置 index也可能会发生变化,需要重新计算index(rehash)。如果不重新计算,数据都存在新数组的前半部分,不均匀,后续加数据更容易发生碰撞。另外计算index的函数也是和数组长度有关的,这样才能均匀分布在整个数组中。
- 每次扩大都是翻倍。也就是都是2的n次幂。
- 优点:
- 如果长度是2的N次幂,那么计算位置的时候就是用现在的方式按位与“&”,因为length-1二进制低位都是1,按位与运算之后保证了索引位置足够分散。想想一下如果地位都是0,高位是1.按位与运算(都是1,结果才是1)之后,只能是两个结果。
而按位与运算在计算机中应该比其他方式更快,所以讲长度限制为2的n次幂是有优势的。 - 每次增大一倍,既不容易多次扩容,又不容易浪费空间。
- 如果长度是2的N次幂,那么计算位置的时候就是用现在的方式按位与“&”,因为length-1二进制低位都是1,按位与运算之后保证了索引位置足够分散。想想一下如果地位都是0,高位是1.按位与运算(都是1,结果才是1)之后,只能是两个结果。
- 思考的问题:随着不断的扩容。h(hash()所得)的值是否会变化。如果容量非常大,但是h的范围很小,也容易发生冲突。
h是通过函数final int hash(Object k)函数所得。返回int类型,有32位。HashMap最大的容量是1<<30,比h范围还小。
- 优点:
重写equals方法需同时重写hashCode方法
- 这个跟hashtable数据结构关系不大,大家看这个人的分析就清楚了。
hashtable 和hashmap的区别
- 网上一搜一大堆,这里只是简单提一下。 HashMap 允许 null key 和 null value,非线程安全 。Hashtable线程安全。另外hashtable已经过时,如果不需要线程安全,使用HashMap,如果需要线程安全,使用ConcurrentHashMap。
参考文献
https://www.cnblogs.com/chengxiao/p/6059914.html
https://blog.youkuaiyun.com/u010476994/article/details/80049715
https://juejin.im/post/6844904020704772103
https://blog.youkuaiyun.com/u014532901/article/details/78936283