【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析

HashMap 再认识

    • Ⅰ 从面试出发
    • Ⅱ Map 整体结构
    • Ⅲ 相关数据结构与算法
      • 一、 数据结构
        • ① 散列表
        • ② 链表
        • ③ 红黑树
      • 二、算法
    • Ⅳ 源码分析
      • 一、 HashMap
        • ① 内部结构实现
        • ② 一些极其巧妙并且重要的方法
          • a. 哈希桶的索引位置如何确定
          • b. 扩容 & 数据迁移
          • c. 树化
    • Ⅴ 总结

Ⅰ 从面试出发

HashMap 现在也算是面试官非常爱考的一个东西了,针对 HashMap 可以考量的东西很多,比如牵扯到的几种数据结构(散列表,链表,红黑树),典型的应用场景,以及技术实现等等。尤其是在 Java 8 中,HashMap 发生了很大的变化,都是可以被考察的点。

这篇文章还是延续我上一篇的模式 <链接>,先从一个简单的面试题出发,然后从数据结构与算法以及力扣题的总结予以补充,最后再从源码进行分析。

Q: 请你说说 HashMap, HashTable, TreeMap 有什么不同?

下面的答案源引自极客时间,杨晓峰《Java核心技术面试精讲》。

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操作数据的容器类型。


Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。


HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户 ID 和用户信息对应的运行时存储结构。


TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。

本篇文章着重在相关的数据结构和算法的补充以及 HashMap 的源码实现上,因为 HashMap 的源码可说的地方比较多,面试常考的也是这里,像 HashMap 的扩容,树化以及散列冲突的处理,都是很值得学习的。

Ⅱ Map 整体结构

在分析 HashMap 之前,我们先来看一看 Map 家族的整体结构。在上一篇 ArrayList & LinkedList 分析 中,我也提到了 Map 虽然通常被包括在 Java 集合框架里,但是它并不属于集合(Collection)。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第1张图片

Map 接口通常有四个常用的实现类,HashMapHashtableLinkedHashMapTreeMap,我们着重来看一下这四个类的特点。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第2张图片

  • HashMap
    1. HashMap 根据键值对(Key, Value)来存储数据,因此大部分时间都可以直接根据 Key 访问到对应的 Value,访问的时间复杂度是 O(1) 的。但是HashMap 的遍历访问顺序是不确定的。
    2. HashMap 允许键为 null(仅可以一个键为null),允许多条值为null
    3. HashMap 是线程不安全的,如果多个线程同时对它进行操作,可能会有数据不一致的问题。在需要线程安全的场景下,可以使用Collections类的synchronizedMap() 方法使得HashMap 具有线程安全能力,或者直接使用JUC的ConcurrentHashMap
  • LinkedHashMap
    1. LinkedHashMapHashMap的一个子类,是用双向链表 + 散列表实现的,因此保留了插入顺序,默认是按照插入的顺序进行遍历。
    2. LinkedHashMap可以通过改变参数实现 LRU cache 的效果,可以按照访问顺序(包括put,remove,get)排序。
  • TreeMap
    1. TreeMap的本质是BST,也就是平衡二叉树,是用红黑树实现的,因此也可以将键值有序排列。默认是按照键值升序排列的,可以通过对Comparator接口的实现自行排序。
    2. 在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。
  • HashTable
    1. HashTable 是一个遗留类,继承自Dictionary类,所以是线程安全的。
    2. HashTable是同步的,由于ConcurrentHashMap引入了分段锁,所以并发性并不如ConcurrentHashMap
    3. 基本功能和HashMap类似。

HashMap 的性能表现非常依赖于哈希值的有效性,因此必须要遵循 hashcode 和 equals 的一些约定

  • 重写了 hashcode 必须重写 equals。
  • equals 相等, hashcode 必须相等。
  • hashcode 需要保持一致性,类的状态改变不能影响哈希值的一致。
  • equals 的自反性,传递性,对称性。

