前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JDK容器学习之HashMap (二) : 读写逻辑详解

JDK容器学习之HashMap (二) : 读写逻辑详解

作者头像
一灰灰blog
发布2018-02-06 15:55:30
6950
发布2018-02-06 15:55:30
举报
文章被收录于专栏:小灰灰小灰灰

Map读写实现逻辑说明

前一篇博文 JDK容器学习之HashMap (一) : 底层存储结构分析 分析了HashMap的底层存储数据结构 通过put(k,v)方法的分析,说明了为什么Map底层用数组进行存储,为什么Node内部有一个next节点,这篇则将集中在读写方法的具体实现上

本片博文将关注的重点:

  • 通过key获取value的实现逻辑
  • 新增一个kv对的实现逻辑
  • table 数组如何自动扩容
  • 如何删除一个kv对(删除kv对之后,数组长度是否会缩水 ?)

1. 根据key索引

get(key) 作为map最常用的方法之一,根据key获取映射表中的value,通常时间复杂度为o(1)

在分析之前,有必要再把HashMap的数据结构捞出来看一下

结构描述
结构描述

根据上面的结构,如果让我们自己来实现这个功能,对应的逻辑应该如下:

  • 计算key的hash值
  • 根据hash确定在table 数组中的位置
  • 判断数组的Node对象中key是否等同与传入的key
  • 若不是,则一次扫描next节点的key,直到找到为止

jdk实现如下

代码语言:javascript
复制
public V get(Object key) {
   Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
  Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  // 判断条件 if 内部逻辑如下
  // table 数组已经初始化(即非null,长度大于0)
  // 数组中根据key查到的Node对象非空
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (first = tab[(n - 1) & hash]) != null) {
      if (first.hash == hash && // always check first node
          ((k = first.key) == key 
          || (key != null && key.equals(k))))
          return first;
      if ((e = first.next) != null) {
          if (first instanceof TreeNode)
              // 红黑树中查找
              return ((TreeNode<K,V>)first).getTreeNode(hash, key);
          do {// 遍历链表
              if (e.hash == hash &&
                  ((k = e.key) == key 
                  || (key != null && key.equals(k))))
                  return e;
          } while ((e = e.next) != null);
      }
  }
  return null;
}

