HashMap底层实现原理(源码分析)

基础

1、数组

内存连续,元素类型相同,长度确定

数组对象本身存储在堆中

优点:

按照索引查询元素速度快

能根据下标随机访问元素

缺点:

根据元素内容查找元素速度慢

插入和删除速度慢

数组的大小一经确定不能改变,不适合动态存储;

内存空间连续,在分配内存的时候需要分配一块连续的空间,所以数组不能定义的太大

2、链表

内存空间不连续,没有索引,查找只能从头遍历查找,每一个元素都包含了下一个元素的内存地址,增加删除元素只用改变相邻元素的地址指针

优点:

插入和删除元素速度快

节点个数可以按照需要进行增删,存储空间动态变化

内存利用率高,不会浪费内存

缺点:

不能随机访问元素,从第一个开始遍历,查找效率低

3、数组和链表区别

  1. 数组元素个数固定 , 链表节点个数不固定
  2. 数组内存在创建数组的时候定义 , 链表内存可以动态向系统申请
  3. 数组中元素顺序由下标决定 , 链表节点顺序由节点包含的指针来决定
  4. 数组有索引,查询方便,增删效率低,涉及元素的整体移动
  5. 链表没有索引,查询不方便,只能从头遍历,增删效率高,只用修改相邻节点的指针

4、散列表(哈希表)

又叫哈希表

基础是数组 , 数组元素中保存的数据是一串链表

5、哈希

​ 核心理论: Hash也称散列、哈希,对应的英文都是Hash,基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出。将一个大数据转化成小数据,看可能会产生hash冲突。

​ 这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值。

Hash特点:

1.从hash值不可以反向推导出原始的数据
2.输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值
3.哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
4.hash算法的冲突概率要小

可能会出现不同数据的Hash值相同的情况

HashMap原理

1、HashMap继承体系

img
最重要的是Map接口。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

HashMap继承自AbstractMap抽象类

AbstractMap实现Map接口

2、Node数组数据结构分析

在HashMap类中有一个静态内部类Node,它继承了Map.Entry接口。Map.Entry接口中写了getKey、getValue、setValue方法。

添加进map中的数据都会封装成一个Node元素,然后再存到散列表中。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//存储key计算后的的Hash值
        final K key;   //key
        V value;       //value
        Node<K,V> next;//Hash碰撞后存储在同一位置的链表结构

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

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

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

3、底层存储结构分析

img

底层结构其实就是数组+链表+红黑树

第一次初始化的时候Node数组的长度是16

当发生冲突之后,会在数组中形成链表

链表长度超过8 达到9并且数组中所有元素大于64的时候,链表结构会升级为红黑树

4、put数据原理分析

在这里插入图片描述
比如我们要使用put方法添加一个(“暴躁”,“小刘”)的key / value 数据。

  • 获取key值“暴躁”字符串的hash值
  • 经过hash值扰动函数 , 使hash’值更加散列
  • 构造出Node对象,key = “暴躁” , value = “小刘”
  • 经过路由算法, 计算出这个node元素应该存放在数组哪个位置。

路由算法: (数组长度 -1 ) & 刚刚计算的key的hash值

源码分析

1.HashMap核心属性分析(threshold, loadFactory, size, modCount)

在HashMap.java文件中有
基本常量

//设置常量作为数组的初始大小 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//缺省的负载因子大小
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//转化为红黑树的链表长度阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//转化为红黑树的数组长度阈值
static final int MIN_TREEIFY_CAPACITY = 64;
//存放数据的Node数组
static class Node<K,V> implements Map.Entry<K,V> {
}

HashMap中的重要参数

//用Node数组构建的散列表
transient Node<K,V>[] table;

//表示当前HashMap包含的键值对数量
transient int size:

//表示当前HashMap结构修改次数(插入或者删除元素)
transient int modCount:

//扩容阈值
//表示当前HashMap能够承受的最多的键值对数量,一旦超过这个数量HashMap就会进行扩容
int threshold:

//负载因子,用于计算扩容阈值。扩容阈值=数组长度*负载因子
final float loadFactor:

⒉.构造方法分析

做校验:数组长度必须大于0, 小于规定的最大长度 , 并且 负载因子 必须 大于0