关于最后一点其实就是高中里集合的几个定义,大家如果不清楚可以去看这篇文章 equals 自反,对称,传递 。

Ⅲ 相关数据结构与算法

一、 数据结构

① 散列表

这里我们还是着重看一下 HashMap 就好了。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第3张图片
HashMap的结构大概如上图所示,这里用到了一个数据结构叫 散列表(hash table),也叫哈希表。这里不再赘述概念,对散列表不熟悉的同学请跳转去看下面的文章。

【数据结构与算法】->数据结构->散列表(上)->散列表的思想&散列冲突的解决

【数据结构与算法】->数据结构->散列表(中)->工业级散列表的设计

【数据结构与算法】->数据结构->散列表(下)->散列表和它的好朋友链表

要熟悉相关的概念,比如散列冲突、散列函数,只看第一篇就够了。在接下来讲解 HashMap 的时候我会讲到 HashMap 解决散列冲突(也叫哈希碰撞)的方法,如果你对这里也不熟悉,可以再把第二篇文章也看了。

② 链表

这个非常基础,我不再赘述,在上一篇文章中我分析到了 LinkedList,在其中对链表的相关知识做了详尽的讲解和链接,可以直接跳转过去 <链表相关>。

③ 红黑树

红黑树应该属于数据结构里最难的一种了,所以放心,一般不会有面试官会变态到让你手写红黑树出来,如果让你写了,可能就是变相的劝退吧。

如果想对红黑树有个详细的了解的话,大家可以先去看看2-3树,红黑树其实就是对2-3树的一个再优化。这里我只完成一个概述,因为确实即使看了红黑树的源码(大概率看不懂),对面试的帮助也不会很大,毕竟面试官也不会。

首先要明确红黑树是平衡二叉树,而平衡二叉树是从 BST(二叉查找树)来的,关于二叉树以及二叉查找树不明确的同学可以去看我下面的文章

【数据结构与算法】->数据结构->树与二叉树

【数据结构与算法】->数据结构->二叉查找树

我们知道二叉查找树最根本的特性就是:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。 这样我们就可以对链表进行 <二分查找> 了。

但是数据的插入是很不规律的,有可能会发生很多数据都插入到同一边的情况,那这时候 BST 的查找时间复杂度就不再是 O(logn) 了,而是退化成了一根链表,查找的时间复杂度变成了 O(n)。

这样平衡二叉树就诞生了,比如最经典的 AVL 树,就是为了防止一边数据过多,退化成链表,所以严格定义了平衡因子,当左右子树的高度相差大于 1 的时候,就要进行一个旋转操作,使得其平衡,也就是达到左右子树高度最多相差 1 的程度。

这个旋转操作就是平衡二叉树中最重要的操作之一,一共有四种旋转方式(左旋右旋左右旋右左旋)。这里我给出几个简单的图示。

在做旋转操作的时候,大家一定要想到BST的性质,左子树的节点都是小于它的根的,而右子树的结点都是大于它的。在旋转后这个性质也是绝对也是不能变的。

  1. 左旋

左旋发生在右右子树的情况。注意哦,这时候A的值一定是小于B的值,而B一定小于C的,这样才满足右子树的所有结点都大于根的性质(这种情况A是根,B自然要比A大,C要比B大)。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第4张图片
左旋后:
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第5张图片
这样左旋完成之后,是不是还是满足左子树的结点(A)小于根(B),而根又小于右子树的结点(C)。整棵树还平衡了,左右子树的高度一致了。是不是很神奇?

  1. 右旋

右旋发生在左左子树的情况,
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第6张图片
右旋后
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第7张图片
是不是很简单?

现在我们看稍微复杂一点的情况。

  1. 左右旋

左右旋发生在左右子树的情况。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第8张图片
这种左右子树的情况,C是大于B的,而B是小于A的,我们要做的就是先进行一次左旋。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第9张图片
大家想想是不是没毛病,C是A的左子树里的一个结点,所以必然是小于A的,而它又是B的右子树,所以这样左旋以后还是满足BST的性质的。可以看到,这又变成了前面的左左子树的情况,那我们再进行一次右旋就好了。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第10张图片
4. 右左旋