上面的逻辑算是比较清晰,再简单的划一下重点

  1. 通过key定位table数组中索引的具体逻辑
  • hash(key) & (table.length - 1)
  • key的hash值与(数组长度-1)进行按位与,计算得到下标
  1. 判断Node是否为所查找的目标逻辑
  • node.hash == hash(key) && (node.key == key || (key!=null && key.equals(node.key))
  • 首先是hash值必须相等
  • and == or equals
    • key为同一个对象
    • or Node的key等价于传入的key
  1. TreeNode 是个什么鬼

上面的逻辑中,当出现hash碰撞时,会判断数组中的Node对象是否为 TreeNode,如果是则调用 TreeNode.getTreeNode(hash,key) 方法

那么这个TreeNode有什么特殊的地方呢?

2. TreeNode 分析

TreeNode 依然是 HashMap 的内部类, 不同于Node的是,它继承自LinkedHashMap.Entry,相比较与Node对象而言,多了两个属性 before, after

1. 数据结构

TreeNode对象中,包含的数据如下(将父类中的字段都集中在下面了)

代码语言:javascript
复制
// Node 中定义的属性
final int hash;
final K key;
V value;
Node<K,V> next;
// ---------------

// LinkedHashMap.Entry 中的属性
Entry<K,V> before, after;
// ---------------


// TreeNode 中定义的属性
TreeNode<K,V> parent;  // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;    // needed to unlink next upon deletion
boolean red;
2. 内部方法

方法比较多,实现也不少,但是看看方法名以及注释,很容易猜到这是个什么东西了

红黑树

具体方法实现身略(对红黑树实现有兴趣的,就可以到这里来膜拜教科书的实现方式)

3. TreeNode 方式的HashMap存储结构

普通的Node就是一个单向链表,因此HashMap的结构就是上面哪种

TreeNode是一颗红黑树的结构,所以对上面的图走一下简单的改造,将单向链表改成红黑树即可

newTech
newTech

3. 添加kv对

博文 JDK容器学习之HashMap (一) : 底层存储结构分析 对于添加kv对的逻辑进行了说明,因此这里将主要集中在数组的扩容上

扩容的条件: 默认扩容加载因子为(0.75),临界点在当HashMap中元素的数量等于table数组长度*加载因子,长度扩为原来的2倍

数组扩容方法, 实现比较复杂,先撸一把代码,并加上必要注释

代码语言:javascript
复制
final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table; // 持有旧数组的一份引用
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  int oldThr = threshold;
  int newCap, newThr = 0;
  if (oldCap > 0) {
      if (oldCap >= MAXIMUM_CAPACITY) { 
          // 容量超过上限,直接返回,不用再继续分配
          threshold = Integer.MAX_VALUE;
          return oldTab;
      }
      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
               oldCap >= DEFAULT_INITIAL_CAPACITY)
          // 新的数组长度设置为原数组长度的两倍
          // 新的阀值为旧阀值的两倍,
          newThr = oldThr << 1; // double threshold
  } else if (oldThr > 0) {
      // initial capacity was placed in threshold
      newCap = oldThr;
  } else { 
      // 首次初始化
      newCap = DEFAULT_INITIAL_CAPACITY;
      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  
  
  if (newThr == 0) { 
      float ft = (float) newCap * loadFactor;
      newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
  }
  threshold = newThr;
  @SuppressWarnings({"rawtypes","unchecked"})
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  if (oldTab != null) {
   // 下面是将旧数组中的元素,塞入新的数组中
      for (int j = 0; j < oldCap; ++j) {
          Node<K,V> e;
          if ((e = oldTab[j]) != null) {
              oldTab[j] = null;
              if (e.next == null) {
                  // 若Node节点没有出现hash碰撞,则直接塞入新的数组
                  newTab[e.hash & (newCap - 1)] = e;
              } else if (e instanceof TreeNode) {
                  // 对于出现hash碰撞,且红黑树结构时,需要重新分配
                  ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
              } else { // preserve order
                  Node<K,V> loHead = null, loTail = null;
                  Node<K,V> hiHead = null, hiTail = null;
                  Node<K,V> next;
                  do {
                      next = e.next;
                      if ((e.hash & oldCap) == 0) {
                      // 新的位置相比原来的新增了 oldCap
                          if (loTail == null)
                              loHead = e;
                          else
                              loTail.next = e;
                          loTail = e;
                      }
                      else { // 位置不变
                          if (hiTail == null)
                              hiHead = e;
                          else
                              hiTail.next = e;
                          hiTail = e;
                      }
                  } while ((e = next) != null);
                  if (loTail != null) {
                      loTail.next = null;
                      newTab[j] = loHead;
                  }
                  if (hiTail != null) {
                      hiTail.next = null;
                      newTab[j + oldCap] = hiHead;
                  }
              }
          }
      }
  }
  return newTab;
}

上面的逻辑主要划分为两块

  • 新的数组长度确定,并初始化新的数组
  • 将原来的数据迁移到新的数组中
    • 遍历旧数组元素
    • 若Node没有尾节点(Next为null),则直接塞入新的数组
    • 判断Node的数据结构,红黑树和链表逻辑有区分
    • 对于链表格式,新的坐标要么是原来的位置,要么是原来的位置+原数组长度,链表顺序不变

说明

这个扩容的逻辑还是比较有意思的,最后面给一个测试case,来看一下扩容前后的数据位置

4. 删除元素

删除的逻辑和上面的大致类似,显示确定节点,然后从整个数据结构中移除引用