// initialCapacity 数组长度    loadFactor  负载因子
    public HashMap(int initialCapacity, float loadFactor) {
    //数组长度<0
        if (initialCapacity < 0)
        //返回异常
            throw new IllegalArgumentException("Illegal initial capacity: " +   initialCapacity);
            //大于设定的最大长度30
        if (initialCapacity > MAXIMUM_CAPACITY)
        //从新设置长度
            initialCapacity = MAXIMUM_CAPACITY;
            负载因子不能<0 , 不能不是数字
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +  loadFactor);
        //赋值操作
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

3.HashMap put方法执行流程

  • 在使用默认构造器去初始化一个HashMap对象时,在第一次put键值对的时候会计算key的hash值,根据计算得到的hash值来确定存储的位置。

  • 紧接着调用了putVal方法,在刚刚初始化之后的table值为null(延迟初始化)因此程序会进入到resize()方法中。而resize方法就是用来进行扩容的(稍后提到)。扩容后得到了一个table的节点(Node)数组,接着根据传入的hash值去获得一个对应节点p并去判断是否为空,是的话就存入一个新的节点(Node)。反之如果当前存放的位置已经有值了就会进入到else中去。接着根据前面得到的节点p的hash值以及key跟传入的hash值以及参数进行比较,如果一样则替覆盖。如果存在Hash碰撞就会以链表的形式保存,把当前传进来的参数生成一个新的节点保存在链表的尾部(JDK1.7保存在首部)。而如果链表的长度大于8那么就会以红黑树的形式进行保存。

延迟初始化:
散列表是占用内存空间的, 有的时候我们创建了, 但是并不一定使用,. 如果这个时候就初始化, 就用占用内存空间, 所以要延迟到使用的时候再初始化。

要执行替换操作的情况
1、当前table中存放的key与传入的key一样
2、在当前链表中找到了一个一样的key

1、在put方法内部调用了一个putVal方法,里面有一个hash扰动函数

    // 第一次添加键值对,先调用hash方法来确定存储的位置
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

2、hash扰动函数,
作用:i让lkey的hash值的高16位也参与路由运算

    // 作用:确定键值对存储的位置
    static final int hash(Object key) {
        int h;
        //key = null  , hash = 0
        //
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

3、put底层调用了putVal()方法


   final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
   
   //tab: 引用当前hashMap的散列表'
   //p: 表示当前散列表的元素
   //n: 表示散列表数组的长度
   //i: 表示路由寻址结果
       Node<K,V>[] tab; Node<K,V> p; int n, i;
       
       // 由于刚开始是table值是null,程序会进入到resize()方法中,resize()方法就是用来扩容的。
       if ((tab = table) == null || (n = tab.length) == 0)
           // 创建一个table节点的数组
           n = (tab = resize()).length;
           
       // 根据hash值来确认存放的位置。如果当前位置是空直接添加到table中
       if ((p = tab[i = (n - 1) & hash]) == null)
           tab[i] = newNode(hash, key, value, null);
           
       else {
       // 根据前面得到的节点p的hash值以及key跟传入的hash值以及参数进行比较,如果一样则替覆盖。
       
           //e: 不为null的话,找到了一个与当前要插入的key-value一致的key,要执行替换操作
           //k: 表示临时的一个key
           Node<K,V> e; K k;
           
           //确认当前table中存放键值对的Key是否跟要传入的键值对key一致,
           //一致的话,后面就要进行替换操作
           if (p.hash == hash &&
               ((k = p.key) == key || (key != null && key.equals(k))))
               //一样就把p赋值给e
               e = p;
               
           // 如果key不一样,并且是红黑树
           else if (p instanceof TreeNode)
               e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
               
           else {
           // 如果hash一样的两个不同的key,就会以链表的形式存在
           //迭代链表
               for (int binCount = 0; ; ++binCount) {
               
               //如果将p的下一个节点赋值给e,并且下一个节点是null,
               //说明整个链表都没有和它重复的
                   if ((e = p.next) == null) {
                   //将这个新元素添加到链表末尾
                       p.next = newNode(hash, key, value, null);
                       
                       // 判断链表长度大于8
                       if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                       //转化为红黑树
                           treeifyBin(tab, hash);
                       break;
                   }
                   
                   //如果找到一个,hash一样、key一样、key!=null
                   //说明找到了一个一样的元素,就要执行替换操作
                   if (e.hash == hash &&
                       ((k = e.key) == key || (key != null && key.equals(k))))
                       break;
                   p = e;
               }
           }
           
           //e!=null , 说明找到了一个key一样的元素,要执行替换操作
           if (e != null) { 
               V oldValue = e.value;
               if (!onlyIfAbsent || oldValue == null)
                   e.value = value;
               afterNodeAccess(e);
               return oldValue;
           }
       }
       
       //散列表修改次数+1
       ++modCount;
       
       //插入新元素,size+1
       //如果当前HashMap的容量超过threshold则进行扩容
       if (++size > threshold)
           resize();
           
       afterNodeInsertion(evict);
       return null;
   }