同理可以知道,右左旋发生在右左子树的情况。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第11张图片
我们先进行右旋。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第12张图片
然后再进行左旋。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第13张图片

这时候就会有人想到,那如果要旋转的结点还带有子树怎么办呢?不怕,我们只要心里默念左小右大,对BST释放出足够的尊重,就可以想明白这个旋转方法。

  1. 带有子树的右旋

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第14张图片
右旋需要将B的右子树挂在A的左子树上面。看看三角形里我标记的注释,应该可以想来吧?

右旋后
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第15张图片
6. 带有子树的左旋

左旋就是右旋的逆操作嘛,我们直接把上面右旋后的结果拿下来。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第16张图片
刚才右旋是把中间的结点换了个父节点,接在了挪下来的A的左边,那我们要左旋,就还是把B结点拉下来,把刚才的中间这坨还是接到B的右边就好了呀。

左旋后:
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第17张图片

有没有大呼精妙?这就是AVL树的平衡操作。

你不需要特别懂得AVL到底是怎么实现的,只要把这四个旋转搞清楚,就足以应付面试了。

说了这么多AVL,我们接着来看红黑树。

在看了上面的旋转操作,你有没有发现AVL的弊端?对,就是旋转的操作很消耗时间,一次还好,但是AVL的平衡因子要求如此之严格,几乎每次挪动一个结点,都要触发旋转操作。这样树的维护成本就会很高。

近似平衡二叉树应运而生,这就是红黑树。红黑树能够确保任何一个结点的左右子树的高度相差小于两倍。 这样维护二叉树的时间更少,总体上性能就会更好一点。

红黑树具体的原理是很麻烦的,同样如果大家要具体了解的话,建议先看看2-3树是什么。我这里只列出几条硬性质,在面试的时候了解上面AVL的旋转以及红黑树的这几条性质就已经足够了。

  1. 每个结点要么是红色,要么是黑色。(废话)
  2. 根结点是黑色。
  3. 每个叶子结点(都为null)是黑色。
  4. 不能有相邻的红色结点。
  5. 从任一结点到其每个叶子节点的所有路径都包含相同数量的黑色结点。

这五条性质使得红黑树可以保证

关键性质: 从根到叶子结点的最长路径不多于最短的可能路径的两倍长

因此红黑树也是可以近乎稳定地达到 O(logn) 的时间复杂度,并且比AVL树的维护成本要小。

二、算法

关于HashMap的题其实都不难,这里我列几个比较经典的,大家可以多去练练。

首先就是力扣的第一题 #1 两数之和。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第18张图片

第一题自然是很简单的,就是求一个数组中哪两个数字之和等于给定的 target。

当然,用两个 for 循环就可以搞定,不过时间复杂度是 O(n2) 的。那怎么用 O(n) 解决这道题呢?答案就是 HashMap。在上面我们说过了它的性质就是数据是通过键值对来存储的,并且键是唯一的,那我们就可以把遍历过的数字都放到 HashMap中去,在访问前先看 HashMap中有 target-curNum,如果有,那就已经找到了,取出来就可以,那个就是第一个数。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第19张图片
第二道题就是 #146 LRU cache。

这道题我在讲解链表时讲过,要用一个双向链表 + Map 实现。第二种方法就是用我们前面提到的 LinkedHashMap,因为它就是一个双向链表 + 散列表的组合,正好对应了这道题的要求,并且我们也说了通过传入的参数可以让 LinkedHashMap 实现 LRU Cache。我直接将代码贴出。

class LRUCache extends LinkedHashMap<Integer, Integer> {
     
    private int capacity;

    public LRUCache(int capacity) {
     
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }
    
    public int get(int key) {
     
        return super.getOrDefault(key, -1);
    }
    
