Hash算法

拉链法
重Hash法
线性探测

1. HashMap 简介

1.1 类定义

  1. public class HashMap<K,V> extends AbstractMap<K,V>
  2. implements Map<K,V>, Cloneable, Serializable

1.1.1 实现和继承关系

  • 继承 抽象类AbstractMap,提供了 Map 的基本实现,使得我们以后要实现一个 Map 不用从头开始,只需要继承 AbstractMap, 然后按需求实现/重写对应方法即可;
  • 实现 Map接口,Map是”key-value键值对”接口;
  • 实现 Cloneable接口,即覆盖了clone()方法,能被克隆;
  • 实现 java.io.Serializable 接口,支持序列化,能通过序列化去传输;

1.1.2 HashMap引入

1.1.2.1 哈希表原理

  • 哈希表

[1]我们知道在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这个特性,哈希表的主干就是数组;

[2]比如我们要新增或查找某个元素,我们通过把当前元素的关键字通过某个函数映射到数组的某一个位置,通过数组下标一次定位就可完成操作;

存储位置 = f(关键字) 其中这个函数f()一般就称之为【哈希函数】,这个函数的设计好坏会直接影响到哈希表的优劣.举个栗子,比如我们要在哈希表中执行插入操作: hash.png 查找操作:同理,先通过哈希函数计算 出实际存储地址,然后从数组中对应位置取出即可;

  1. - **哈希冲突**

如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办? 也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了, 其实这就是所谓的哈希冲突,也叫哈希碰撞。

解决hash冲突的方法:

[1]线性探测,开放地址法(发生冲突,继续寻找下一块未被占用的存储地址)

[2]重Hash算法

[3]链地址法(HashMap采用的就是这种方法)

1.1.2.2 底层实现

1.1.2.2.1 基本面貌
  1. HashMap采用链地址法,大概长下面的样子
  • 横着看

hashmap-横着看.jpg

  • 竖着看

hashMap-竖着看.png

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的, 如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表, 对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,因为最近被添加的元素被查找的可能性更高, 而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。 所以,性能考虑,HashMap中的链表出现越少,性能才会越好;

1.1.2.2.2 基本组成成员
  • table
  1. // 我们称之为 桶(数组),初始化为16个;
  2. transient Node<K,V>[] table;
  • Node
  1. //基本节点(构成链表的基本元素)
  2. // hash 当前节点 hash值;
  3. // key 当前节点的 key;
  4. // value 当前节点 value;
  5. // next 当前节点的下一个指向;
  6. static class Node<K,V> implements Map.Entry<K,V> {
  7. final int hash;
  8. final K key;
  9. V value;
  10. Node<K,V> next;
  11. }
  • 重要属性:
  1. // 初始化HashMap数组的长度
  2. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  3. // 最大数组的长度
  4. static final int MAXIMUM_CAPACITY = 1 << 30;
  5. // 默认负载因子为0.75,代表了table的填充度有多少;
  6. static final float DEFAULT_LOAD_FACTOR = 0.75f;
  7. //用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,
  8. //如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
  9. //需要抛出异常ConcurrentModificationException
  10. transient int modCount;
  11. //实际存储的key-value键值对的个数
  12. transient int size;
  13. //当add一个元素到某一个桶位置时,若链表长度达到8就会将链表转化为红黑树;
  14. static final int TREEIFY_THRESHOLD = 8;
  15. //当某一个桶位置上的链表长度退减为6时,由红黑树转化为链表;
  16. static final int UNTREEIFY_THRESHOLD = 6;
  17. //阈值,当table == {}时,该值为初始容量(初始容量默认为16);
  18. //当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。
  19. //HashMap在进行扩容时需要参考threshold,后面会详细谈到
  20. int threshold;
变量名 变量初始值 变量值含义
DEFAULT_INITIAL_CAPACITY 2^4 HashMap默认初始化桶(数组)数量
MAXIMUM_CAPACITY 2^30 HashMap最大桶(数组)数量
DEFAULT_LOAD_FACTOR 0.75 负载因子(影响扩容机制)
size 无初始化值 实际存储的key-value键值对的个数
TREEIFY_THRESHOLD 8 链表长度>8时变化数据结构为红黑树
UNTREEIFY_THRESHOLD 6 当桶位置上元素个数

