java 集合-HashMap 原理

jdk1.7 和 1.8 大致相同但还是有区别,主要是数据结构的区别,1.7 为数组+链表;1.8 为数组+链表+红黑树

关键知识点

  • 加载因子:装填因子,目的是何时对 map 进行扩容,默认是 0.75 即容量达到 75% 时对 map 扩容,原数组扩大为两倍长度
  • 扩容阈值,根据数组长度和加载因子相乘得到的值,达到这个值就会扩容
  • hash 算法:也叫hash函数,hash运算,指的是把 key 换算成数组的下标的算法(hashCode+数组长度+一系列右移和异或运算)
  • hash 冲突:不同的 key 经过 hash 运算后得到了相同的数组下标(这时数组元素不止一个,解决的方式很多,java 采用 链式寻址法 也就是链表来解决)
  • 数组长度默认是 16,且长度只会是 2 的指数个数(初始化指定的长度实际会是当前值最接近2的指数的值,比如指定为3,实际会是8)
  • new HashMap() 时不会初始化数组,在 put 的时候才会初始化,具体是 put 时判断数组是否为空,如果为空再创建长度为 16 的数组
  • 数组的元素是链表,这个说法不是特别准确,只是说可以这样理解,准确的说法是数组的元素是链表的头节点

jdk1.7

1.7 的逻辑比叫简单,数据结构也好,扩容也好,甚至代码的阅读上都比1.8要清晰些

put 过程

  • 判断数组(table)是否为空,如果为空就先创建数组(put 才初始化数组,扩容阈值等也是 put 时确定,懒加载思维)
    • 如果不指定长度,默认就是16
    • 如果指定长度,并不是创建指定长度的数组,而是根据指定的长度得到最接近 2 的指数值,把这个值作为数组长度来初始化
  • hash 运算得到数组下标 i
    • 先得到 hashCode,hashCode 远远不够,还需要别的运算才能满足当前数组的下标范围
    • 把 hashCode 多次右移和异或运算得到一个值(hash 值)
    • 把 hash 值和数组长度-1做与运算(i = h & length-1),因为要保证这里的与运算能正确得到符合数组的下标,所以数组的长度必须是 2 的指数
  • 把元素放入数组下标i的位置,分两种情况:当前位置有值和没有值
    • 如果有值,table[i] 就是个链表,遍历这个链表,也会有两种情况,key 重复和不重复(使用 equals 比较),如果 key 重复就覆盖,返回原来的值,流程结束
    • 如果没有值或者 key 不重复,继续往下走,执行新增逻辑,addEntry 方法
  • 新增元素 addEntry 方法逻辑(如果 table[i] 为空,table[i] 就放当前 entry,如果不为空那就是个链表,头插法维护链表)
    • 如果 size + 1 大于扩容阈值(长度*负载因子)会先进行扩容,table 长度变为原来的 2 倍,扩容方法是resize(2 * teble.length)
    • 如果 size + 1 不大于扩容阈值,就把 key-value 构建成一个节点(Entry 对象)采用头插法维护 table[i] 的链表
    • size++,返回 null,流程结束

扩容

创建一个新的 table, 长度变为原来的 table 的两倍,首先把原来 table 的元素先全部拷贝到新的 table 中,拷贝的的过程中不是简单复制数组,而是重新 根据新的 table 长度进行哈希运算,把原来的元素放进新的 table 中,这样有个显著的特点,因为 table 变成了,所以 hash 运算得到的下标和原来的下标不一样,这样就会更少的发生 hash 冲突,从而把链表的长度变短

get

只要明白 put 就应该能明白 get,因为 put 会先找一遍,如果没有再新增 entry。get 大概逻辑:

  • 根据 key 得到 hashCode
  • 再经过 hash 函数(多次右移+异或)得到 hash
  • 根据 hash 和 数组长度-1(数组下标)进行与运算得到数组下标
  • 再用 eauals 比较链表的 key,如果相等就获取返回

jdk1.8

数据结构为:数组+链表(但双向都有)+红黑树