    public void put(int key, int value) {
     
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
     
        return this.size() > this.capacity;
    }
}

关于 HashMap 其实没什么难的,我再补充两道题大家可以做一做,就不再做讲解了。

49. 字母异位词分组

242. 有效的字母异位词

Ⅳ 源码分析

基础的部分完成,我们正式进入到 HashMap 的高频考点,Java 中 HashMap 的实现。其他几个实现类不是重点,我只大概讲一讲,我们主要还是看HashMap

一、 HashMap

一般围绕 HashMap 的设计与分析有三个主要部分,我们的源码分析也是围绕着这三个方向展开。

  • HashMap 内部实现基本点分析
  • 容量(capacity)和 负载因子(load factor)
  • 树化

① 内部结构实现

首先我们来看看 HashMap 的内部实现。

在前面介绍HashMap的时候我画了一个HashMap的内部结构图,我将图拿下来再做个参考。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第20张图片

可以看到, HashMap 就是通过 链表 + 数组 实现的,正如我前一篇文章说的,所有的高级的数据结构都是链表和数组,因为内存只有这两种分配方式,高级的数据结构往往就是通过升维来加速,现在我们来看看 HashMap 中是怎么构造 链表 + 数组的。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第21张图片
首先可以看到源码中有一个内部类 Node,它实现了Map.Entry接口,也就是一个键值对(映射)。我用黄色框框圈起来的部分就是它的成员和构造函数,可以很清晰地看到,hash 我们先不用管,K 是键,V是值,next是它自己 Node 类型的。这就很出清楚了,这就是定义了一个链表的结点嘛,通过 next 指向下一个结点,每个结点都存储着一个键值对。

那么就可以知道,我们在图中画的黄色的圆结点,就是这个Node

这是 HashMap中的内部类,我们再来找它的成员。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第22张图片
顺着 Node 找,就找到了 HashMap 中的table成员,显然这就是我们画的数组。

现在结构就清晰了,Node[] tableHashMap的哈希桶数组。那我们就可以大胆猜测,Node类中的hash成员一定就是哈希桶数组的索引。这个在后面的源码中我们再验证。

我们可以顺道再看一下HashMap的构造函数。截取一个就好。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第23张图片
有没有发现,这里居然没有初始化 table 数组,所以我再次大胆猜测,HashMap 采取的是 lazy-load 原则,在首次使用时被初始化(拷贝构造函数除外)。

