HashMap初体验

本文是学习博客:试阿里,HashMap 这一篇就够了之后的记录,也有一些包括自己的理解

哈希表

什么是哈希表

如线性表、树等查询(折半查找、二叉排序树查找、B树查找),这类查找都属于比较类型的查找,也就是说查询效率完全取决于比较次数。为什么要比较呢,因为我们在存储元素位置的时候,完全是随机的。

哈希表是另一种查询方式,是将存储元素与存储位置建议一个对应关系,这样就可以不通过比较查找元素,类似于生活中的字典,通过关键字直接找到关键字的页码。

哈希==关系映射==f(x)=x

举例说明,关键字是我们的省份,映射关系为取首字母第一个字母,根据字母顺序,找到对应的存储下标

xBEIJINGTIANJINHEBEISHANXISHANGDONGGUANGZHOU
f(x)022008191907

存储结构
在这里插入图片描述

  1. 如果我们要查找BEIJING,通过关键字关系映射,找到下标02,找到元素
  2. 对于不同的key,但是对应的f(x)相同,如SHANXI 、SHANGDONG 都对应19,这种现象叫哈希冲突,哈希冲突是不能避免的,只能在冲突的时候想办法
  3. 由哈希算法构成的数据集合就是哈希表
哈希函数的构造方法

方法有几种,这里只讲HashMap使用的方法,除留余数法

H(key) = key MOD p

举例,关键字:28、35、63、77、105,p=21

关键字28356377105
哈希地址7140140
处理冲突的方法

方法有几种,这里只讲HashMap使用的方法,链地址法,当发生哈希冲突,采取数组+链式(当然HashMap有自己的优化)

在这里插入图片描述

哈希表的查找分析

哈希表的查找效率取决于链表长度,如果链表元素特别多,那么就是链表遍历算法了。所有我们要控制哈希冲突的概率,即当哈希表中元素达到哈希表的长度一定比列时,将要扩容,减少哈希冲突的概率,即哈希表的装填因子

在这里插入图片描述

注:HashMap取值默认为0.75

基于了以上理论知识,我们开启HashMap学习,采取以阅读源码java.util.HashMap为主,主要弄清几个重要方法的生命周期,对应代码为JDK 1.8

HashMap

为了理解HashMap,我提前学习了这些知识,帮助理解

HashMap存储结构

JDK 1.8 之前是由“数组+链表”组成,如下图所示

在这里插入图片描述
JDK 1.8 “数组+链表+红黑树”组成,如下图所示
在这里插入图片描述

问题一,为什么要引入红黑树?

答:链表查询效率是O(n),红黑树是一颗平衡树,查询效率是O(logn),当哈希冲突造成结点过多时候,链表的查询效率低,引入红黑树优化

问题二,为什么还要链表,数组+红黑树不行吗?
答:不行,红黑树在构建的时较复杂,设计结点平衡调整等操作,且相比链表需要多余的存储空间,当冲突结点比较少时,链表综合性能优于红黑树

问题三,当结点发生哈希冲突时,什么时候用链表?什么时候用红黑树?

参考代码变量

// 链表转红黑树结点阀值(treeifyBin)
static final int TREEIFY_THRESHOLD = 8;
// 链表转红黑树数组最小容量阀值
static final int MIN_TREEIFY_CAPACITY = 64;
// 红黑树转链表阀值(untreeify)
static final int UNTREEIFY_THRESHOLD = 6;

对应四个生命周期

  1. 当相同地址冲突结点小于8的,此时为链表
  2. 当相同地址冲突结点大于8的,但数组容量小于64,先扩大数据容量,不立刻转红黑树
  3. 当相同地址冲突结点大于8的,但数组容量大于64,链表转红黑树
  4. 当相同地址冲突结点原来是红黑树,由于数组扩容后,冲突结点小于等于6时候,红黑树转链表