1.1.2.2.3 构造器
  • 没有传入参数
  1. // 默认构造器,负载因子为0.75;
  2. public HashMap() {
  3. this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
  4. }
  • 传入参数 : 指定数组长度,指定负载因子
  1. /**
  2. * Constructs an empty <tt>HashMap</tt> with the specified initial
  3. * capacity and load factor.
  4. *
  5. * @param initialCapacity the initial capacity
  6. * @param loadFactor the load factor
  7. * @throws IllegalArgumentException if the initial capacity is negative
  8. * or the load factor is nonpositive
  9. */
  10. public HashMap(int initialCapacity, float loadFactor) {
  11. if (initialCapacity < 0)
  12. throw new IllegalArgumentException("Illegal initial capacity: " +
  13. initialCapacity);
  14. if (initialCapacity > MAXIMUM_CAPACITY)
  15. initialCapacity = MAXIMUM_CAPACITY;
  16. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  17. throw new IllegalArgumentException("Illegal load factor: " +
  18. loadFactor);
  19. this.loadFactor = loadFactor;
  20. this.threshold = tableSizeFor(initialCapacity);
  21. }

tableSizeFor方法

  1. /**
  2. * Returns a power of two size for the given target capacity.
  3. */
  4. static final int tableSizeFor(int cap) {
  5. int n = cap - 1;
  6. n |= n >>> 1;
  7. n |= n >>> 2;
  8. n |= n >>> 4;
  9. n |= n >>> 8;
  10. n |= n >>> 16;
  11. return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
  12. }
  13. 功能: tableSizeFor的功能(不考虑大于最大容量的情况)
  14. 是返回大于输入参数且最近的2的整数次幂的数;
  15. 为什么上述的位运算能得到大于参数且最近的2的整数次幂的数呢?
  16. 答:| 为或操作符, >>>y为无符号右移y ,空位补0,
  17. n |= n >>> y会导致它的高n+1位全为1,
  18. 最终n将为111111..1 1,即为100000..00,即2的整次幂;

对于这个构造函数,主要讲两点:

  1. [1]虽然说是自定义initialCapacity,但是实际上并不是你所想的那样的任意数组长度;
  2. 经过tableSizeFor方法的处理之后,会得到一个大于输入参数且最近的2的整数次幂的数;
  3. 比如你输入一个10,得到的是初始化数组长度为16HashMap;
  4. [2]在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),
  5. 而是在执行put操作的时候才真正构建table数组;

1.1.2.3 基本操作

1.1.2.3.1 如何获取Hash值
  • 扰动函数

[1]获取hashCode值

  1. public final int hashCode() {
  2. return Objects.hashCode(key) ^ Objects.hashCode(value);
  3. }

[2]获取key的hash值

  1. static final int hash(Object key) {
  2. int h;
  3. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  4. }

扰动函数解析:

  1. 上述代码中key.hashCode()函数调用的是超类Object类中的哈希函数,JVM进而调用本地方法,返回一个int值;
  2. 理论上是一个int类型的值,范围为-2147483648-2147483647,
  3. 前后加起来大概有40亿的映射空间,的确这么大的空间,映射会很均匀
  4. 但是这么大的数组,内存中是放不下的。HashMap的初始化数组只有16
  5. ,所以这个散列值是无法直接拿来用的,所以要对hashCode做一些处理
  6. ,具体在下面小节中解答;
  7. 扰动函数的结果: keyhashhashCode中高位和低位的复合产物;

