源码解析|第一次有人把HashMap说的这么清楚~

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 源码解析|第一次有人把HashMap说的这么清楚~

背景

促使自己开始研究源码的原因主要有两个,第一个是在面试高级工程师的时候,源码这块是必问的,第二个原因是现在框架是越来越多,也没有太多精力去学习,于是就准备开始研究各种底层知识,看看那些底层大佬们是如何写代码,这是踏出的第一步,后面会有越来越多的源码学习经验和大家一起分享,希望大家能够提出宝贵的意见。话不多说,直接进入我们今天的主题

开发环境

开发工具 JDK版本
IDEA 2020 JDK1.8

抛砖引玉

首先给大家呈上几道经典的有关hashmap 1.8的面试题?

  1. HashMap的初始容量为什么是2的幂次方?
  2. HashMap在什么时候会进行扩容?
  3. HashMap是如何进行扩容的?
  4. HashMap底层数据结构?
  5. HashMap1.8为什么引入红黑树?
  6. HashMap什么时候会将链表转换成红黑树?
  7. HashMap在多线程情况下会出现什么?
  8. 能说说HashMap的hash算法么?
  9. HashMap是如何定位到key所在数组上的位置的?

先说这么多吧,相信大家应该都会被问过这些问题,会不会很惊讶,就一个hashmap都能整出这么多面试问题?接下来我会通过本篇文章带着大家一起解读hashmap的这些骚操作,大家看完之后,上面的这些面试题都会知道该如何解答了,我们开始吧~

在讲代码之前我想先和大家说下hashmap里面的一个数据结构

  1. 首先hashmap底层是一个数据结构,为什么要用数组呢,因为他查找非常的快,于是刚开始他长这样,他的初始长度是16
    在这里插入图片描述
  2. 然后我插入一个key,他是怎么计算到自己的位置的呢,通过计算他的hash码,得到一个整数,然后和16取模,就能够将数据散列到0-15的位置了啊,但是jdk会用一个更加牛逼的方法去算出这个位置,后面我会说到的,看完之后,你会觉得算法真香。
  3. 当有越来越多的数据存进来之后,发现我的那个位置被人占用了,那可咋办呢,我又不能覆盖它吧,然后就有了链表这个新的成员加入,先看下链表和数组的结合
    在这里插入图片描述
  4. 也就是说当位置相同的时候,所有的数据都会以链表的形式在那个位置一直往下接,就形成了上面这样的形式
  5. 加入链表之后,我们的数据存储问题是解决了,但是当这个链表越来越长的时候,我们找起来就费劲了,我们知道链表结构增加和删除是很快的,但是查找的复杂度就是o(n)了,得挨个遍历。所以有必要引入新的成员了,红黑树
  6. 红黑树是平衡二叉树的一种实现方式,数据结构这块后续会有相关的文章进行讲解,那我们就来看下引入红黑树之后,是怎样一个组合呢
    在这里插入图片描述

好的,hashmap结构这块我已经和大家大概的讲完了,下面就让我们一步一步分析代码,来解决我们心中的疑惑吧!

代码示例

一个简单地main方法,然后跟着这个方法,我们调到map的世界里面去

public class Main {
    public static void main(String[] args) {
    // write your code here
        Map<String,String> map = new HashMap<>(27);
        map.put("name","乐哉开讲");
    }
}

我们先进入到new HashMap,看看构造器都给我们做了什么

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

可以看到他又调用了另外一个构造方法,并且又调用了另外一个构造方法
DEFAULT_LOAD_FACTOR 这个就是一个负载因子0.75

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

我们来分下这段代码,

  1. 判断我们设置的初始容量是否合法
  2. 判断初始容量是否大于最大容量 MAXIMUM_CAPACITY = 1 << 30;一般都不会大于这个最大值得,如果大于,就用这个最大值作为初始容量
  3. 判断负载因子是否合法,后面讲扩容得地方的时候再说这个负载因子是用来干嘛的
  4. tableSizeFor 是为了计算出 大于等于这个初始容量的最小二次幂,如 15 的最小二次幂为16 7的最小二次幂是8,看下具体是如何实现的,有兴趣的话可以跑下这段代码,看看是不是这样的,所以最终map的初始容量都是2的幂次方,并不一定是我们设置的数值
    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;
    }