为什么会选择6 、8个数为参考呢?源码注释给出来解释:随机的hashcode下,哈希地址是遵循泊松分布的,参考:(http://en.wikipedia.org/wiki/Poisson_distribution)

相同哈希地址冲突个数概率
0:0.60653066
1:0.30326533
2:0.07581633
3:0.01263606
4:0.00157952
5:0.00015795
6:0.00001316
7:0.00000094
8:0.00000006
大于8小于千万分之一

通过概率我们可以得知,当结点为6和8时候,上下结点的概率相差非常之大,比如理论情况下,要达到链表转红黑树条件,节点数超过千万级别

HashMap重要属性
  1. 存储结点table 数组
  • transient Node<K,V>[] table;
  • 结点Node是链表类型
    class Node<K,V> implements Map.Entry<K,V> {
    	final int hash;
    	final K key;
    	V value;
    	Node<K,V> next;
    	
    	...
    }
    
  • 结点Node是可以转换成红黑树结点TreeNode,TreeNode是Node的子类
    public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
        static class Entry<K,V> extends HashMap.Node<K,V> {
            Entry<K,V> before, after;
    			
    		...
        }	
    }
    
    final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    	TreeNode<K,V> parent;  // red-black tree links
    	TreeNode<K,V> left;
    	TreeNode<K,V> right;
    	TreeNode<K,V> prev;    // needed to unlink next upon deletion
    	boolean red;
    	
    	...
    }
    

table 数组存储结构如下所示
在这里插入图片描述
2. 其他属性

// table.length: 容量
transient Node<K,V>[] table;
// HashMap 已经存储的节点个数
transient int size;
// 扩容阈值,当 HashMap 的个数达到该值,触发扩容
int threshold;
// 负载因子,扩容阈值 = 容量 * 负载因子。
final float loadFactor;

大致思路是上述描述,细节有所不同,截图部分重要代码做以说明
在这里插入图片描述

前面我们已经回顾了哈希表原理,那么上述四个重要属性则是理所当然了,所有掌握一些必要的理论还是有必要的。

默认值

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
  1. 数组容量属性说明,HashMap 的容量必须是2的N次方,当然HashMap支持传入一个初始化容量,当不满足2的N次方,代码会转换

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    

    下面就来看看怎么转换的,由于HashMap没有定义容量变量,可通过table.length得到容量,但是初始化HashMap不会立刻去构造transient Node<K,V>[] table;,代码通过threshold扩容阀值变量曲线救国,先当做容量用,初始化的的时候再换回来(恶心啊,不肯多定义一个变量)

        public HashMap(int initialCapacity, float loadFactor) {
            this.loadFactor = loadFactor;
            this.threshold = tableSizeFor(initialCapacity);
        }
    

    threshold 和容量纠正回来
    在这里插入图片描述

下面看看tableSizeFor是如何做到将不是2的N次方数据转换成2的N次方的

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

代码其实很简单,思路也比较简单,其实非常好理解,没有大家说的那么复杂,我解释一下思想

假设我们是使用的32位存储空间,最高位为1

代码对应二进制
初始值n1000 0000 0000 0000 0000 0000 0000 0000
n |= n >>> 1;1100 0000 0000 0000 0000 0000 0000 0000
n |= n >>> 2;1111 0000 0000 0000 0000 0000 0000 0000
n |= n >>> 4;1111 1111 0000 0000 0000 0000 0000 0000
n |= n >>> 8;1111 1111 1111 1111 0000 0000 0000 0000
n |= n >>> 16;1111 1111 1111 1111 1111 1111 1111 1111

通过上述运算,我们可以看出,通过运算,最高位后面的数字都会被1填充,也就是说,我们只要把握好最高位的控制就可以了,最后通过+1,这个数字一定是2的N次方

举例说明一下

  • 例子一)当入参为16时候,对应二进制为00010000,执行int n = cap - 1;后变成00001111,再经过逻辑运算,最高位1后面都填1,还是00001111,最后执行n + 1;,变成了最终容量大小00010000=16
  • 例子二)当入参为25时候,对应二进制为00011001,执行int n = cap - 1;后变成00011000,再经过逻辑运算,最高位1后面都填1,变成00011111,最后执行n + 1;,变成了最终容量大小00100000=32