好,那我们就去看看数据是怎么被 put 进去的。
在这里插入图片描述
可以看到它调用了 putVal方法,看来主要的秘密就藏在这个方法中。

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;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
     
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
     
                for (int binCount = 0; ; ++binCount) {
     
                    if ((e = p.next) == null) {
     
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

果然是个复杂的函数,不过不要紧,我们只看最关键的逻辑。我将它直接提取出来。


final V putVal(int hash, K key, V value, boolean onlyIfAbent,
               boolean evit) {
     
    Node<K,V>[] tab; Node<K,V> p; int , i;
    if ((tab = table) == null || (n = tab.length) = 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == ull)
        tab[i] = newNode(hash, key, value, nll);
    else {
     
        // ...
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first 
           treeifyBin(tab, hash);
        //  ... 
     }
}

这样就清晰很多了。这个 putVal() 方法的执行逻辑就是:

  1. (tab = table) == null || (n = tab.length) == 0
    在这里插入图片描述

如果 table(也就是 HashMap 的哈希桶数组)为 null 或者 tab.length = 0table 为空),就会执行 resize() 方法。意味着 resize()既是初始化函数,又是一个扩容的函数。

  1. (p = tab[i = (n - 1) & hash]) == null
    在这里插入图片描述

这说明是 对应的桶并不存在(从对应下标处i取出的数据为null) 就直接在 tab[i] 中插入一个 newNode(...),这个显然是一个初始化Node 的方法。就是将新的结点放到对应下标的桶中。

注意这里不要忘了table的数据结构,每个数组元素都是一个链表,所以就相当于每个数组元素都是一个桶,里面可能装着好几个结点。每个桶的编号(也就是数组的索引)计算过程也很有意思,我们放到后面说。

  1. 其他情况

说明此时 p (相当于从 i对应的桶中取出的链表的首结点) 存在。

这句话可能有点绕,大家可以再看看这张结构图。 p 就相当于我用红圈圈出来的结点。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第24张图片

这里又分成了几种情况来讨论。

		else {
     
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
     
                for (int binCount = 0; ; ++binCount) {
     
                    if ((e = p.next) == null) {
     
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

3.1 p的键 和要加入的新的元素的键值相同
在这里插入图片描述
这种情况就执行e = p,也就是把老结点 p 赋值给临时变量 e。这是要干什么,我们再往下看就知道了,在这些if的最下面,有几行代码
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第25张图片
显然就是要将新加入的元素的值 value 赋值给老结点 (指向老结点pe) 的value,也就是要覆盖老的值。因此HashMapput()方法,如果新put的方法的键已经存在,会直接将对应的值覆盖成put的值。

3.2 p是一个树结点
在这里插入图片描述
在前面我们说如果链表的结点超过一定的数量(8个),链表会变成红黑树,显然这里就是要看p有没有变成红黑树,如果变成了,直接执行红黑树的插入结点的逻辑。

3.3 其他情况

这里的其他情况,我们想想,就只剩下一种了,就是 p 是一个普通的链表首节点,我们现在要在它那条链表的末尾插入一个新的结点。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第26张图片
那我们就只需要判断两种情况,就是链表的结点数有没有到要树化的临界点。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第27张图片
如果没有到树化的临界点,就执行的是正常的链表的插入操作。

好了,通过putVal() 方法,我们已经可以大致梳理出HashMap的内部实现逻辑了,在这一块的分析中我并没有分析涉及到的其它几个重要方法,而这几个方法也是HashMap中非常巧妙的一部分,我们接着来看。

② 一些极其巧妙并且重要的方法

在看这里之前,我们首先要问自己一个问题,在一个散列表的设计中,最重要的一点是什么。

对散列表熟悉的同学可能一下子就想到了,对,就是散列冲突(也称哈希碰撞)的处理。我们知道散列表本质也是个数组,只是通过散列函数(哈希函数)计算出一个值,用这个值当作当前元素的索引,存放在数组中。

但是,这世界上并没有完美的散列函数,即使设计的再巧妙,也会有不同的元素的哈希值相同,除非你用一个容量和数据量相同或者大于数据量的数组,但是这在大规模的数据中是很不可取的,会造成空间极大的浪费。

所以我们只能设置一个差不多大小的数组,先往里放数据,做好一定会发生散列冲突的准备。

在我上面散列表的文章里我已经列出了解决散列冲突的两个方法,一个是开放寻址法,一个是链表法。我们已经看到了,HashMap就是使用的链表法。

但是,如果数据量十分大,那最后还是会出现很多结点都有一条非常长的链表的情况,这时候散列表 O(1) 的查询肯定是做不到的,因为链表的查询非常费时间。所以我们就应该在数据量达到一定程度,可能会造成有些结点的链很长,查询效率退化的时候,就应该把数组的容量扩大,这样空间更大,平均下来每个桶被分配到的结点数量就会降低,这就很好地维护了查询的效率。

那么,应该在什么时候扩充容量呢?这就是负载因子的作用了。

基础的部分就啰嗦到这里,我们直接来看源码。

这里我们先看一下HashMap的散列函数是怎么计算的。

a. 哈希桶的索引位置如何确定

我们还是从put()方法来看起。当我们向HashMap中插入数据的时候,到底这些数据是怎么分配的。在这里插入图片描述
这里put()调用putVal()方法的时候,传入了一个参数hash(key),这个key当然就是我们外界传进去的一个键。然后put()调用了hash()方法,来生成一个内部使用的哈希值,那我们接着来看这个散列函数hash()

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第28张图片
可以看到,当我们put()进去的键是 null 时,哈希值返回的是 0,否则返回:

h = key.hashCode()) ^ (h >>> 16

也就是先把键key转成ObjecthashCode()方法生成的初步哈希值,然后再和它自身的低十六位进行异或运算。

这里有个小细节,因为位运算的优先级比较低,我们惯常理解的是赋值是最低的,但是这里赋值的优先级比异或运算 ^ 要高,所以 在异或之前 h已经被赋值成了key.hashCode()。所以最终hash()方法生成的哈希值 h 就是:

key.hashCode() ^ (key.hashCode() >>> 16)

我们先接着看这个过程,然后我再回头说这个细节。

好,现在我们得到这个哈希值h了,再看回去putVal()方法。
在这里插入图片描述
我用红框圈起来的,是前面的put()方法传进去的hash,也就是我们put() 进去的键在HashMap中最终的哈希值。注意哦,putVal()hash就是之前hash()方法生成的那个 h

明确了以后,我们再来继续看。

在这里插入图片描述
这个操作在上面中我们已经分析过了,我用红框圈起的部分计算的就是元素在table中的索引,也就是桶的下标。

所以一个哈希桶的索引是怎么算的呢?我再总结一下。

第一步,取 key 的 hashCode 值。
在这里插入图片描述
这一步没什么说的,就是所有的类都有的原始的hashCode()方法,实现了一个比较初级的哈希函数。

第二步,高位运算。
在这里插入图片描述
注意,这里定义的 hint类型的,所以它二进制位长度为 32 位。那么 h >>> 16 就是右移了十六位,最后的结果就是原先的高十六位挪到了低十六位的位置。

这里是 >>>,是逻辑右移,左边补 0。

所以这里叫高位运算,最后生成的哈希值就是 初级的哈希值的高十六位与低十六位异或的结果。

第三步,取模运算。
在这里插入图片描述

一般我们想哈希函数,都会想到对数组的长度进行取模,这样得到的下标会比较均匀,也不会越界,最后一步这个 n 已经经由上一步赋值过了,就是 table的长度。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第29张图片
这里其实就是对数组长度进行一个取余,那为什么可以用 & 而不是用%符号呢,因为HashMap已经将容量大小设置好了,使得table的长度一直是2的幂次方,这里我们后面会说。而h & (x-1)在 x 是2的幂次方的时候,就相当于取余操作。

这个完整过程如下:

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第30张图片
那这里为什么要进行第二步的高位运算呢?这样可以解决数据不均匀的问题。

因为有的数字计算出的哈希值很大,和别的哈希值的差异都在高位上,而我们每次是要和数组的容量大小取模,这样的操作可以使得数组的容量还很小的时候,也能保证高低位都能参与到位运算中,这样可以进一步降低哈希碰撞的几率,提升效率。

哈希桶的索引问题我就说到这里,这部分我比较啰嗦一点,主要是怕久了以后就晕了。如果大家对位运算有什么疑惑的话,可以看我之前做的一次 社群分享,之后有时间我也会将位运算的有关题目整理出来。

b. 扩容 & 数据迁移

在我讲putVal() 方法时,一个方法出现了很多次,就是resize(),这也是一个十分重要的方法。

在看扩容之前,我们先来看看两个HashMap的成员变量。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第31张图片
其中,thresholdHashMap所能容纳的键值对的极限。loadFactor是负载因子。

这里有一个关系就是threshold = table.length * loadFactor,这很容易理解。

结合这个关系我们知道,threshold就是在这个负载因子和当前数组长度下所能允许的最大元素数目,超过threshold就需要进行扩容。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第32张图片
这里可以看到Java HashMap 的负载因子默认是 0.75,这也是经过计算机科学家们计算得到的最合理的一个负载因子大小,所以即使负载因子可以更改,也不建议大家做更改负载因子的操作。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第33张图片
这里可以看到数组的初始容量设置,就是16。这一点我在讲解ArrayList时就写过,在确定数据量的前提下,尽量初始化的时候写下容量大小,这样可以避免很多扩容带来的时间消耗。

在这里插入图片描述
HashMap 最大容量为 230

这里HashMap设计的将哈希桶数组table的大小只能为2的n次方,其实是非常规的。常规的设计比如HashTable是将桶的大小设置成一个素数的,因为相对来说素数导致散列冲突的概率要小于合数 -> 证明。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

首先来看resize()的初始化部分。

Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
     
            if (oldCap >= MAXIMUM_CAPACITY) {
     
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {
                    // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
     
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;

这里比较简单,我大概说一下逻辑。

首先先得到当前的极限值以及容量大小。
在这里插入图片描述
如果oldCap > 0 说明已经初始化过了,再调用resize() 肯定就是要扩容。

这里就要先判断当前的容量大小是不是已经到了最大容量了,也就是 230
如果是的话,那就把极限值直接设置为Integer.MAX_VALUE,这个的目的是防止再进行resize() 的调用。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第34张图片
因为在进行这个resize()调用前,都会先进行一个判断
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第35张图片
我们知道Integer.MAX_VALUE的值为 2^31 - 1,而HashMap的最大容量只有230,所以不会再有sizethreshold大了,就不会再执行扩容方法的调用,这种细节非常值得学习,因为调用方法也是要消耗堆栈空间的。

基本的逻辑就是每次扩容会将容量和极限值增加到之前的二倍。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第36张图片
接着我们来看扩容后数据迁移的处理。

		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;
                        }
                    }
                }
            }
        }
        return newTab;

首先当然是要申请一个新的以newCap为长度的数组,然后遍历老的数组,进行数据迁移。在老的数组中找到了一个有效的桶,得到它之后就把它设置为null,避免重复对一个桶的数据进行搬移。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第37张图片
这里的逻辑和putVal()一样,还是三个部分。单独一个结点,红黑树结点,以及一条链表。

单独一个结点没什么好讲,就是再用原来的hash值再与 newCap就好了。
在这里插入图片描述
是树的结点就牵扯到了红黑树的操作,这里也不深入,我们主要来看一下链表上的结点是怎么移动的,这里实在是妙不可言。

在 jdk 7 中,数据的迁移是需要重新计算 hash索引的 。
在这里插入图片描述
但是在 jdk 1.8中,实现的是2次幂的扩展,并且容量是始终都为2次幂的,那么在扩容后元素的位置要么是在原来的位置,要么是在原位置再移动2次幂的位置。因为十分巧妙的设计,所以并不需要重新计算hash

这里我们要明确一点,在一条链表上的结点,它们的hash值几乎是不一样的,因为这个哈希值的计算方法都很复杂,要重合是比较难的,它们之所以被放在了一条链表上,是因为数组大小的限制,使得不同的hash值的结点最后映射到数组上的索引是相同的,这才把它们放到了一个桶里。

所以如果数组的大小变了,一般都是要重新计算哈希值的,因为随着数组大小的变化,映射肯定也会变,原来在一条链表上的现在有可能就不在了,这个过程大家可以仔细想一想。

比如有一个长度为10的数组,哈希函数为 f(x) = |x - 10| - 1;那么元素0 和 20 最后得到的值都是 9,映射到数组上就是 index = 9的地方,0 和 20 就会被放到一起。那么当数组长度变为11的时候,它们映射的下标就变了,所以不会放在一起了。

我们可以再回过头看一下jdk 8 哈希桶的索引的计算过程。
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第38张图片
在将容量大小扩大一倍后,仅能对最后一步索引值的计算有影响,哈希值还是那个哈希值,只不过索引从 (n-1) & hash 变成了 ((n << 1)-1) & hash

我还是用上图举例子,最后的下标 5 是通过与运算得到的。此时 oldCap = n = 16
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第39张图片
那 n << 1 之后,就变成了

0001 1111
0001 0101

也就是hash的第五位由于n的扩充,而被与到了,所以最后取到的值就是 (10101)2 = 21 = 5 + 16 = 原位置 + oldCap

再想想,如果hash不是 0001 0101 而是 0000 0101,也就是 n 扩充的那一位对应的 hash 为 0,是不是下标就不变?

这就是我前面说的,在 jdk 1.8中,扩容后元素的位置要么是在原来的位置,要么是在原位置再移动2次幂的位置。 是不是非常巧妙?

比如当容量从16变成32的时候,resize()的示意图如下:

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第40张图片

我们来看一看源码中这个实现的过程。

【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第41张图片
【Java底层原理】-> HashMap & HashTable & TreeMap 详细分析_第42张图片

c. 树化

在这里插入图片描述
关于红黑树的转换,我们需要知道当链表长度大于8时就会将链表转化为红黑树处理。

我们可以简单地看一下树化的操作。我还是简化一下它的代码,提取主要逻辑。


final void treeifyBin(Node<K,V>[] tab, int hash) {
     
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
     
        //树化改造逻辑
    }
}