构造器分析完之后,我们在来到开始的地方,执行put方法,我们点进去看下,这里面是我们这次讲的核心的地方,大家认真看下

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

我们看到put方法是去调用putVal方法去执行put逻辑的,先不跟进去看,我们会看到,这里会将key做一个hash运算,看看上面的面试题8,是不是也说到这个了,我们就点进去看下,这个hash他做了什么?

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

大家会不会很失望,这里面只有三行代码,能做什么呢?虽然只有只有三行代码,但是这里减小了hash碰撞的机会,什么叫hash碰撞呢,就是我们刚开始的时候提到的有些数据得到了相同的下标,然后会以链表的形式存储,会导致链表过长,这里就是为了让hash的更加均匀,而采取的一些手段,我们来分析下代码

  1. key如果为空的话,直接返回hash为0
  2. key进行hashcode的话,会得到一串整数,
  3. 我们知道整形是占用四个字节,占用32个bit,我们将前16个作为高位,后16个作为低位,然后将32个bit右移16,是不是就能得到高16位的值,然后再讲高位和低位进行疑惑,得到一个新的二进制,为什么这么做呢,因为这样能够在计算元素下标的时候,能够让hash的高位和低位都能参与进行来,减少碰撞的概率
  4. 返回新的hashcode
    hashcode计算完之后,我们再回到上一个方法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;
    }

数组初始化

这个方法是很长的,大家需要认真的慢慢的看完,我们从第一行开始进行解析

  1. 首先定义一个Node节点,和一个Node数组
  2. 接着判断table是否为空或者数组长度是否为0 ,这个table是什么呢,这个table就是存放你创建过得Node数组,如果你第一次put操作的话,这个就是空的,第二次进来就是有内容的

7.假如我们是第一次进来,他会给我们进行resize操作,也就是初始化一个长度的Node数组,我们点进去看下,他都做了什么?顺便说下,当数组进行扩容的时候也会进入到这个resize方法

    final Node<K,V>[] 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;
        @SuppressWarnings({"rawtypes","unchecked"})
            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;
    }

这个代码写的真长。。。,不过没关系,我们今天就是来一探究竟的,我们一步一步往下看

  1. 首先把resize之前的Node数组复制给oldTab,保存旧的数组长度和旧的阈值(负载因子*数组长度)
  2. 接着判断旧的数组容量是否是大于0(扩容的话会进入这个判断条件),如果是大于0的话就判断是否大于最大的容量MAXIMUM_CAPACITY,如果大于的话,会给阈值赋值为这个最大容量,返回oldtab,如果旧数组的容量没有超过这个最大容量,则进行两倍扩容,阈值也会进行扩容
  3. 如果旧的阈值大于0,则将旧的阈值作为新的数组的大小,这一步我理解的是第一次构造map的时候不是设置了一个初始容量,然后转换为了一个二次幂,这里就是用那个值来初始化一个Node数组

10.如果旧的阈值等于0的话,那就会使用map默认的初始容量16和负载因子0.75来计算数组的容量和阈值大小,

  1. 为什么最后又要判断下newThr == 0呢,因为这个如果为的话,肯定是第一次初始化数组的时候,他这个阈值是没有计算的,所以需要重新计算下。
  2. 到目前为止,新的数组已经扩展或初始化好了
  3. 再次判断if (oldTab != null) ,如果旧的数组不为空的话,说明就是扩容,如果为空则可以直接返回这个数组了,不为空的话,则需要进行扩容后的数据迁移的工作了
    数据迁移
  4. 遍历所有旧数组中的元素
  5. 判断当前元素是否为空,不为空才进行数据迁移
  6. 接下来会有三个判断,作用分别为 判断是否是单个节点,判断是否是红黑树节点,判断是否是链表
  7. 首先判断如果是单个节点,则 通过e的hash值和新的容量-1进行与运算,会得到这个元素在新数组中的索引位置,还记得我们前面说过 jdk用了一个比较厉害的定位元素位置的方法么,这里就是他的实现过程

    假如我们有一个数值,我们想让他在0-15中间进行散列,我们想到使用模运算 %16,这里给大家介绍另外一种方法,假如是 19 ,16取模之后,会是3,如果将19& (16-1),计算之后也是3后者效率会更高的,所以jdk采用后面这种方法,更加高效。大家会不会跟困惑这是为啥呢,我在这位大家简单地介绍下:
    
