深入了解HashMap

1,085 阅读9分钟

map不是地图吗?为什么map变成了映射?

小学就学过,map这个单词的含义是地图。但是,大多数人第一次接触集合时候可能一脸懵逼,为何map的意思变成了映射?别急,且听我细细道来。

其实map的本意就是映射。所谓地图就是把地球上的一条路映射到了一张纸上,把地球上的一个房子映射到了一张纸上,地图的本质不就是将现实世界的某些东西映射到了一张纸上吗,然后给这张纸起名叫:地图。

所以map的本意其实就是映射,而地图其实算是map的引申义。

扯了map映射的来历,接下来要聊一聊HashMap了。

HashMap 和 Hashtable 的区别

  1. 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
  2. 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,因为性能太差了;
  3. 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
  4. 初始容量大小和每次扩充容量大小的不同 :
    1. 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    2. 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  5. 底层数据结构:
    1. JDK1.8 以后的 HashMap 在链表长度大于8且数组的长度大于64时,会将链表转化为红黑树,以减少搜索时间
    2. Hashtable 没有这样的机制

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构:
    • JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现
    • JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑树
    • Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式
  • 实现线程安全的方式:
    • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分段,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
    • 在 JDK1.8 的时候,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作,每次只锁头结点,并发度很高。
    • Hashtable: 使用 synchronized 来保证线程安全,也就是说整个HashTable只使用一把锁,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,会进入阻塞。

HashMap底层数据结构, 1.7和1.8有何不同?

  • 1.7的HashMap是哈希表, 数组 + 链表.
  • 1.8的HashMap是数组 + (链表 或 红黑树)

HashMap什么时候进行扩容

  • HashMap 默认的初始化大小为16, 默认负载因子为0.75.
  • 当hashmap中的元素个数超过 数组大小*负载因子(loadFactor) 时,就会进行数组扩容.
  • 之后每次扩充,容量变为原来的 2 倍。

HashMap为何使用红黑树

  • 最开始使用链表是 用哈希表的拉链法来解决Hash冲突问题. 当某个位置, Hash冲突严重, 则链表的长度会很长. 那么查找的时候依次比较, 效率会很低. 将链表转为红黑树, 因为红黑树左子树全都小于根, 右子树全都大于根, 搜索效率很高. 所以使用红黑树就提高了查找效率.
  • 红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。
  • 平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。

HashMap的链表什么时候树化

  • 链表长度超过树化阈值8
  • 数组长度大于等于64
  • 当链表长度超过8时, 若数组长度小于64, 则会对数组进行扩容, 然后二次哈希的值就会变, 此时链表的部分值就会重新分配到数组其他位置

为何一开始不树化

  • 刚开始链表的长度可能只有三四个, 如果此时树化, 那么树化后查询的效率和短链表差不多. 所以, 在短链表的情况下, 树化意义不大.
  • 而且链表的节点是Node, 红黑树的节点为TreeNode. TreeNode的内存占用大于Node.

为何树化阈值为8

  • 红黑树用来避免DoS攻击,防止链表超长时性能下降树化应当是偶然情况.
  • hash表的查找, 更新的时间复杂度是O(1). 而红黑树的查找, 更新的时间复杂度是O(log2n), TreeNode占用空间也比普通Node的大,如非必要,尽量还是使用链表。
  • hash值如果足够随机,则在hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006. 长度超过8的链表出现几率非常小, 选择8就是为了让树化几率足够小

何时红黑树退化为链表

  • 退化情况1: 当元素增加, 数组扩容时, 如果树被拆分, 当树节点<=6时会退化为链表.
  • 退化情况2: 删除节点时, 若root, root.left, root.right, root.left.left有一个为null(移除前检查), 也会退化为链表.

索引计算方式

  • 对任何一个对象调用其hashCode()方法会获得其原始hash值. 对原始hash值再调用HashMap的hash方法进行二次hash, 获取到二次hash值. 二次hash值对数组容量进行取余操作获取到存放的数组下标.
    • 取余操作可以用 hash值 & (数组容量 - 1) 的方式得到. 这种按位与数组容量-1的方式取余的效率更高.
  • 注: 按位与数组容量-1的方式需要数组容量为2^n才可以.

为何需要二次hash?

  • 二次hash是为了让hash值分布更加均匀, 减少hash冲突, 从而使链表更短, 因此也就提升了查找效率.
  • 1.8中二次hash的算法: 二次hash值 = 原hash值 ^ (原hash值>>>16)
  • 原hashCode为32位, 右移16位就获取到了其高16位. 用原hashCode和其高16位异或的到二次hash值.

数组容量为何是2^n?

  • 计算索引时,如果是2的n次暴可以使用位与运算代替取模运算, 效率更高
  • 扩容时 二次hash值&旧数组容量==0 的元素留在原来位置,不等于0则新位置=旧位置+旧容量
  • 数组容量为质数会使hash值分布均匀, 但是2^n计算索引的效率更高.

HashMap的put()方法流程

1.8的put流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组
  2. 计算索引(桶下标)
  3. 如果桶下标还没人占用,创建Node放入数据后返回
  4. 如果桶下标已经有人占用
    1. 已经是TreeNode走红黑树的添加或更新逻辑
    2. 是普通Node,走链表的添加或更新逻辑. 如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值, 一旦超过进行扩容.
    1. 扩容时, 先将新的数据放进数组, 然后创建新的数组, 再将旧数组元素迁移到新数组

1.8和1.7的put流程不同之处

  • 链表插入节点时, 1.7是头插法, 1.8是尾插法
  • 1.7是大于等于阈值且待插入的桶中已经有数据的时候才扩容. 1.8是只要大于阈值就扩容
  • 1.8有判断链表长度, 树化的逻辑. 1.7没有.

负载因子为何是0.75

  • 0.75是在占用空间和查询时间中取得了比较好的平衡. (扩容阈值=数组大小*负载因子)
  • 大于0.75, 冲突增加了, 数组空间就节省了, 但是链表就会比较长, 影响性能.
    • 若负载因子为1, 则16*1 = 16, 只有当存满16个元素之后, 才会扩容. 节省空间, 影响性能.
  • 小于0.75, 冲突减少了, 链表会比较短, 数组扩容会很频繁, 空间占用增多.
    • 若负载因子为0.25, 则 16*0.25=4 , 当元素个数大于4个, 就会扩容, 浪费空间, 性能提升

多线程操作HashMap会出现什么问题

  • 扩容死链 (1.7)
    • 扩容的时候线程切换, 两个线程都要进行扩容.
    • 因为1.7是头插法, 进行数组扩容的时候, 需要链表迁移. 在并发环境下, 会出现循环链表, 造成扩容死链问题.
  • 数据错乱 (1.7, 1.8)
    • 两个线程都要放入一个新数据 (两个数据索引一致, 且该索引下无链表). 此时就会出现数据错乱问题

1651999253986.png

key 能否为 null,作为key的对象有什么要求?

  • HashMap的key可以为null,但Map的其他实现则不然
  • 作为key的对象,必须实现hashCode和equals,并且key的内容不能修改(不可变)
    • 若key为一个对象, put进hashMap中后, 修改了对象属性值. 那么再get只会得到null

String对象的hashCode()如何设计的,为啥每次乘的是31

  • 目标是达到较为均匀的散列效果,每个字符串的hashCode足够独特