HashMap保姆级理解解析

HashMap

含义

  • hash就是把任意的内容(视频、图片、文字等)转换成一个特殊的值,不管原内容多大,转换后的结果都是同一个格式(如果是数字的话,那么它的格式就是长度都一样,不管是图片、视频等都会转换成同一个长度的数字)的值,这个值可以是字符串,可以是数字,具体取决于调用的算法

  • 举例:

    • 想要知道同学们的英语水平如何,英语水平这是一个抽象的概念,那么想要了解具体怎样,用考试的方法最方便,通过考试可以得到每个人的分数,分数就是这个人的英语水平的具体体现。那么分数是固定的0~100之间的一个值,也就是含义中所说的那个格式

    • 英语水平就是我们传进去的变量,而考试的打分规则就是hash算法,这个规则可以不同,常见的有MD5,sha256等,经过打分规则得到的分数就是变量的哈希值

特点

  • 任意长度的输入,得到固定长度的输出

    • 每个人的英语水平经过考试都会得到一个固定的0~100的分数
  • 不可逆,可以把原文计算成密文(哈希值),但是不能倒推回去

    • 比如“我爱你”计算后为“aaa",但是你无法通过”aaa"推导出“我爱你”
  • 算法不固定,只要满足hash的思想就是hash算法

    • 常见的摘要算法有md5、sha256

