HashMap 继承自 AbstractMap,实现了 Map 接口,基于哈希表实现,元素以键值对的方式存储,允许键和值为 null。因为 key 不允许重复,因此只能有一个键为 null。HashMap 不能保证放入元素的顺序,它是无序的,和放入的顺序并不相同。HashMap 是线程不安全的。
哈希表基于数组实现,当前元素的关键字通过某个哈希函数得到一个哈希值,这个哈希值映射到数组中的某个位置。哈希函数的好坏直接决定该哈希表的性能
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,这就是所谓的哈希冲突,也叫哈希碰撞
解决方法如下:
HashMap 由数组和链表实现对数据的存储,HashMap 里面实现一个静态内部类 Entry,包含 Key、Value 和对 key 的 hashcode 值进行 hash 运算后得到的 Hash 值,它还具有 Next 指针,可以连接下一个 Entry 实体,以此来解决 Hash 冲突的问题
初始化哈希表:真正初始化哈希表(初始化存储数组)是在第一次添加键值对时
数组为空:设置默认阈值与初始容量
设置了传入容量:将传入的容量大小转化为大于自身的最小的二次幂。如果超出最大允许容量,则设置为最大值
判断键是否为空:对 null 作哈希运算,结果为 0,所以以 null 为键的键值对一般放在数组首位,该位置的新值总是会覆盖旧值
计算元素存放位置:首先根据 key 的 hashcode 计算 hash 值,然后根据 hash 值计算 index 下标值
哈希冲突:当发生哈希冲突时,为了保证键的唯一性,哈希表不会马上在链表中插入新数据,而是先遍历链表,查找该键是否已存在,若已存在,替换即可
添加键值对:使用头插法,新添加元素放在链表头部,原始节点作为新节点的后继节点
JDK 1.7 做了 9 次扰动处理 = 4 次位运算 + 5 次异或运算
计算元素位置采用的是 & 运算,该方法返回 h & (length - 1),其中 h 为 key 的 hash 值,length 是数组长度
先判断是否需要扩容,再插入
1.8 以前 HashMap 采用 数组 + 链表 实现,即使用链表处理冲突,同一 hash 值的节点都存储在一个链表里。但是当同一 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。JDK1.8 中,HashMap 采用 数组 + 链表 + 红黑树 实现,当链表长度超过阈值时,将链表转换为红黑树,大大减少了查找时间
初始化哈希表:真正初始化哈希表(初始化存储数组)是在第一次添加键值对时
数组为空:设置默认阈值与初始容量
设置了传入容量:将传入的容量大小转化为大于自身的最小的二次幂。如果超出最大允许容量,则设置为最大值
判断键是否为空:对 null 作哈希运算,结果为 0,所以以 null 为键的键值对一般放在数组首位,该位置的新值总是会覆盖旧值
计算元素存放位置:首先根据 key 的 hashcode 计算 hash 值,然后根据 hash 值计算 index 下标值
哈希冲突:当发生哈希冲突时,为了保证键的唯一性,哈希表不会马上在链表中插入新数据,而是先遍历链表,查找该键是否已存在,若已存在,替换即可;如果不存在,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于 8 并且数组长度大于 64, 大于的话链表转换为红黑树
添加键值对:链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后
JDK 1.8 简化了扰动函数 = 只做了 2 次扰动 = 1 次位运算 + 1 次异或运算,本质是哈希码的低 16 位异或高 16 位
计算元素位置采用的是 & 运算,该方法返回 h & (length - 1),其中 h 为 key 的 hash 值,length 是数组长度
先进行插入,插入完成再判断是否需要扩容。扩容时,1.7 需要对原数组中的元素进行重新 hash 定位,以确定在新数组中的位置,1.8 采用更简单的判断逻辑,位置不变或索引 + 旧容量大小
HashMap 使用懒扩容机制,只有在进行 PUT 操作时才会判断是否扩容,需要用到的属性有两个:
阈值 = 数组大小 * 负载因子,容器默认大小为 16,此时 阈值 = 16 * 0.75 = 12,如果当前数组中元素的数量大于阈值,则将数组大小扩大为原来的两倍,并将原来数组中的元素进行重新放到新数组中。需要注意的是,每次扩容之后,都要重新计算元素在数组的位置,因为元素所在位置和数组长度有关,既然扩容后数组长度发生了变化,那么元素位置也会发生变化
我们可以自定义数组容量及加载因子的大小。加载因子过大时,HashMap 内的数组使用率高,内部极有可能形成 Entry 链,影响查找速度。加载因子过小时,HashMap 内的数组使用率低,内部不会生成 Entry 链,或者生成的 Entry 链很短,提高了查找速度,不过会占用更多的内存。所以要进行时间和空间的折中考虑
因为 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为非常大,前后加起来大概 40 亿的映射空间,一个 40 亿长度的数组,内存是放不下的。而且使用之前还需要对数组的长度取模运算,得到余数才能用来访问数组下标
加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少哈希冲突
这也解释了为什么 HashMap 的数组长度要取 2 的整数幂。因为 数组长度 减一 正好相当于一个低位掩码。与操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问,其结果与取模运算相同,效率却要高很多
因为 1.7 扩容时,元素会被重新移动到新的数组,而使用头插法会使链表发生反转,比如原本是 A-B-C 的链表,扩容之后就变成 C-B-A 了,在多线程环境下,会导致链表成环的问题。而尾插法,在扩容时会保持链表原本的顺序不变,就不会出现链表成环的问题