put 过程

  • 判断数组是否是空,如果是创建一个长度为 16 的数组,同时根据加载因子设置扩容阈值
    • 源码里面为了性能考虑,把 table 设置到方法的局部变量,根据局部变量的 table 来比较(因为从堆取数据效率低于栈里面取数据)
  • 根据 hash 运算得到 hash 值(和1.7一致)
  • 根据 hash 值得到下标(和1.7一致)
  • 把元素放到 table[i]
    • 如果 table[i] 没有值,直接放进去(modCount++)
    • 如果 table[i] 有值,遍历这个链表,如果 key 相等就覆盖,返回旧值(modCount不加1)
    • 如果 table[i] 有值,但是 key 不重复(这时就要维护链表了,但是也有可能是红黑树,还可能达到条件先转红黑树再插入)
      • 先判断是不是 treeNode(红黑树),如果是,直接插入红黑树
      • 如果不是红黑树,那就是链表。情况1:链表节点数小于8,直接 尾插法 插入链表;情况2:达到条件,转红黑树
  • size++,之后判断是否需要扩容,不需要就结束,需要就扩容再结束

链表转红黑树

  • 先搞清楚为什么要转成红黑树?为了效率,红黑树的时间复杂度是 logN
  • put 时发生的,当 hash 冲突,多个元素放在 table[i] 时
  • 如果当前链表数量小于8,最大就是7,算上当前节点就是8个,不会超过转红黑树的阈值,这时尾插法插入链表
  • 如果当前链表数量不小于8,最小就是8,算上当前节点就是9个,这时超过转红黑树的阈值,调用转红黑树的方法 treeifyBin()
    • treeifyBin() 方法不一定一定就转红黑树了,方法里面还要判断第二个条件,只有当 table 长度也达到 64 时才会转,不然只是扩容
  • 具体链表转红黑树的逻辑是先遍历链表,把每个链表先转成 TreeNode(现在本身就是双向链表了),之后再设置左右节点(调用的是 treeify() 方法,这时就是树状了,同时也是双向链表)

扩容

好几个地方都调用了扩容方法
1,初始的时候扩容就是初始化数组(此时数组是空,创建默认长度的数组)
2,当 put 之后 链表长度大于 8 的时候也会扩容(这时可能还没达到扩容阈值)
3,当 put 之后 size 达到扩容阈值时也会扩容

  • 先把 table 的 size 扩大一倍,扩容阈值也是一倍(源码里的具体做法是右移1位)
  • 遍历原来数据的元素,放到新数组中,分为 4 中情况,空、一个元素、链表、红黑树
    • 空,就忽略了
    • 一个元素,和 1.7 一样,重新 hash,确定新下标,然后放到新数组
    • 链表,遍历链表,得到高位和地位的两个链表,分别把这两个链表放到新数组中
      • 数据转移的时候只要确定了老数组元素在新数组的下标就能把元素放进去
      • 元素在老数组和新数组的下标是有规律的,比如元素在老数组的小标是2,在新数组中要么也是2(低位),要么就是 2+老数组的长度(高位)
      • 具体做法也是重新 hash 得到高低位的两个链表,然后根据高低位的下标放入对应的链表完成数组转移
    • 红黑树,因为红黑树既是一颗树,也是一个双向链表,所以做法先和链表的做法一致,先遍历维护好高低位的链表(对于树,专门提供了一个方法 split 来拆分,原理和链表一致就是确定高低位的链表)
      • 高低位的链表个数如果小于6,新数组的元素就是这个链表,不然就再次把拆分后的链表转成树(链表到树的阈值是8,树到链表的阈值是6)
      • 红黑树在扩容之后可能都成了链表,或者都是树,或者链表和树的结合

get

到现在应该不需要再说这个了吧,讲道理应该已经很清楚了~

1.7 和 1.8 区别

  • 对于链表,元素名称不一样,1.7 是 entry,1.8 是 node,存放的内容都一样,当前节点和下个节点指针
  • 对于链表,1.7 是头插法,1.8 是尾插法
    • 因为 1.8 引入了红黑树(当链表节点达到8,数组长度64时)如果是尾插,遍历的时候记录链表个数,就能知道当前链表的长度,如果是头插,是不能知道链表的个数的
  • 数据结构不同,1.7 是数组+链表(单向),1.8 是数组+链表(单双向都有)+红黑树
    • 1.7 的 Entry 和 1.8 的 Node 都有后一个节点指针
    • 1.8 的红黑树(TreeNode)存的有 左、右、下 节点点指针,并且继承了 Node,所以 TreeNode 有上下左右,上下都有了就是双向了
  • 扩容方式不一样
    • 1.7 把原来的数组的元素(双重for循环,数组的长度是一层,链表的内容也是一层)的 key,根据新数组的长度重新 hash,确定新数组的下标然后放进去
    • 1.8 也是先把元素重新 hash,得到高低位的链表或红黑树,再放到新数组
posted @ 2023-03-01 21:19  CyrusHuang  阅读(21)  评论(0编辑  收藏  举报