put方法总结:
  • 在put方法内部调用了一个putVal方法,里面有一个hash扰动函数

  • 在putVal方法中if 判断数组table是否为空或者长度为0,是就执行resize()方法,进行初始化扩容

  • 根据键值key计算hash值得到插入的数组索引下标 i ,然后将得到的节点赋值给p ,如果当前节点是null ,直接新建节点添加即可,不是空就进入else判断

  • 设置一个临时的Node节点e,

  • 根据前面得到的节点p的hash值以及key与传入的hash值以及参数进行比较,如果一样就把得到的节点p赋值给临时节点e 。后续执行替换。

  • 如果key不一样,并且是红黑树,则直接插入键值对。

  • 如果hash冲突,就会以链表的形式存在。遍历链表 ,

  • p的下一个节点是null, 说明已经遍历到最后了,都没有找到一样的,就将这个新元素添加到链表末尾。判断链表长度大于8,就转化成红黑树。(JDK1.8之前是添加在链表开始)

  • 如果找到一个,hash一样、key一样、key!=null , 说明找到了一个一样的元素,就要执行替换操作。

  • 散列表修改次数+1 ,数组长度size +1 , 如果size超过阈值则进行扩容
    在这里插入图片描述

4.HashMap tesize扩容方法分析!!!!!!!

什么时候进行扩容:

当put方法执行的时候,如果table为空,则执行resize();方法扩容。默认长度为16;

        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

当table中存储值的个数大于等于threshold的时候,进行扩容。容量为原来的2倍。

   if (++size > threshold)
            resize();

为什么要扩容
让添加的数据特别多的时候,就会形成很长的树或链表,查找性能就会降低。所以需要对数组扩容,扩容后里面的数据就会分散,以空间换时间。