作用

  • 用来快速检索

    • 比如在一万篇文章中找出和我手上的文章一模一样的一篇,一个字一个字读太慢了,将这片文章经过hash算法转换成哈希值,拿哈希值进行比对就会快很多
  • 防篡改

    • 密码学的主要途径,只能加密不能解密,由于哈希值不可逆的特点,得到文件经过加密后的哈希值是不能推导出原文件的,所以发送数据时将原文和密文一起发送给对方,对方收到后再次对原文进行加密得到密文,如果密文与收到的一样就代表没有被改过
  • 密码保存

    • 保存的密码经过加密保存到数据库,工作人员看不到
  • 快速查找

    • 哈希表是比数组查询速度更快的结构,在使用数组进行查找时,需要按顺序一个一个从头开始找,这种查找方式叫做线性查找,但是当数据量非常多的时候,线性查找是非常消耗时间的,所以用数组来存储元素并不合适
    • 哈希表
      • 存储元素时,传进的元素经过哈希算法计算得到哈希值,将得到哈希值除以数组的长度(叫mod运算后得到索引,将元素存放进该索引的位置,如果该索引的位置已经有了元素(又叫哈希冲突),那么就通过链表的形式,在原有元素的后面进行存储
      • 查询元素时,再次将要查询的元素经过同样的哈希算法得到哈希值,对哈希值进行Mod运算得到索引,直接找到索引的位置,拿到该元素。这样就不用像数组一样按顺序一个一个去比对的寻找,拿到索引直接找到该元素。若该位置存放了链表,再通过equals方法进行比对
      • 哈希表与数组两者查询速度的差值,与数据量成正比。他俩的比值与数据量成反比

hashcode

含义:

  • JAVA中的hashCode()方法就是根据一定的规则将与对象相关的信息(如:对象的存储地址)映射成一个数值,这个数值被称作哈希值

作用:

  • 假如不使用哈希表在集合中插入不重复的元素

    • 可行的方法为:把插入的元素用equals方法对集合中的每一个元素进行一一比对,如果相同就不插入,如果不同,再将新元素添加进去
    • 显然该方法的效率非常低,hashcode方法的作用就体现出来了
  • 当要添加新对象时,先调用这个对象的hashCode方法,得到对应的哈希值,实际上在HashMap的具体实现中会用一个table保存已经存进去的对象的哈希值,如果table中没有该哈希值,他就直接存进去,不再进行任何比较。如果存在该哈希值,就调用它的equals方法与新元素进行比较,相同就不存。

  • 两个方法的结合运用,才会使得哈希表查询和插入的效率很高

    • 当要插入元素时,先使用hashCode方法计算哈希值,如果table中没有则直接保存,如果有一样的哈希值,在哈希表中的数组结构部分找到该位置
    • 如果该位置上保存了一个链表,就代表该链表上的所有对象的哈希值都与要插入元素的哈希值一致,这个时候再使用equals方法一个一个比对
    • 如果equals方法比对结果还是相同的话,就代表是一模一样的元素,不插入,如果比对结果不相同,再使用头插法或者尾插法插入

hashCode方法与equals方法

  • 不能根据哈希值判断两个对象是否相同,因为不同的对象可能生成相同的哈希值

  • 可以根据哈希值判断两个对象是否不同,哈希值不同,对象必定不同

  • 判断两个对象是否真正完全相等,还是要通过equals方法

  • 以上三点的小结:

    • 两个对象调用equals方法得到的结果为true,则两个对象的哈希值必定相同
    • 如果equals方法结果为false,则两个对象的哈希值不一定不同
    • 如果两个对象的哈希值不同,则equals方法结果必定为false
    • 如果两个对象的哈希值相等,则equals方法得到的结果未知

举例:

  • 在设计一个类的时候,重写了equals方法,那么就必须重写hashCode方法

  • class People{
        private String name;
        private int age;
         
        public People(String name,int age) {
            this.name = name;
            this.age = age;
        }  
         
        public void setAge(int age){
            this.age = age;
        }
         
        @Override
        public int hashCode() {
            return name.hashCode()*37+age;
        }                        //重写hashCode方法,该对象的哈希值为名字的哈希值乘以37再加上年龄
         
        @Override
        public boolean equals(Object obj) {
            return this.name.equals(((People)obj).name) && this.age== ((People)obj).age;
        }
    }
     
     
     
     =====================================================================================================
     
    public class Main {
     
        public static void main(String[] args) {
             
            People p1 = new People("Jack", 12);
            System.out.println(p1.hashCode());
                 
            HashMap<People, Integer> hashMap = new HashMap<People, Integer>();
            hashMap.put(p1, 1);
             
            System.out.println(hashMap.get(new People("Jack", 12)));
            //重新添加一个对象,该对象经过hashCode方法得到的哈希值与p1相同,所以通过get方法应该打印出1
        }
    }
    
    • 假设只重写了equals方法,将重写hashCode方法注释,也就是说如果两个对象的姓名和年龄相同,则认为是同一个对象
      • 而实际打印通过get方法只能得到null,是不会得到1的,代表两个对象并不相同
      • 原因在于没有重写hashCode方法,因为生成的两个对象实际存储地址并不相同,在没有重写hashCode方法的情况下,根据两地址生成的哈希值也是不同的

补充:

  • 对同一个对象调用hashCode方法都应该产生同样的值

    • 在用put方法给HashMap添加对象时,根据hashCode给对象产生一个哈希值添加进去,而用get方法取出的时候再次调用hashCode给同样的对象产生哈希值,这个时候发现同样的对象产生的哈希值不同,那么就无法获取该键所对应的值了

    • 发生这种情况的原因是:在hashCode方法中,使用了对象中易变的数据,因为此数据发生了变化,hashCode就会生成不同的哈希值

    • 举例代码中,People类不变,Main改变:

      public class Main {
       
          public static void main(String[] args) {
               
              People p1 = new People("Jack", 12);
              System.out.println(p1.hashCode());
               
              HashMap<People, Integer> hashMap = new HashMap<People, Integer>();
              hashMap.put(p1, 1);
               
              p1.setAge(13);    //在这里对年龄做了修改,而hashCode方法里用到了年龄这个变量,那么p1的哈希值也会随之改变
               
              System.out.println(hashMap.get(p1));
          }
      }
      

      此时输出p1的值仍然为null

  • 自定义类为Key:

    • 类添加fianl修饰符,保证类不被继承
      • 如果类可以被继承会破坏类的不可变机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时(多态),不能保证当前类是否可变
    • 保证所有成员变量必须私有,并且加上final修饰
      • 通过这种方式保证成员变量不可改变
      • 这种方法还不够,因为如果是对象成员变量有可能在外部改变其值(如:People类中可以使用set方法对年龄做修改)
    • 不提供改变成员变量的方法,包括setter避免通过其它接口改变成员变量的值,破坏不可变特性

MOD运算(取模运算)

作用:

  • 通过Hash值计算索引,让每一个元素可以均匀的分布在数组每一个格子内
    • 为了让每一个元素均匀的分布在数组不同的格子里,总不能明明有16个格子,但是每个元素全都挤在一个格子里,然后通过链表结构去一个一个往后面添加,这样的话哈希表毫无作用,直接完全变成了一个链表结构,查询效率降了两个档次

如何”均匀“:

  • 想要有几种情况,那么就对数字几取余

    • 对3取余,可能的结果为0、1、2,有三种结果,对4取余,可能的结果为0、1、2、3,有四种结果......诸如此类

    • int i=0;
      while(true){
      	if(i%3==0){
      	
      	}
      	if(i%3==1){
      	
      	}
      	if(i%3==2){
      	
      	}
      	i++;
      }
      
  • 对于哈希来说,由于不同元素经过哈希运算得到的哈希值不同,那么用这个哈希值对数组的长度取余后,(假如数组长度为16,得到的哈希值对16取余后,结果可能为0、1、2、3......15,取余的结果就是这个数组中每一个格子的索引)就会得到该元素对应的索引,然后再将其添加进去,这样就可以做到数组每一个索引都可以平均的分到元素

    • 注:

      1.由于根据不同的哈希算法,得到具体的哈希值是不同的,所以哈希值有可能是负数,那么负数对数组长度进行取余(mod运算)的话,得到的索引也是负数,这是不行的

      2.对于计算机来说,二进制的运算效率是最高的,用%进行取余速度很慢

    • 基于以上两点,在进行mod运算时,需要将哈希值和数组长度两个十进制数字转变为二进制,并进行按位与(&)的运算

      • 假设数组长度为17,转变为二进制为10001。传进的三个元素的哈希值分别为0111,0101,0001

        进行按位与运算:

        10001  //数组长度
         0111  //hash1
         0001  //得到索引为1
         
         
        10001  //数组长度
         0101  //hash2
         0001  //得到索引为1
         
        10001  //数组长度
         0001  //hash3
         0001  //得到索引为1
        

        传进来的元素全都被分配到了索引1的位置

      • 假设数组长度为18,转变为二进制为10010。再次传进以上三个元素:0111,0101,0001

        10010  //数组长度
         0111  //hash1
         0010  //得到索引为2
         
        10010  //数组长度
         0101  //hash2
         0000  //????
         
        10010  //数组长度
         0001  //hash3
         0000  //????
        

        可能会得不到索引

      • 每一位与0进行&操作,得到的都是0,这就会发生上面两例的情况,所以为了避免这种情况,要保证数组长度的二进制里,1越多越好

      • 2的幂次方减一转变为二进制,是该字节数量中二进制1的个数最多的

结论:

  • mod运算得到索引的公式为:hash&(数组长度-1)= 索引

高位的计算:

  • 由于得到的哈希值转变为二进制的时候,可能转变的位数比数组长度位数多,那么哈希值的高位就不会被计算到,而当两个哈希值的高位不同低位相同时,由于只计算低位部分,又会被分配到同一个索引,这样就又不均匀了

    • 如:

      10010101  //hash1
      00001111  //数组长度为16-1=15
      00000101  //得到索引为5
      
      11110101  //hash2
      00001111  //数组长度为16-1=15
      00000101  //得到索引为5
      
    • 通过对哈希值的右移让高位也参与运算,避免高位相同,低位不同被分到同一个索引

    • JDK8源码计算哈希值部分:

      static final int hash(Object key) {
              int h;
              return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
          }
      //通过key的hashCode的高16位与低16位做异或运算,目的是为了更离散哈希值
      
      • 从源码中看到,计算哈希值并不仅仅是使用hashCode()方法,还会经过扰动函数的扰动(h = key . hashCode())^(h > > >16),

      • 这里可以看到,当key为null时,得到的索引为0,即默认放在数组的索引为0的位置,也就是说,如果遍历这个hashMap,key为null会出现在前面

      • 如果第一次加入key为null的元素,直接保存在索引为0的位置

哈希冲突

  • 由于哈希是不同长度的输入对应固定长度的输出,那么输入的内容可以是无限的,而哈希值是有限的,所以肯定会有某些数据计算出的哈希值是一样的,这个就叫做哈希碰撞或者哈希冲突

  • 当发现两个数据的哈希值一样时,那么对应的经过mod运算的索引也肯定是一样的,一个索引只保存一个元素,索引一致的情况下就会采用链表的插入方法

    • 链表的插入方法分为头插法和尾插法:

      • 头插法:新数据插入后,让该位置原来的数据指向新的数据,类似于栈的存储方式

      • jdk7采用头插法,因为开发者认为后加入的元素被用到的可能性更大,所以头插法可以快速查询。

        但是该方法也带来安全隐患,在多线程的情况下可能出现死循环

      • 尾插法:新数据插入后,新数据指向原来位置的数据,按顺序一个一个排列在后面

      • 为解决死循环问题,jdk8采用了尾插法

  • 为解决哈希冲突问题,第一种方法:会使用负载因子进行扩容(具体见基本参数中的负载因子)。第二种方法:在计算哈希值时加入了扰动函数也是为了解决哈希冲突,扰动函数综合了哈希值的高位和低位特征并存放在低位,因此在按位与操作时,相当于高低位一起参与了运算

哈希表的扩容:

  • 扩容时,会new一个新的Node数组作为哈希桶(就是存放哈希值的数组)。然后将原哈希表中的所有数据全都移动到哈希桶中,相当于对原哈希表中所有的数据重新做了一个put操作。所以在哈希表每一次的扩容时,如果原哈希表已经非常大了,那么再进行一次扩容,性能消耗十分明显

    • 注:如果我们提前知道要存放非常大的数据,为了不必让哈希表一次一次的扩容影响效率及性能,那么可以在初始化时将初始容量改为我们的理想容量,这样就不需要从默认16的容量开始一次一次的扩容,从而达到代码的优化
  • 元素在原链表上存放的下标(low位),在扩容后得到新的下标(high位)

    • 结论:high位 = low位 + 原数组长度(原哈希桶容量)

    • 分析:

      • 得知 1.计算下标是通过mod运算

        ​ 2.数组的每一次扩容都是按照2的幂次方进行扩容,也就说扩大一倍

      • 基于以上两点:

        0 1111  //原数组长度为16
          0101  //哈希值
          0101  //得到下标为5
         
        0001 1111  //扩容后数组长度为32
             0101  //哈希值不变
        0001 0101  //得到下标为 16+5       原下标(low位)加上原数组长度
        
        

红黑树

自平衡的二叉树:

  • 任意结点的左右两个子树高度差都小于等于1,这样的好处是遍历起来更均匀,快速

  • HashMap保姆级理解解析

红黑树:

  • 二叉树的自平衡是可以自己动态的改变(变色和旋转),不用自己手动平衡

  • HashMap保姆级理解解析

  • JDK8在JDK7的基础上增加了红黑树,HashMap的底层数据结构为数组+链表+红黑树

    • 不管扩容的机制有多好,依然会出现大量的链表导致查询效率低下
    • 所以jdk8在插入的时候,依然按照链表插入,不同于jdk7,这里使用尾插法。
    • 当发现链表的节点(也就是链表的元素)达到8,同时满足数组长度达到64,如果数组长度没达到64会先扩容,两个条件都满足时,会将此链表转成红黑树。
    • 如果发现扩容后红黑树的结点减少到了6,那么又会将他转成链表
    • 尽管红黑树的动态自平衡也比较耗时间,但是当链表结点个数为8个以上时,相较于通过链表查询所耗的时间,红黑树的自平衡时间更少,更划算
  • 不用红黑树,用二叉树也可以,但是在一些极端情况下,如:添加的元素一个比一个小(大),那么二叉树就会就会退化成线性结构,全都排在根结点的左边(右边)

源码解析:

基本参数

  • 默认的初始容量:16

    • static final int DEFAULT_INITIAL_CAPACITY = 1 < < 4;
    • 默认的容量指开辟的哈希表中数组的空间大小为16,索引为0~15
      • 默认容量16是一个经验值,这个数字不大不小,刚好,初始太小了要频繁扩容,太大了又浪费
  • 最大容量:

    • static final int MAXIMUM_CAPACITY = 1 < < 30;
  • 负载因子(扩容因子):

    • static final float DEFAULT_LOAD_FACTOR = 0.75f;
    • 作用:
      • 在存储的元素数量达到扩容阈值(数组长度 乘以 0.75 ),并且新加入的元素与原本的元素发生冲突时,就会扩容,每次扩容后数组的长度为原本长度的2倍(因为要使用mod运算,按位与时必须为2的幂次方减一,所以数组长度就为2的幂次方,每次进行扩容就是原长度*2)
    • 默认负载因子为0.75是经过时间空间成本之间的权衡后找到的最折中的数字,较高的值会减少空间开销,但会增加查找成本。这是经过开发者测试得到的,元素个数达到数组长度*0.75这个值后出现哈希碰撞的概率比较大
    • 使用扩容因子扩容的目的:
      • 并不是因为容量不够而扩容,而是为了解决哈希冲突。放入的元素越多,得到的哈希值越有可能一致,哈希值一致后,如果数组的长度不改变,经过mod运算得到的索引也会一致,就会发生哈希冲突,而通过改变数组长度,重新计算哈希值,再经过mod运算得到的索引也会发生改变,从而解决冲突问题
      • 并不是每次扩容都会重新计算哈希值,是否重新计算哈希值,源码里写了和哈希种子还有int最大值有关系,具体怎么计算并不了解。但是一般情况下并不会rehash
  • 键值对个数:

    • transient int size;
  • 扩容是的阈值:

    • 当前容量*负载因子: int threshold
  • 被修改的次数:

    • transient int modCount
    • 在put、get、remove、interator等方法中,都会用到该参数,每次调用这些方法都会进行modCount++操作,这就是出现并发修改异常的原因
      • 因为HashMap是线程不安全的,所以会在迭代的时候将modCount赋值到expectedModCount属性中,然后进行迭代
      • 如果在迭代的过程中HashMap被其他线程修改了,modCount的数值就会++   发生变化,这时expectedModCount和ModCount不相等,迭代器就会抛出ConcurrentModificationException()异常

构造方法:

  • HashMap()

    • 构造一个空的HashMap,默认初始容量为16,负载因子为0.75

    • 内部依然使用第三个构造方法

    • public HashMap() {
             this.loadFactor = DEFAULT_LOAD_FACTOR; //所有其他的字段都是默认字段
         }
      
  • HashMap(int intitialCapacity)

    • 构造一个空的HashMap具有指定的初始容量(传进的参数),和默认的负载因子(0.75)

    • 内部依然使用第三个构造方法

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

    • 构造一个空的HashMap,具有指定的初始容量和负载因子(两个参数)

    • public HashMap(int initialCapacity, float loadFactor) {
             if (initialCapacity < 0)
                 throw new IllegalArgumentException("Illegal initial capacity: " +
                                                    initialCapacity);
              //如果指定的容量小于0的话,报错       
             if (initialCapacity > MAXIMUM_CAPACITY)
                 initialCapacity = MAXIMUM_CAPACITY;
              //如果指定的容量大于最大容量的话,将容量定为最大容量   
             if (loadFactor <= 0 || Float.isNaN(loadFactor))
                 throw new IllegalArgumentException("Illegal load factor: " +
                                                    loadFactor);
            	 //如果指定的负载因子小于等于0的话(或者负载因子不等于负载因子??)就报错
             this.loadFactor = loadFactor;
             this.threshold = tableSizeFor(initialCapacity);
         }
      
    • 主要是查看传进来的容量和负载因子是否合规,此时,让扩容阈值等于了初始容量

    • 这个时候只是给了必要的参数,并没有在此时初始化HashMap没有分配空间

      • 在给HashMap添加元素时才会初始化HashMap

方法:

put方法:

  • public V put(K key, V value) {
           if (table == EMPTY_TABLE) {     //判断是否内容为空,这里的table是一个键值对数组,默认是空的。
               inflateTable(threshold);			//如果内容为空,则首先初始化
           }
           if (key == null)							//如果传入的key是null,则调用下面的方法
               return putForNullKey(value);
           int hash = hash(key);				//如果key是存在的,那么计算key的hash
           int i = indexFor(hash, table.length);//计算完哈希后,根据哈希和数组长度去计算对应的索引,也就是这个key应该在数组里哪里存储
           for (Entry<K,V> e = table[i]; e != null; e = e.next) {//数组中的每个元素是一个链表,已经计算除了索引,所以遍历该索引位置上的链表,目的是为了检查传进来的key是否已经存在在表里
               Object k;
               if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//如果发现链表上的元素和传进来的key各方面都一样(hash,地址,内容)那么就说明表里已经有了,那么就保持key不变,更新value,并返回旧的value
                   V oldValue = e.value;
                   e.value = value;
                   e.recordAccess(this);
                   return oldValue;
               }
           }
      
           modCount++;  //被修改次数加一,这个值主要是为了线程安全考虑,和本方法的业务无关
           addEntry(hash, key, value, i); //如果是一个新key,则添加元素。
           return null;
       }
    
  • 图解:

