HashMap原密码

149 阅读11分钟

这是我参与11月更文挑战的第21天,活动详情查看:2021最后一次更文挑战

一、什么是哈希表

在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下(后面会探讨下哈希冲突的情况),仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。    这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作: 插入过程如下图所示

hash的过程

二、哈希冲突

如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。

三、HashMap定义的基本属性

// 初始化容量为16且使用位运算来获取
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大值为2d的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
// 负载因子为0.75 也就说说当默认容量存储到12的时候就会扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当链表的长度大于8的时候就会转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当树中的容量小于6了则会把树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 只有当hash表中的容量大于64的时候才允许把链表转换为树
// 如果容量小于64但是某一个链表超过了8那么会通过resize进行数组扩容来解决
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储数据的数组
transient Node<K,V>[] table;
/**实际存储的key-value键值对的个数*/
transient int size;
/**
    扩容的阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
*/
int threshold;
​
/**
    负载因子,代表了table的填充度有多少,默认是0.75,负载因子存在的原因,是为了减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
    所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
    他是一个典型的以空间换时间的思想
*/
final float loadFactor;
​
/**
    HashMap被改变的次数,由于HashMap非线程安全,所以该变量主要是用来实现fail fast机制的,后面会详细谈到
*/
transient int modCount;

大家看到初始化容量的值并没有写死成16而是通过一个位运算来表示的,同时在HashMap种还大量运用到了位运算,这样的代码没有个七八年的功力是写不出来这样的优秀的代码的。

需要特别注意的是DEFAULT_INITIAL_CAPACITY和TREEIFY_THRESHOLD,我们经常会听到很多人说创建一个HashMap的时候初始化容量默认是16,HashMap存储数据的时候如果链表的长度达到了8那么就会转换位树这个说法真的正确嘛?

四、HashMap的构造方法

  public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
  }

默认的构造方法只是做了默认负载因子的设置并没有对table数组进行初始化,也就是说当我们new HashMap的时候实际上他的初始化容量是0的他并没有使用默认的容量进行初始化。

既然他没有初始化那么大家一定好奇他是在什么时候进行的初始化的呢?其实他是在put方法种进行了检测并完成了初始化的工作,代码如下:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;// 此处通过resize方法进行扩容的时候进行的初始化操作
        ......

HashMap默认容量的初始化过程

除了以上使用最多的无参的构造方法以外还有一个构造方法也需要特别关注:

//指定初始化容量-在开发的过程种如果能估算容量一定要使用该方法进行创建-主要是为了避免频繁扩容 
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

五、HashMap对象hash冲突的优化

HashMap并没有使用传统的hash算法而是对他进行了进一步的优化从而避免了hash冲突。

核心的代码:

 public V put(K key, V value) {
        // 存储数据的使用调用hash方法得到一个hash值
        return putVal(hash(key), key, value, false, true);
 }
//hash方法对hash算法进行了进一步的优化    
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

从代码中可以看出来他是先得到了一个hash值然后再右移了16位然后再进行了一个按位异或的操作。

优化的hash算法

即使看看懂了他的算法可能还是很多有都会有一个疑问这样就可以降低hash冲突嘛?当前这样是不可以的但是但是在结合后面要说的高效寻址算法就可以,现在先简单的说一下这个寻址算法,该算法基本只会使用到hash值得低16位进行运算,而低16位最多表示到65535,范围并不大所以出现冲突得概率还是挺大的,但是如果加入了高16位的特征那么该hash值得低16位就多了很多的变化从而避免了hash冲突。

hash冲突

六、put方法存储数据

    // onlyIfAbsent 如果为true则表示不修改存在的value
    // evict 如果为false则表示hash表还处于创建模式
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab;  // 存储数据的Hash表
        Node<K,V> p;  //一个节点数据
        int n, i;
        //如果table为空
        if ((tab = table) == null || (n = tab.length) == 0)
        /*
        执行resize扩容操作,如果hash表为null那么使用默认的容量来进行扩容
        如果已经有了容量则在原来的基础上扩容2倍
        如果容量已经大于了最大容量则扩容为int的最大值
        */
            n = (tab = resize()).length; //hash表的容量
        // 通过高效寻址算法得到一个索引然后获取当前索引的值,
        // 如果值为null则直接再该节点上创建对象
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else { //如果获取的索引节点上已经存在了数据则走else
            Node<K,V> e;
            K k;
        /* 
            如果获取到了索引上的节点的hash值等于传递进来的hash
            且索引上节点的key值等于传递过来的key值同时key值不为null
            则直接用新的数据替换老的数据-数据的修改操作
        */
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
                //如果p已经是一个树形节点数据则走树形结构的添加
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 走链表的添加如果链表长度大于8则转换为树
                for (int binCount = 0; ; ++binCount) {
                    //如果p没有下一个节点数据了则直接添加一个节点
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                /*
                    如果p还有下一个链表节点则获取下一个节点的hash和传递过来的hash进行比较
                    如果hash相等且key值相当同时key值不为空则修改当前节点上的数据
                */
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 实现数据的修改操作
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //如果存储的容量大于了threshold(总容量*负载因子)则进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

put方法

值得注意的是当我们的链表长度到达了8的时候还会检测一下table的长度是否达到了MIN_TREEIFY_CAPACITY ,如果达到了则转换位树如果达不到则进行数组的扩容。

七、高效的寻址算法

在put数据的时候会通过高效的寻址算法来获取数组的下标,具体的高效算法如下:

(n - 1) & hash // n代表的是数组的length

一般情况下数组的长度都不会特别的大,同时之前也给大家讲过低16位全部是1就可以表示65535,所以该高效寻址算法基本上只会用到低16位进行运算,这也是为什么上面我们在讲高效hash算法的时候会把高16位全部设置位0的原因。

同时为什么这个算法是高效的的呢?这其实是一个相对的概念,如果让我们去求一个索引的下标可能很多人会使用取余的方式去实现,那么取余的数学运算相对于位运算来说位运算就要高效很多了。

八、hash冲突了如何解决

  1. 如果是在hash表中冲突了则判断是否是同一个可以如果是则修改数据
  2. 如果是在链表中冲突了则遍历链表看是否有key值相同的如果有则进行数据的修改如果没有则添加一个节点
  3. 如果添加的节点大于8且hash表的总容量小于64则会进行数组的扩容然后重新rehash此时整个数组和链表都会发生变化-因为扩容以后rehash以后原本hash冲突的现在可能不会冲突了
  4. 如果是在树中冲突了则通过变色或者旋转的方式来解决

九、resize以后链表的rehash过程

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            else { // preserve order
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    if ((e.hash & oldCap) == 0) {
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    }
                    else {
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    }
                } while ((e = next) != null);
                if (loTail != null) {
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}

04_hashmap的resize原理

数组扩容以后会重新rehash,然后重新计算一个数组下标的位置来存储数据,那么rehash的过程中他就会打破原来的链表比如原来链表中有四个数据的,rehash以后就可以只有2个数据另外2个数据在另一个索引下标中。