HashMap/HashMap存储/HashMap扩容

HashMap

Java 集合,也称作容器,主要是由两大接口 (Interface)派生出来的:Collection 和 Map

Map集合体系:
在这里插入图片描述

Map集合特点:
(1) 键值对存储(key-value),一个键值对是Map集合中一个元素
(2) 键:无序、无下标、元素不允许重复(唯一 因为key是用set集合存储的)
(3) 值:无序、无下标、元素允许重复 (也是用集合存储的)

实现类

HashMap

探究HashMap是什么、能做什么以及一些操作原理。

HashMap是什么?

HashMap是由我们常用的数组+链表+红黑树(JDK1.8增加了红黑树)组合构成的数据结构。

HashMap的存储及扩容
存储

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实所谓Map其实就是保存了两个对象之间的映射关系的一种集合)。

数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node,两者都为HashMap的内部类且都实现了Map.Entry接口。

下图中的每个黑色圆点就是一个Node对象:
在这里插入图片描述

(此图其实就是一个哈希表,哈希表有多种不同的实现方法,这里是最常用的一种方法—— 拉链法。通俗的讲就是hash+数组+链表的结合)

HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。例如程序执行下面代码:

在这里插入图片描述

数组的初始值为空,长度一定为2的幂次方(默认值为16)。在put插入的时候回根据key用hash函数去计算一个index(下标)值,例如:

map.put("1001","桃树");
hash("1001") = 2  //举例结果为2,真正结果不一定为2

在这里插入图片描述

系统将调用"1001"这个key的hashCode()方法得到其hashCode 值(该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算)来定位该键值对的存储位置,如果位置为空,则直接插入;如果不为空,即两个key定位到了相同的位置,此时表示发生了Hash碰撞。当发生Hash碰撞时,会在当前数组位置用链表存储新的键值对。原本键值对都在数组上,添加、查找等操作只需要一次寻址即可,当出现链表后,对于在链表上的键值对,添加等操作的时间复杂度会增加,变为O(n)。所以,考虑性能,Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。

那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。

注意:如果自定类型的对象作为HashMap的键,为了保证元素不重复(键),则(键)对象对应的类需覆盖 hashCode和equals方法。但是为了提高检索的效率,开发时通常使用String/Integer(例如String的用户名或是Integer的id)作为HashMap的键 。

扩容

java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,在java8之后,都是所用尾部插入了。

在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化:

     int threshold;             // 所能容纳的key-value对极限 
     final float loadFactor;    // 负载因子
     int modCount;  
     int size;

首先,Node[] table(哈希桶数组)的初始化长度length。(initialCapacity , 默认值是16)。

​ threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。

​ loadfactor为负载因子(默认值是0.75)。

在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子loadfactor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。对HashMap的优化就在于此,如果改得好,就实现了对HashMap的优化,如果改得不好,,,凉凉,,。因为默认的负载因子0.75是对空间和时间效率的一个平衡选择,所以,一般不建议不建议不建议修改。

​ size是HashMap中实际存在的键值对数量。(当size>length * loadfactor时会进行扩容)

​ modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

其中:threshold = length * loadfactor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

所以threshold就是在此loadfactor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍

扩容方式:将老数组中的数据逐个地遍历,计算,然后扔到新的扩容后的数组中。

在这里插入图片描述

static int indexFor(int h, int length) { // h 为key 的 hash值;length 是数组长度
        return h & (length-1);  
	}

公式: index = h&(length-1)

PS:为什么不直接将原数组的数据直接复制到新数组而要麻烦的逐个计算再put进新数组?

因为扩容后,数组长度变了,所以同样的键计算出来的index也会发生变化,不再是原来的index值了,所以不能简单的复制。

模运算和高位运算:

模运算: h % length (貌似刚开始的时候用的模运算,扩容的时候用的是高位运算)

高位运算:h & (length-1)(是二进制运算)(将原来的h和扩容后的length-1进行与运算(全为真才是真),如果左边新加的一位是0则元素还放到原来的位置,如果是1则放到新位置,新位置=原来位置+原来数组长度)

在这里插入图片描述
关于table长度必须为2的幂次方

在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化。

​ 1.当长度为2时, h & (length-1) 的值会出现和 h % length 计算的结果是一样的情况,大大减少了之前已经散列良好的老数组的数据位置的重新调换

​ 2.当数组长度为2的n次幂的时候,不同的key算得(高位运算)的index相同的几率较小,那数据在数组上分布也就比较均匀,即碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

put操作图解:

在这里插入图片描述

线程安全性

在多线程使用场景中,应该尽量避免使用线程不安全的HashMap(HashMap可能造成死循环),而使用线程安全的ConcurrentHashMap。

扩展(来自于各个文章)
HashMap和HashTable 的异同?
  1. 二者的存储结构和解决冲突的方法都是相同的。
  2. HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。
  3. HashTable 中 key和 value都不允许为 null,而HashMap中key和value都允许为 null(key只能有一个为null,而value则可以有多个为 null)。但是如果在 Hashtable中有类似 put( null, null)的操作,编译同样可以通过,因为 key和 value都是Object类型,但运行时会抛出 NullPointerException异常。
  4. Hashtable扩容时,将容量变为原来的2倍+1,而HashMap扩容时,将容量变为原来的2倍
  5. Hashtable计算hash值,直接用key的hashCode(),而HashMap重新计算了key的hash值,Hashtable在计算hash值对应的位置索引时,用 **%**运算,而 HashMap在求位置索引时,则用 **&**运算。
put操作代码解:
public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
        //此时threshold为initialCapacity 默认是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        addEntry(hash, key, value, i);//新增一个entry
        return null;
    }

inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
        /**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
        capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.

 private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

hash函数

/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀*/
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

/**
     * 返回数组下标
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为index=2。位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

所以最终存储位置的确定流程是这样的:

在这里插入图片描述

公式: index = h&(length-1)

举例说明下扩容过程:

假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。

其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。

这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能。

在这里插入图片描述

(注:部分图片源于网络)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值