HashMap插入put流程

这个图是参考原作者,自己跟着源代码画了一个,老实说,如果只看这个图很容易忘记的,建议最好还是跟着源码看一遍吧。代码入口:java.util.HashMap#putVal
在这里插入图片描述

如何计算哈希地址

查看源代码,任何关键字的地址入库都是hash函数

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

继续更代码发现最终使用的对象的hashCode,并右移动16位,做异或运算(不同为1,相同为0),后面一起讲原因

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

理论片我们讲了,HashMap使用的方法,除留余数法,但是HashMap有改进,查看代码

p = tab[i = (n - 1) & hash])

我们可以得出哈希地址 index=(容量-1) & hashCode,使用的与运算取模,类似于除留余数法,前面我们讲解了hashCode进行了一个(h = key.hashCode()) ^ (h >>> 16)操作,是因为容量太小的时候,hashCode的高位都参与不到运算

HashMap resize流程

这个图是参考原作者,自己跟着源代码画了一个,老实说,如果只看这个图很容易忘记的,建议最好还是跟着源码看一遍吧。代码入口:java.util.HashMap#resize

在这里插入图片描述
如何理解(e.hash & oldCap) == 0新哈希地址判断

前面我们已经知道了,哈希地址计算:index = (Cap - 1) & hash,当我们扩容以后出现了newCap = oldCap << 1;即newCap = oldCap * 2;

通过哈希地址的计算公式我们可以看出,由于我们扩容是左移动一位,也就是说Cap - 1会把原来多了一个1,举例oldCap-1 是0000 1111,newCap -1是0001 1111,也就说只要判断hashCode对应的oldCap 位置是否是1即可,即(e.hash & oldCap) == 0

在这里插入图片描述
由于新老Cap是2倍关系,所以新老位置相比旧位置就是一个oldCap的距离,比较巧妙

HashMap线程安全问题

HashMap 不是线程安全的,在并发下存在数据覆盖、遍历的同时进行修改会抛出 ConcurrentModificationException 异常等问题

JDK 1.8 之前还存在死循环问题(原作者讲解的比较多,但是我这里更加巧妙的理解思路,下面来看看)

  • JDK 1.7 扩容采用的是“头插法”
  • JDK 1.8 之后采用的是“尾插法”

也就说头插法在并发下会出现问题,那就来看看怎么造成的在这里插入代码片
JDK 1.7.0 的扩容代码,代码只是说明是头插法,不用看

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

头插法举例,假设我们有数据1,2, 3,演示过程如下

在这里插入图片描述
那么我们现在就来简化问题,问题变成循环一个链表,采用“头插法”的倒序元素,即如图所示
在这里插入图片描述
多线程情况复现

步骤一)线程一先执行到标记到第一个元素,此时切换到步骤线程二执行,线程二很给力啊,直接执行完了(真6,DOGE)
在这里插入图片描述
步骤二)现在轮到线程一执行了,这个时候我们再对照代码看一下,重要的1/2/3步骤,标记了
在这里插入图片描述

方法标记1)Entry<K,V> next = e.next;(这个也是退出循环的条件e = next;)
在这里插入图片描述

方法标记2)e.next = newTable[i];(newTable[i]就是HEAD)
在这里插入图片描述
方法标记3)newTable[i] = e;(newTable[i]就是HEAD)
在这里插入图片描述
此时已经构成环了,就是这回事,同时也会退出循环,转换完毕,懂了吧

那么死循环是在哪里发生的呢

java.util.HashMap#get方法,(e = e.next) != null永远不会发生,这代码跑CPU非常厉害

在这里插入图片描述

总结(这段直接抄的,没啥好说的)

JDK 1.8 的主要优化有以下几点:

  1. 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
  2. 计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
  3. 优化了 hash 值的计算方式,老的通过一顿瞎JB操作,新的只是简单的让高16位参与了运算
  4. 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环
  5. 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。

其他
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值