HashMap保姆级理解解析

addEntry(hash,key,value,i)

  • void addEntry(int hash, K key, V value, int bucketIndex) {
            if ((size >= threshold) && (null != table[bucketIndex])) {//当当前的元素个数大于等于扩容阈值的时候,并且分配给新元素的这个位置以及有值,则扩容
                resize(2 * table.length);//扩容为原来的数组长度乘2
                hash = (null != key) ? hash(key) : 0;//重新计算key的hash值,如果key=null,则hash为0
                bucketIndex = indexFor(hash, table.length);//根据新数组长度重新计算索引
            }
    
            createEntry(hash, key, value, bucketIndex);//插入该节点
        }
    

get方法:

HashMap保姆级理解解析

初始化:

private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);//这里可以看到,初始化时,无论传进来的初始容量是多少,都会向上取整为2的幂。

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算扩容阈值
        table = new Entry[capacity];//初始化
        initHashSeedAsNeeded(capacity);
    }
  • 虽然构造方法中可以让用户自定义容量大小,但是进来后也会向上转成2的幂。当然,如果本身就是2的幂,那么就不会转换了,这里看源码,会发现他做了一个-1操作,目的就是为了防止把正确容量也翻一倍。比如你传进来的是15,那么向上取整为16.如果传进来的是16,向上取整就成了32,这是不合理的,所以任何数在取整时都先-1.