首先我们知道,与运算的话必须是全部为1则为1,如果要达到这样的效果的话,这个数值必须是2的n次方-1,肯定是所有bit为都为1,这也就是为什么map要求数组容量必须是2的幂次方了。
接下来我们拿到1111这样的数值之后和我们的hash进行运算 

11111000110011  &  1111  这样运算之后得到的是3

111111111111111 & 1111 这样运算之后得到的是15 永远也不会超过15,大家这下应该知道这个原> 理了吧

  1. 再接着判断如果是红黑树的话,则进行红黑树的相关操作
  2. 最后再判断如果是链表的话,则进行遍历迁移
  3. 这里一个主要的操作是 给索引相同的元素进行均分
  4. 将元素的hash和老的数组长度进行与操作,如果为,说明他的高位为1,与新的数组长度进行与之后,还是原来的结果,如果不为0,则可以直接将索引下标加上旧的数组长度,然后将节点引到新的数组对应的索引下面,这里大家有可能很懵,说这么多到底是什么意思呢,我以画图的形式和大家说下吧
    在这里插入图片描述

我们看到上面会有两个table,分别是扩容前的数组和扩容后的数组

  1. oldTab 数组上的第七个索引上,元素的hash分别为7和15,7 & (8-1)和 15 & (8-1) 都得到的是
    7,所以存放到了7上面,
  2. 现在进行扩容,数组长度扩大为16,这时候如果直接拿两个hash值和新的数组长度进行与运算的话,会得到7和15两个位置,这样链表就会被这两个位置均分掉
  3. 但是我们看代码,jdk并没有这么做,他先判断hash和原先的数组长度进行与操作,之前一直是和数组长度减1做与操作,如果结果为0,说明他在新的数组上面索引的位置还是和当前一样,则直接把数据放到新数组上,如果不为0 ,则只需要把当前索引位置加上旧的数组长度即可,因为数组扩容长旧数组两倍的,

讲了这么多,数组初始化这块讲完了
我们再回到前面的代码

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

第一个判断处理完之后,我们继续往下看,

  1. p = tab[i = (n - 1) & hash]) == null 判断如果数组所在的索引位置上的数据如果为空,则直接new一个新的Node直接放在元素上即可
  2. 如果不为空,则继续往下走
  3. 如果当前节点不为空,并且它的key值和我们传入的key是相等的,则直接取出这个节点,直接在这个节点上进行操作,后面再说
  4. 如果节点是红黑树节点,则进行红黑树相关的操作
  5. 如果上面条件都不满足的,则说明是一个链表结构,
  6. 遍历链表里面的元素
  7. 如果在链表里面找到了key值相同的节点,则直接取出这个节点,不再遍历
  8. 如果已经遍历到链表的最后一个节点都还没有拿到的话,则需要创建新的节点
  9. 这里有两种情况,通过binCount进行判断,这个变量用来干嘛的呢,我们会看到我们每次进行链表节点的时候都会把这个进行自增,其实也就是记录这个链表的长度
  10. 如果比较发现 链表的长度已经大于 map中定义的TREEIFY_THRESHOLD - 1的话,也就是7,就会将链表转换为红黑树,将数据存到红黑树中,这里为什么要减掉1呢,其实这块也是面试官必问的,也就是我刚开始提到的一个面试题:什么时候链表会转换成红黑树?map里面定义的是8,这里减了1.是因为在我们进行遍历链表之前,我们已经取出来了数组上面的第一个链表元素了,后面的遍历是基于这个元素的next进行遍历的,所以这里就需要将TREEIFY_THRESHOLD -1作为转换条件判断。
  11. 最后我们看下这段代码
if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }

这个e也就是我们上面取出来的元素,如果判断不为空的话,则说明map中已经存在了这个元素,则只需要给他赋上新的值就好了,然后将旧值返回回去,
最后将数组长度加一,如果是更新操作,则不会走到这一步,加一之后如果发现当前的数组中元素的长度如果大于阈值则进行扩容操作。

到这里终于把map中最重要的put操作讲完了,get和remove操作大家可以按照这个思路自己去看下咯,刚开始的面试题也在文章里都有讲解到,还有一个多线程情况下,map会出现什么问题,这个后续再说了,本文篇幅有点长。。。,文中有讲的不准确的地方,希望各位大佬指正