可以看到还有一个常量,
在这里插入图片描述
所以树化的逻辑就是,当容量小于MIN_TREEIFY_CAPACITY时,只会进行简单的改造,如果容量大于 MIN_TREEIFY_CAPACITY时,则会进行树化改造。

之所以要进行树化改造,本质上是一个安全问题。构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。

Ⅴ 总结

最后再对 HashMap 家族几个类做一个小总结。

三者均实现了Map接口,存储的内容是基于key-value的键值对映射,一个映射不能有重复的键,一个键最多只能映射一个值。

(1) 元素特性
HashTable中的key、value都不能为null;HashMap中的key、value可以为null,很显然只能有一个key为null的键值对,但是允许有多个值为null的键值对;TreeMap中当未实现 Comparator 接口时,key 不可以为null;当实现 Comparator 接口时,若未对null情况进行判断,则key不可以为null,反之亦然。

(2)顺序特性
HashTable、HashMap具有无序特性。TreeMap是利用红黑树来实现的(树中的每个节点的值,都会大于或等于它的左子树种的所有节点的值,并且小于或等于它的右子树中的所有节点的值),实现了SortMap接口,能够对保存的记录根据键进行排序。所以一般需要排序的情况下是选择TreeMap来进行,默认为升序排序方式(深度优先搜索),可自定义实现Comparator接口实现排序方式。