源码:

    final Node<K,V>[] resize() {
        //oldTab  : 引用扩容之前的hash表
        Node<K,V>[] oldTab = table;
        // oldCap : 扩容之前的数组长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //oldThr  : 扩容之前的扩容阈值
        int oldThr = threshold;
        //newCap ; 扩容之后的数组长度
        //newThr : 扩容之后的新的阈值
        int newCap, newThr = 0;
        
        //开始计算 newCap, newThr 的值  
        //大于0 , 说明散列表已经初始化过了,下面要进行扩容操作
        if (oldCap > 0) {
            //如果数组长度已经大于设定的最大长度,就不能扩容了,
            //设置阈值为Integer的最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
                //把阈值改成Integer的最大值
                threshold = Integer.MAX_VALUE;
                //然后把表返回
                return oldTab;
            }
            //将原数组长度左移一位,也就是翻倍,赋给新的数组长度
            //如果新数组长度 小于 最大限制 , 并且老数组长度大于 16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //阈值翻倍     
                newThr = oldThr << 1; 
        }

        //oldCap = 0 ,还没有初始化。
        //如果老阈值大于0
        else if (oldThr > 0) 
            //把老阈值 赋给 新数组长度
            newCap = oldThr;
            
        //oldCap = 0,oldThr = 0  ,说明要初始化,配置默认值
        else {              
            newCap = DEFAULT_INITIAL_CAPACITY; //16
            // 新的阈值 12
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 
        }
        //新阈值 = 0
        if (newThr == 0) {
            // 阈值 = 新数组长度 *  负载因子
            float ft = (float)newCap * loadFactor;
            
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //结束计算 newCap, newThr 的值
        
        // 计算出新的数组长度后赋给当前成员变量table
        @SuppressWarnings({"rawtypes","unchecked"})
        //新建hash桶数组
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;//将新数组的值复制给旧的hash桶数组

        // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,
        //否则进入扩容元素重排逻辑,使其均匀的分散
        if (oldTab != null) {
            // 遍历新数组的所有桶下标
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e; //设置临时Node节点
                // 旧数组的桶节点赋给临时节点e,
                if ((e = oldTab[j]) != null) {
                    //并且解除旧数组中的引用,否则就数组无法被GC回收
                    oldTab[j] = null;
                    
                    // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                    if (e.next == null)
                        // 用同样的hash映射算法把该元素加入新的数组
                        newTab[e.hash & (newCap - 1)] = e;
                        
                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是链表的头并且e.next!=null,那么处理链表中元素重排
                else { // preserve order
                    // loHead,loTail 代表扩容后不用变换下标,见注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表扩容后变换下标,见注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
                                // 代表下标保持不变的链表的头元素
                                loHead = e;
                            else                                
                                // loTail.next指向当前e
                                loTail.next = e;
                            // loTail指向当前的元素e
                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
总结:
  • 在HashMap刚创建的时候,不会初始化,在第一次put的时候才会用resize方法进行初始化,也就是第一次扩容。容量为16、阈值为12
  • 在resize方法中会先定义一些变量:扩容之前的hash表、扩容之前数组长度、扩容之前阈值、新的数组长度、新的阈值
  • 开始计算 newCap, newThr 的值 ,
    如果扩容前数组长度已经大于设定的最大长度,就不能扩容了,设置阈值为Integer的最大值。
    如果没有达到最大长度,就将原数组长度左移一位,也就是翻倍,赋给新的数组长度。阈值也进行移位翻倍。
    如果扩容前数组长度=0 ,说明还没有初始化,那么就进行赋值操作,新数组长度为16 , 阈值为12。
    如果新阈值为0,那么就等于数组长度 * 负载因子
    newCap, newThr 的值计算结束
  • 然后根据扩容后数组长度新建一个Node节点数组
  • 如果不是初始化操作,还要进行扩容元素重排逻辑,使其均匀的分散。
  • 重排逻辑待续。。。。。。

HashMap为什么会在1.8中使用红黑树

  • 当我们往hash表中添加一个对象时,会调用对象的hash code方法,根据hash算法算出对应的数组的索引值,再根据索引值查找数组,数组中是否存在对象,如果不存在对象直接存进去。
  • 如果存在对象,则通过equals比较两个对象的key值是否相等,如果相等则覆盖value值。
  • 如果不相等则形成链表结构,当链表中的元素越来越多时,由于链表的增删效率比较高,但是查询效率比较低,因此当链表达到一定长度之后,就会将他转换为红黑树,用来提高查询效率。
  • 什么是红黑树
    • 红黑树是一个自平衡的二叉查找树,查找的效率非常高
    • 节点是红色或者黑色
    • 根节点是黑色
    • 每个叶节点也是黑色的。
    • 每个红色节点的两个子节点都是黑色
    • 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
  • 为什么要选择红黑树?
    • 红黑树是一个自平衡的二叉查找树,查找的效率非常高
  • 为什么不一下子将整个链表转换为红黑树
    • 构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。
    • HashMap频繁的扩容,会造成底部红黑树不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。

HashMap的key常用String类型

  • hashCode方法的一个重要因素就是同一个对象调用hashCode()方法应该产生相同的值;
  • String对象的底层是一个final修饰的char类型的数组,内部已重写了equals()、hashCode等方法,不容易出现Hash值计算错误的情况;。
  • 非String类型的对象在获得的value时需要首先保障散列码相同,并且经过equals()方法判断为true时才可以获得对象的value。
  • 设计 hashCode() 时最重要的因素就是对同一个对象调用 hashCode() 都应该产生相同的值。String 类型的对象对这个条件有着很好的支持,因为 String 对象的 hashCode() 值是根据 String 对象的内容计算的,并不是根据对象的地址计算
  • 如果你想把自定义的对象作为 key,那也是可以的,你只需要重写 hashCode() 方法与 equals() 方法即可,因为每创建一个对象,内存地址都是不一样的。。

为什么HashMap中key只能是引用类型,不能为基本类型

  • HashMap存储数据的特点是无序,无索引不能存储重复的元素,因此没存储一个对象都会调用其hashCode方法计算出hash值,如果相同就拒绝存储,如果不同还会调用equals方法进程比较,如果返回true,也会拒绝存储,
  • 不能为基本类型的原因也正是因为基本类型中是没有hashCode和equals方法的。无法进行比较

HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。

这个算法应该如何设计呢

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。采用2的幂次方,可以进行移位操作

那为什么是两次扰动呢

答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;

HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

答:hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值