面试题:

  • HashMap的底层数据结构

    • 在jdk7及之前为数组加链表的数据结构

      • 数组为:内存中的一片连续区域,同类型数据的集合,有索引,查询快,增删慢,不可扩容。
      • 链表为:不连续的区域,每个节点放值和指向下一个节点的指针。查询慢,增删块。
      • 哈希表为:数组加链表。即一个一维数组,但是数组中的每个元素是一个链表
    • jdk8及之后为数组加链表加红黑树的数据结构

      • 所以jdk8在插入的时候,依然按照链表插入,不同于jdk7,这里使用尾插法,为了解决头插法可能出现的死循环问题
      • 当发现链表的节点(也就是链表的元素)达到8,同时满足数组长度达到64,如果数组长度没达到64会先扩容,两个条件都满足时,会将此链表转成红黑树。
      • 如果发现扩容后红黑树的结点减少到了6,那么又会将他转成链表
      • 尽管红黑树的动态自平衡也比较耗时间,但是当链表结点个数为8个以上时,相较于通过链表查询所耗的时间,红黑树的自平衡时间更少,更划算
  • 一般用什么作为key?

    • 一般用Integer、String这种不可变类当HashMap的键,而且String最常用
    • 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
    • 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
上一篇:为什么 Map 桶中超过 8 个才转为红黑树?


下一篇:Java基础之hashcode剖析