(3)初始化与增长方式
初始化时:HashTable在不指定容量的情况下的默认容量为11,且不要求底层数组的容量一定要为2的整数次幂;HashMap默认容量为16,且要求容量一定为2的整数次幂。
扩容时:Hashtable将容量变为原来的2倍加1;HashMap扩容将容量变为原来的2倍。

(4)线程安全性
HashTable其方法函数都是同步的(采用synchronized修饰),不会出现两个线程同时对数据进行操作的情况,因此保证了线程安全性。也正因为如此,在多线程运行环境下效率表现非常低下。因为当一个线程访问HashTable的同步方法时,其他线程也访问同步方法就会进入阻塞状态。比如当一个线程在添加数据时候,另外一个线程即使执行获取其他数据的操作也必须被阻塞,大大降低了程序的运行效率,在新版本中已被废弃,不推荐使用。
HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步(1)可以用 Collections的synchronizedMap方法;(2)使用ConcurrentHashMap类,相较于HashTable锁住的是对象整体, ConcurrentHashMap基于lock实现锁分段技术。首先将Map存放的数据分成一段一段的存储方式,然后给每一段数据分配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap不仅保证了多线程运行环境下的数据访问安全性,而且性能上有长足的提升。

(5) HashMap小总结
HashMap基于哈希思想,实现对数据的读写。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的链表中,可通过键对象的equals()方法用来找到键值对。如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),链表就会被改造为树形结构。

你可能感兴趣的:(Java核心原理,数据结构,java,HashMap,AVL树,链表)