代码语言:javascript
复制
final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 删除的前置条件:
    // 1. 数组已经初始化
    // 2. key对应的Node节点存在
    if ((tab = table) != null && (n = tab.length) > 0 
      && (p = tab[index = (n - 1) & hash]) != null) { 
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash && 
          ((k = p.key) == key || (key != null && key.equals(k)))) {
          // 数组中的Node节点即为目标
            node = p;
        } else if ((e = p.next) != null) {
          // hash碰撞,目标可能在链表or红黑树中
          // 便利链表or红黑树,确定目标
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        if (node != null && 
            (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 找到目标节点,直接从数组or红黑树or链表中移除
            // 不改变Node节点的内容
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

测试

上面的几个常用方法的逻辑大致相同,核心都是在如何找到目标Node节点,其中比较有意思的一点是数组的扩容,旧元素的迁移逻辑,下面写个测试demo来演示一下

首先定义一个Deom对象,覆盖hashCode方法,确保第一次重新分配数组时,正好需要迁移

代码语言:javascript
复制
public static class Demo {
  public int num;

  public Demo(int num) {
      this.num = num;
  }

  @Override
  public boolean equals(Object o) {
      if (this == o) return true;
      if (o == null || getClass() != o.getClass()) return false;

      Demo demo = (Demo) o;

      return num == demo.num;
  }

  @Override
  public int hashCode() {
      return num % 3 + 16;
  }
}


@Test
public void testMapResize() {
  Map<Demo, Integer> map = new HashMap<>();
  for(int i = 1; i < 12; i++) {
      map.put(new Demo(i), i);
  }

  // 下面这一行执行,并不会触发resize方法
  map.put(new Demo(12), 12);
  // 执行下面这一行,会触发HashMap的resize方法
  // 因为 hashCode值 & 16 == 1,所以新的位置会是原来的位置+16
  map.put(new Demo(13), 13);
}

实际演示示意图

showdemo.gif
showdemo.gif

小结

1. 根据Key定位Node节点

  • key计算hash,hash值对数组长度取余即为数组中的下标
    • hash & (len - 1) === hash % len
  • 以数组中Node为链表头or红黑树根节点遍历,确认目标节点
    • 判断逻辑:
    • hash值相同
    • key1 == key2 or key1.quals(key2)

2. 扩容逻辑

  • 当添加元素后,数组的长度超过阀值,实现扩容
    • 初始容量为16,阀值为12
  • 计算新的数组长度,并初始化
    • 新的长度为原来的长度 * 2
    • 新的阀值为 新的长度 * loadFactor; loadFactory 一般为 0.75
  • 将原来的数据迁移到新的数组
    • 原位置不变 (hash % 原长度 == 0)
    • 原位置 + 原数组长度 (hash % 原长度 == 1)

3. 其他

  • jdk1.8 之后,当链表长度超过阀值(8)后,转为红黑树
  • 新增元素,在添加完毕之后,再判断是否需要扩容
  • 删除元素不会改变Node对象本身,只是将其从Map的数据结构中 出来
  • Map如何退化为链表
    • 一个糟糕的hashCode方法即可模拟实现,如我们上面的测试用例
    • 红黑树会使这种退化的效果不至于变得那么糟糕

相关博文

  • JDK容器学习之HashMap (一) : 底层存储结构分析

关注更多

扫一扫二维码,关注 小灰灰blog

参考

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Map读写实现逻辑说明
    • 1. 根据key索引
      • 2. TreeNode 分析
        • 1. 数据结构
        • 2. 内部方法
        • 3. TreeNode 方式的HashMap存储结构
      • 3. 添加kv对
        • 4. 删除元素
        • 测试
        • 小结
          • 1. 根据Key定位Node节点
            • 2. 扩容逻辑
              • 3. 其他
              • 关注更多
              • 参考
              相关产品与服务
              容器服务
              腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档