相关文章
|
22天前
|
Java
让星星⭐月亮告诉你,HashMap中保证红黑树根节点一定是对应链表头节点moveRootToFront()方法源码解读
当红黑树的根节点不是其对应链表的头节点时,通过调整指针的方式将其移动至链表头部。具体步骤包括:从链表中移除根节点,更新根节点及其前后节点的指针,确保根节点成为新的头节点,并保持链表结构的完整性。此过程在Java的`HashMap$TreeNode.moveRootToFront()`方法中实现,确保了高效的数据访问与管理。
25 2
|
4天前
|
消息中间件 缓存 安全
Future与FutureTask源码解析,接口阻塞问题及解决方案
【11月更文挑战第5天】在Java开发中,多线程编程是提高系统并发性能和资源利用率的重要手段。然而,多线程编程也带来了诸如线程安全、死锁、接口阻塞等一系列复杂问题。本文将深度剖析多线程优化技巧、Future与FutureTask的源码、接口阻塞问题及解决方案,并通过具体业务场景和Java代码示例进行实战演示。
21 3
|
12天前
|
存储 Java API
详细解析HashMap、TreeMap、LinkedHashMap等实现类,帮助您更好地理解和应用Java Map。
【10月更文挑战第19天】深入剖析Java Map:不仅是高效存储键值对的数据结构,更是展现设计艺术的典范。本文从基本概念、设计艺术和使用技巧三个方面,详细解析HashMap、TreeMap、LinkedHashMap等实现类,帮助您更好地理解和应用Java Map。
31 3
|
22天前
|
存储
让星星⭐月亮告诉你,HashMap的put方法源码解析及其中两种会触发扩容的场景(足够详尽,有问题欢迎指正~)
`HashMap`的`put`方法通过调用`putVal`实现,主要涉及两个场景下的扩容操作:1. 初始化时,链表数组的初始容量设为16,阈值设为12;2. 当存储的元素个数超过阈值时,链表数组的容量和阈值均翻倍。`putVal`方法处理键值对的插入,包括链表和红黑树的转换,确保高效的数据存取。
48 5
|
22天前
|
算法 索引
让星星⭐月亮告诉你,HashMap的resize()即扩容方法源码解读(已重新完善,如有不足之处,欢迎指正~)
`HashMap`的`resize()`方法主要用于数组扩容,包括初始化或加倍数组容量。该方法首先计算新的数组容量和扩容阈值,然后创建新数组。接着,旧数组中的数据根据`(e.hash & oldCap)`是否等于0被重新分配到新数组中,分为低位区和高位区两个链表,确保数据迁移时的正确性和高效性。
55 3
|
22天前
|
存储 Java
HashMap之链表转红黑树(树化 )-treefyBin方法源码解读(所有涉及到的方法均有详细解读,欢迎指正)
本文详细解析了Java HashMap中链表转红黑树的机制,包括树化条件(链表长度达8且数组长度≥64)及转换流程,确保高效处理大量数据。
58 1
|
22天前
|
Java 索引
让星星⭐月亮告诉你,HashMap之往红黑树添加元素-putTreeVal方法源码解读
本文详细解析了Java `HashMap` 中 `putTreeVal` 方法的源码,该方法用于在红黑树中添加元素。当数组索引位置已存在红黑树类型的元素时,会调用此方法。具体步骤包括:从根节点开始遍历红黑树,找到合适位置插入新元素,调整节点指针,保持红黑树平衡,并确保根节点是链表头节点。通过源码解析,帮助读者深入理解 `HashMap` 的内部实现机制。
30 2
|
24天前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
49 0
|
22天前
|
Java 索引
让星星⭐月亮告诉你,HashMap中红黑树TreeNode的split方法源码解读
本文详细解析了Java中`HashMap`的`TreeNode`类的`split`方法,该方法主要用于在`HashMap`扩容时将红黑树节点从旧数组迁移到新数组,并根据`(e.hash & oldCap)`的结果将节点分为低位和高位两个子树。低位子树如果元素数少于等于6,则进行去树化操作;若多于6且高位子树非空,则进行树化操作,确保数据结构的高效性。文中还介绍了`untreeify`和`replacementNode`方法,分别用于将红黑树节点转换为普通链表节点。
19 2
|
6月前
|
存储 安全 Java
HashMap源码全面解析
HashMap源码全面解析

热门文章

最新文章

推荐镜像

更多