1.1.2.3.2 获取桶索引位置
  1. JDK 1.8中是直接通过p = tab[i = (n - 1) & hash这个方法来确定桶索引位置的;
  2. [1]hash为扰动函数的产物;
  3. [2]n-1为当前hashmap的数组个数len-1;
  4. 此时,我们会有诸多疑问,比如:
  5. 1】为什么要做&运算呢?
  6. 答: 与操作的结果就是高位全部归零,只保留低位值,用来做数组下标访问。
  7. 举一个例子说明,初始化长度为16,16-1=15 .
  8. 2进制(4字节,32位)表示就是 00000000 00000000 00000000 00001111
  9. 某散列值做与操作结果就是
  10. 00000000 10100101 11000100 00100101
  11. 00000000 00000000 00000000 00001111
  12. & -------------------------------------
  13. 00000000 00000000 00000000 00000101 //高位全部归零,只保留尾部四位
  14. 2】为什么是 n-1 呢?
  15. 答: 源头就是HashMap的数组长度为什么要取2的整数幂?
  16. 因为这样(n-1)
  17. 相当于一个"低位掩码";
  18. 这样能结合下面的&运算使散列更为均匀,减少冲突
  19. (相对其他数字来说,比如10的话,下面我们以0010010100110001这两个数做一个例子说明)
  20. 15& 10& 15& 10&
  21. 00100101 00100101 00110001 00110001
  22. 00001111 00001010 00001111 00001010
  23. &------- -------- -------- --------
  24. 00000101 00000000 00000001 00000000
  25. 我们发现两个不同的数00100101 0011001 分别和15以及10做与运算,
  26. 15做&运算会得到两个不同的结果(即会被散列到不同的桶位置上),而和10做与运算得到的是相同的结果(散列到同一个位置上),
  27. 这就会出现冲突!!!!!
  28. 3hash&(len-1) hash%len 的效果是相同的;
  29. 例如 x=1<<4,即2^4=16;
  30. x: 00010000
  31. x-1: 00001111
  32. 我们假设一个数M=01011011 (十进制为:Integer.valueOf("01011011",2) = 91)
  33. M : 01011011
  34. x-1: 00001111
  35. &-------------
  36. 00001011(十进制数为:11)
  37. 91 % 16 = 11
  38. 证明两者效果相同,位运算的性能更高在操作系统级别来分析;
  39. 4】为什么扰动函数是那样子的呢?
  40. 如下图所示,h>>>16 之后和h 做异或运算得到的hash前半部分是h的高8位,
  41. 后半部分是hash的高16位和低16位的复合产物;

扰动函数.png

1.1.2.3.3 put()方法
  • put()方法
  1. [1]调用put方法放入key-value键值对;
  2. public V put(K key, V value) {
  3. return putVal(hash(key), key, value, false, true);
  4. }
  1. [2] putVal()过程;

putVal.png

  • 分析put过程:
    • [1]首先判断table是否为空或者为null,如果是,则初始化数组table;
    • [2]根据键值key计算hash值 并得到桶索引位置((n-1)& hash),如果table[i]=null,直接新建节点添加,转向[6],如果table[i]不为空,则转向[3];
    • [3]判断table的首个元素是否和key相同,如果相同的话直接覆盖value,否则直接转向[4],这里的相同指的是hashCode和equals
    • [4]判断table[i]是否为treeNode,即table[i]是否为红黑树,如果是红黑树,则直接在树中插入键值对,否则转向[5];
    • [5]遍历tablei,判断链表长度是否大于8,大于8的话将链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
    • [6]插入成功,判断实际存在的键值对size是否超过了最大容量(阈值),如果超过,进行扩容;

[1]这里重点说明下,在JDK1.6中index = hash % len 做与运算 ,在JDK 1.7/1.8中 变为了 i =(len-1) & hash,两者的作用是相同的;

[2]Put时如果key为null,存储位置为table[0]或table[0]的冲突链上(table为HashMap中存的数组),

如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value,如果对应数据不存在,则添加到链表的头上(保证插入O(1));
put:首先判断key是否为null,若为null,则直接调用putForNullKey方法。若不为空则先计算key的hash值,
然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,循环遍历链表,比较是否存在相同的key,
若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。

  • 拉链法的工作原理
  1. HashMap<String, String> map = new HashMap<>();
  2. map.put("K1","V1");
  3. map.put("K2","V2");
  4. map.put("K3","V3");
  5. [1]新建一个HashMap,默认大小为1<<4(16);
  6. [2]插入Node<K1,V1> ,先计算K1hash值为115,使用和(len-1)做&运算得到所在的桶下标为115&15=3;
  7. [3]插入Node<K2,V2>,先计算K2hash值为118,使用和(len-1)做&运算得到所在的桶下标为118&15=6;
  8. [4]插入Node<K3,V3>,先计算K3hash值为118,使用和(len-1)做&运算得到所在的桶下标为118&15=6,插在<K2,V2>后面.

hashmap-put.png

1.1.2.3.4 get()方法

get.png

  • 过程分析:
    • 根据Key计算出hash值;
    • 桶位置index =hash & (len-1)
      • 如果要找的key就是上述数组index位置的元素,直接返回该元素的值;
      • 如果该数组index位置元素是TreeNode类型,则按照红黑树的查询方式来进行查询;
      • 如果该数组index位置元素非TreeNode类型,则按照链表的方式来进行遍历查询;

2. HashMap 高级特性

2.1 扩容机制

  1. resize() 方法用于初始化数组(初始化数组过程由于涉及到类加载过程,将会放到JVM模块中进一步解释)或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移。

2.1.1 扩容后位置变化

  1. 每次扩容,新容量为旧容量的2倍;
  2. 扩容之后元素的位置
  3. [1]要么在原来位置上
  4. [2]要么在原位置再移动2^n的位置上;
  • 解释过程
  1. hashmap的原来桶数量为16,即n-1=15(二进制表示:1111)
  2. [1]扩容前
  3. n-1 : 0000 0000 0000 0000 0000 0000 0000 1111
  4. hash1: 1111 1111 1111 1111 0000 1111 0000 0101
  5. &----------------------------------------
  6. 0000 0000 0000 0000 0000 0000 0000 0101 (桶位置为5)
  7. n-1 : 0000 0000 0000 0000 0000 0000 0000 1111
  8. hash2: 1111 1111 1111 1111 0000 1111 0001 0101
  9. &----------------------------------------
  10. 0000 0000 0000 0000 0000 0000 0000 0101 (桶位置也是5)
  11. 两个hash值不同的对象,和n-1做&运算之后,得到相同的结果;
  12. [2]扩容后
  13. hashmap此时桶的数量变为32,即n-1=31(二进制表示:11111)
  14. n-1: 0000 0000 0000 0000 0000 0000 0001 1111
  15. hash1: 1111 1111 1111 1111 0000 1111 0000 0101
  16. &---------------------------------------
  17. 0000 0000 0000 0000 0000 0000 0000 0101 (扩容后,桶位置依旧是5)
  18. n-1: 0000 0000 0000 0000 0000 0000 0001 1111
  19. hash2: 1111 1111 1111 1111 0000 1111 0001 0101
  20. &---------------------------------------
  21. 0000 0000 0000 0000 0000 0000 0001 0101 (扩容后,桶位置变为之前5+2^4=21)
  1. 元素在hashmap扩容之后,会重新计算桶下标,从上面的例子中可以看出来,hash1的桶位置在扩容前后没有发生变化,
  2. hash2的桶位置在扩容前后发生了变化;
  3. 上述扩容过程中&运算的关键点就在于
  4. 扩容之后新的长度(n-1)转化为2进制之后新增的bit1,
  5. keyhash 值所对应位置的bit1 还是0,
  6. 如果是0的话那么桶位置就不会变化,依旧为index;
  7. 1 的话桶位置就会变成index+oldCap;
  8. 通过if((e.hash&oldCap)==0)来判断hash的新增判断bit1还是0;

2.1.2 HashMap扩容为啥是2倍扩展

  1. 综上所述,根本原因为2进制数字;

2.2 负载因子为什么是0.75

  1. 结论: 通俗地说默认负载因子(0.75)在时间和空间成本上提供了很好的折中;
  1. * Because TreeNodes are about twice the size of regular nodes, we
  2. * use them only when bins contain enough nodes to warrant use
  3. * (see TREEIFY_THRESHOLD). And when they become too small (due to
  4. * removal or resizing) they are converted back to plain bins. In
  5. * usages with well-distributed user hashCodes, tree bins are
  6. * rarely used. Ideally, under random hashCodes, the frequency of
  7. * nodes in bins follows a Poisson distribution
  8. * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
  9. * parameter of about 0.5 on average for the default resizing
  10. * threshold of 0.75, although with a large variance because of
  11. * resizing granularity. Ignoring variance, the expected
  12. * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
  13. * factorial(k)). The first values are:
  14. *
  15. * 0: 0.60653066
  16. * 1: 0.30326533
  17. * 2: 0.07581633
  18. * 3: 0.01263606
  19. * 4: 0.00157952
  20. * 5: 0.00015795
  21. * 6: 0.00001316
  22. * 7: 0.00000094
  23. * 8: 0.00000006
  24. * more: less than 1 in ten million

资料见引用

  1. 理想状态下,在随机哈希值的情况,对于loadfactor = 0.75 ,
  2. 虽然由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布;
  3. 而对于一个bucket是空或非空的概率为0.5,通过牛顿二项式等数学计算,得到这个loadfactor的值为log2),约等于0.693.
  4. 同回答者所说,可能小于0.75 大于等于log2)的factor都能提供更好的性能,0.75这个数说不定是 pulled out of a hat

2.3 线程安全问题

2.3.1 线程不安全的表现

  • JDK 1.7: 并发死环,丢数据
  • JDK 1.8: 并发丢数据

2.3.2 源码分析

2.3.2.1 扩容后节点顺序

2.3.2.2 JDK1.7死环源码分析
  1. 说到根本就是扩容之后,链表元素顺序发生变化导致的;
  2. JDK 1.7 中扩容之后 链表顺序会倒置(采用头插法);
  1. JDK 1.7 扩容中重新安排Entry操作:
  2. void transfer(Entry[] newTable) {
  3. Entry[] src = table; //src引用了旧的Entry数组
  4. int newCapacity = newTable.length;
  5. for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
  6. Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
  7. if (e != null) {
  8. src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
  9. do {
  10. Entry<K,V> next = e.next;
  11. int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
  12. e.next = newTable[i]; //标记[1]
  13. newTable[i] = e; //将元素放在数组上
  14. e = next; //访问下一个Entry链上的元素
  15. } while (e != null);
  16. }
  17. }
  18. }
  19. 实际就是一个链表的反转,迭代方法; newTable[i] 的作用就是配合操作,保存值;

2.3.2.2.1 扩容后节点顺序反向存储 (重要)

实际就是一个链表的反转,迭代方法;

  1. 在分析HashMap的线程不安全之前,我们先看一下上面扩容重组过程中的一段扩容后重新分配bucket的代码
  2. ,并结合一小段自定义代码来理解这个过程。
  1. 我最开始看上面那段代码的时候,死活看不懂,最后自己模拟了这个过程才明白。咱们先看下 我的模拟代码:
  2. 链表反转
  • Entry :
  1. @AllArgsConstructor
  2. @NoArgsConstructor
  3. @Builder
  4. @Data
  5. public class Entry<T> {
  6. int val;
  7. Entry next;
  8. public Entry(int val) {
  9. this.next = null;
  10. this.val = val;
  11. }
  12. }
  13. public static void main(String[] args) {
  14. Entry e = new Entry(1);
  15. e.next = new Entry(2);
  16. e.next.next = new Entry(3);
  17. e.next.next.next = new Entry(4);
  18. Entry[] newTable = new Entry[6];
  19. while (e != null) {
  20. // [1]
  21. Entry next = e.next;
  22. // [2]
  23. e.next = newTable[5];
  24. // [3]
  25. newTable[5] = e;
  26. // [4]
  27. e = next;
  28. }
  29. }

2.3.2.2.2 模拟过程

hashmap-1.7-resize-01.pnghashmap-1.7-resize-02.pnghashmap-1.7-resize-03.pnghashmap-1.7-resize-04.pnghashmap-1.7-resize-05.pnghashmap-1.7-resize-06.pnghashmap-1.7-resize-07.pnghashmap-1.7-resize-08.pnghashmap-1.7-resize-09.png

上面讲完了链表反转的基本操作,我们举一个例子来看下 如何在多线程环境下形成死环的。(该例子 来自于CSDN ,博客地址见引用)

HashMap - 图17
假设HashMap 初始化容量为2,阈值为1,即塞入元素3的时候 就要发生扩容。

HashMap - 图18

之前我们分析了transfer 方法就是让 原链表顺序发生逆转。
现在假设有2个线程A,B
A在扩容获取到元素3时,执行完 Entry next = e.next,之后发生了时间片切换,此时next 指向 元素 7 .
B上场开始扩容,B扩容完毕之后,元素7 的next指针指向 元素3 ,而CPU切回到线程A之后,对于A继续执行,但是此时 ,元素3和元素7 之间互相指向。如图所示,

image.png
A线程就一直会陷在do while循环中找 e.next!=null之中。

2.3.2.3 JDK1.8扩容过程分析及数据丢失
  • 扩容后保证顺序不变化
  1. JDK 1.8扩容时保持链表顺序不变,避免了死环现象的发生;
  • 数据丢失问题分析

image.png

  1. HashMap 数据丢失的问题 比较好理解,我们看下JDK 8 上图这段代码,
  2. 704行和705行.
  3. 假设线程A704行判断 oldTab[j]!=null,然后在705 将其oldTab[j]设置为null,此时时间片切换,
  4. 线程B704行判断oldTab[j]为空,直接跳过这个索引下的旧数据,然后往下走执行完整个扩容过程,
  5. 会直接导致这个桶位置上的所有元素丢失。

2.4 树状化存储结构

知识铺垫:
需要了解下面三种树的优点及缺点,才能了解到 HashMap 为何选择 红黑树(工程中首选)作为树状化存储结构的原因。

二叉搜索树(BST: BinarySearch Tree) -> 平衡二叉树(AVL Tree)-> 2-3树 ->红黑树

引用

Stackoverflow-老外的牛顿二项式
泊松分布
HashMap线程安全问题
美团技术团队-Java8 系列之重新认识HashMap
Wikipedia-BinarySearch Tree
Wikipedia-AVL Tree
Wikipedia-2-3 Tree
Wikipedia-Red Black Tree
算法日记月累-AVL
CNBLOG-2-3树
可视化数据结构