JDK1.8 Collection知识点与代码分析--HashMap

本文深入探讨了JDK1.8中HashMap的实现,包括常量、构造器、tableSize()、put过程、hash计算、resize机制、树化策略等关键知识点。分析了为何容量需为2的幂,以及头插法变尾插法的原因。同时,文章提到了并发使用HashMap可能导致的问题及其原因。
摘要由CSDN通过智能技术生成

HashMap

在了解HashMap之前, 首先谈一谈经常和它一起出现的HashTable.Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用. TreeMap是基于红黑树实现的Map类, 其put,get操作的时间复杂度在O(logN), 但是它的键值存储是有序的, 顺序与compareToComparator有关.

下面是Map家族的结构概览
在这里插入图片描述
在以上的类中, 如果需要常数时间复杂度的增删改查, 并且对顺序没有要求, 最常用的就是HashMap类. 哈希是通过哈希函数, 将任意长度的输入变成固定长度的输出, HashMap正是利用这一性质, 将键值对的特征(hash)转化为固定长度的整型输出, 将输出作为数组下标, 将键值对存储到数组中, 由于数组的随机访问的性能, 能够保证其常数时间的读写.

用到hash的地方, 必须要考虑的问题就是哈希冲突的解决, 哈希冲突的解决思路有以下几种:

  • 开放定址法: 当发生冲突时, 采用线性探测或二次探测等方法, 在初始位置的周围寻找一个空的位置放置. 封闭哈希, 元素数量不能超过桶数量, 高负载下性能下降严重
  • 再哈希法: 当第一个hash函数产生的值发生碰撞, 用第二个hash函数再产生一个值
  • 链地址法: 也就是拉链法, 每个地址相当于一个桶, 当发生冲突时, 还是将键值对放到桶中, 同一个桶的键值对构成链表. 适用频繁插入删除的情况
  • 溢出区法: 专门建立一个溢出区, 将发生碰撞的元素用链表存储起来. 和拉链法的思想相似, 但是溢出集中时, 性能不如拉链法.

HashMap采用的方法是拉链法, 当同时当元素数/桶数达到一定比例(负载因子)时, 进行扩容. 接下来分析源码. 以下分析基于JDK1.8

HashMap#常量

首先是HashMap中有几个重要的常量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 HashMap的默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap的最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;  // 推荐的负载因子, 通常情况下, 不用修改
static final int TREEIFY_THRESHOLD = 8; // 单个桶中元素数量, 达到树化门限则会从链表调整为红黑树, 防止哈希碰撞拒绝服务攻击
static final int UNTREEIFY_THRESHOLD = 6; // 单个桶中数量小于门限, 则会退化成链表
static final int MIN_TREEIFY_CAPACITY = 64; // 最小的树化容量, 当Map的容量小于该值时, 如果一个桶的元素过多, 会首先采用扩容方法尝试缓解. 超过该值时, 如果达到TREEIFY-THRESH则会树化

HashMap#构造器

从构造器开始看, HashMap提供了以下几种构造器

public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)

后面的几种构造函数, 无非就是将一些参数设为了默认值, 我们直接看第一个构造器

    public HashMap(int initialCapacity, float loadFactor) {
        // 判断initialCapacity和loadFactor的合法性, 大于0, 不为NaN等
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

这个构造器总共就做了两个参数的赋值, 没有其他实际的创建结构, 所以这里其实采用了懒惰加载的方法, 在真正调用的时候,才去初始化内部的结构.

HashMap#tableSize()

然后这里的tableSizeFor()方法, 其目的是将输入的容量, 转换为大于等于输入容量的2的幂.为什么HashMap的容量总要保持2的幂, 我们之后再讨论, 但是这里好像还是不对.

返回值为什么是赋值给的threshold, 这个参数不是扩容的门限值么?
其实这个参数的解释是: 如果table数组还没有被创建, 这个参数等于数组的初始大小, 如果为0则采用默认初始大小.

接着我们看一看tableSizeFor是怎么转成2的幂的

// 1.8的实现
static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

// 早期版本的实现
int capacity = 1;
while (capacity < initialCapacity)
    capacity <<= 1;  

从早期版本实现, 我们很容易看懂, 就是找一个最小的2的幂, 大于等于initalCapacity嘛, 但是新版的好像就有点懵了, 这是在干什么, 我们不妨举个例子.

  1. 假设我们的输入是65, 二进制0100 0001, 减1得到0100 0000
  2. 注意观察最高位的1, 当第一次右移, 然后做或运算, (0100 0000 |0010 0000) = 0110 0000
  3. 这样最高位的1就变成了两个1, 接着做右移 2位, 然后或运算, (0110 0000 | 0001 1000) = (0111 1000)
  4. 现在从最高位往右就有至少4个1了, 再用它们往右移4位覆盖低位, 最终得到的从最高位开始往右都是1的结果, 0111 1111
  5. 最后加1, 得到了1000 0000, 128

这样相比原来循环1位一位往上移, 又快了一些, 如果还没有看懂, 可以看下这个参考资料, 里面带了一些示意图.

HashMap#put

接下来看下具体的初始化工作. 初始化发生在第一次put, 这个函数只有一行, 但是有个细节, 这里用hash函数处理了key, 然后再进行实际的putVal操作.

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

HashMap#hash

我一开始以为, hash函数只是返回了key.hashCode()的值, 然后发现并不是.

// 1.8
static final int hash(Object key) {
       int h;
       return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
   }
// 以往版本
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

在 JDK1.8 的实现中,将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。为什么比以往版本的hash有所简化, 我所看到的资料主张是因为加入了树化后, 碰撞情况的查找成本小了, 所以hash的计算可以简化.

HashMap#putVal

这个函数是整个类中, 逻辑最密集的之一, 所以相当精彩, 函数中要做的包括懒惰加载-初始化, 检查是否key已经存在, 检查是否要树化等.

/**
     * Implements Map.put and related methods
     *
     * @param hash 经过hash函数得到的hash值
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent 为true时, 如果已经存在就不修改
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0) // 如果首次调用, 进行初始化
            n = (tab = resize()).length; // resize函数进行初始化
        if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash相当于hash对n取模, 详细看后面的解释. 
        // 如果找到的桶为空, 则key肯定不存在
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // p是桶的头节点 e是p的后一个节点, 两个指针一前一后遍历链表
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) // 如果头结点就是要找的key的node
                e = p;
            else if (p instanceof TreeNode) // 如果头节点是个树节点, 则调用树插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 如果有key, e=node 否则e = null
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) { // 如果整个链没有找到, e= null
                        p.next = newNode(hash, key, value, null); // 创建一个新节点
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash); // 如果达到树化条件, 则树化或扩容
                        break;
                    }
                    if (e.hash == hash && // 找到了key
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e; // 和e = p.next 构成链表往下走
                }
            }
            if (e != null) { // existing mapping for key
            // 找到key所在节点, 修改value并返回旧值   
            }
        }
        ++modCount;
        if (++size > threshold) // 超过门限, 扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

这里留个悬念, 为什么在创建新节点的时候, 调用的是newNode()方法而不是直接new Node()产生一个对象? 具体原因请看JDK1.8 Collection知识点与代码分析–LinkedHashMap(LinkedHashMap需要将新节点连接到链表上)

HashMap#resize

resize函数是另一个重要的方法, 其功能包括初始化和扩容

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) {
               // 不进行扩容
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else 
        //  初始化时, thresh的值不为0, 则按照thresh初始化, 否则按照默认的初始化容量初始化
        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; // 创建新的table
        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)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 让树自己拆分并放置到新数组中
                    else { // preserve order // 尾插法保持顺序
                    // 如果(e.hash & oldCap) == 0 则留在原桶中, 否则进入新桶
                    }
                }
            }
        }
        return newTab;
    }

HashMap#Treeify

树化是JDK1.8中HashMap实现的一大亮点, 代码量也很多, 如果一起分析的话, 篇幅过长了, 但是这个放一个关于树化部分的详细分析的文章, 供大家参考. 本文中, 仅对几个和树化相关的非常有意思的核心方法进行分析.

树化的整体流程如下: treeifyBin方法把一个Bin链表上的节点全部包装成TreeNode, 然后由treeify对链表进行建树, log(N)的时间复杂度下将bin中的元素插入到新建的树中, 每插入一个元素需要通过balanceInsertion对树进行再平衡.

我们直接来看treeify的方法源码:

final void treeify(Node<K,V>[] tab) {
      TreeNode<K,V> root = null;
        for (TreeNode<K,V> x = this, next; x != null; x = next) {
            next = (TreeNode<K,V>)x.next;
            x.left = x.right = null;
            if (root == null) {
                x.parent = null;
                x.red = false;
                root = x;
            }
            else {
                // 当不是根节点时,
                K k = x.key;
                int h = x.hash;
                Class<?> kc = null;
                for (TreeNode<K,V> p = root;;) {
                    int dir, ph; // dir表示方向 -1 左, 1 右
                    K pk = p.key;
                    if ((ph = p.hash) > h)
                        dir = -1;
                    else if (ph < h)
                        dir = 1;
                    // hash 相等的情况
                    else if ((kc == null &&
                              (kc = comparableClassFor(k)) == null) || // 如果class实现了Comparable 返回class 否则返回null
                             (dir = compareComparables(kc, k, pk)) == 0) // 如果pk的getClass是kc, 返回k.compareTo(pk), 否则返回0
                        // 如果两者不可比较
                        // 如果k, pk的class名不同, 比较两个string
                        // 如果class相同或者两个之间有至少一个null, 比较Object.hashCode返回的地址
                        dir = tieBreakOrder(k, pk);

                    TreeNode<K,V> xp = p; // 记住当前p, 尝试左右儿子
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;
                        else
                            xp.right = x;

                        // 通过log(n)的复杂度找到插入位置, 插入后, 重新做balance
                        root = balanceInsertion(root, x);
                        break;
                    }
                }
            }
        }
        moveRootToFront(tab, root);
    }

这个方法中有一步调用了tieBreakOrder方法, 这个方法的具体功能我在注释中进行了解释, 但是这个方法非常有意思, 一个二叉搜索树需要是严格有序的, 在map中又允许null作为key(只有一个) 因此这个方法的作用就是在两个key不可比的时候, 通过稳定的比较方法计算两者谁更大, 保证之后查找这个key的时候, 可以通过该tieBreakOrder方法, 再次找到保存在树中的key.

在上述方法的最后, 调用了balanceInsertion方法进行再平衡, 该方法也就是最关键的判断是否左右旋, flipColor逻辑的代码, 我这里直接贴上我自己的对balanceInsertion方法的注释贴上来, 作为资料的补充

static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                              TreeNode<K,V> x) {
      x.red = true; // 2-3树尝试同一层插入
      for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // xp x-parent, xpp x-parent-parent, xppl xpp-left, xppr xpp-right
          // 找到root节点了
          if ((xp = x.parent) == null) {
              x.red = false;
              return x;
          }
          // 如果父节点就是root节点,
          // 如果父节点非红, 不需要提高层数, 注意这里没有要求只有左儿子能是红边
          else if (!xp.red || (xpp = xp.parent) == null)
              return root;
          // 1. x的父节点是红边, 遇到了两个红边相连的状态
          // 2. x的祖父xpp不为空

          // 父节点是祖先节点的左儿子
          if (xp == (xppl = xpp.left)) {
              // 祖先节点的左儿子是红边
              // 如果祖先节点的右儿子也是红边, flip
              // 这是由于将xpp变成红色以后可能与xpp的父节点发生两个相连红色节点的冲突,这就又构成了第二种旋转变换,所以必须从底向上的进行变换,直到根。
              // 所以令x = xpp,然后进行下下一层循环,接着往上走。
              if ((xppr = xpp.right) != null && xppr.red) {
                  xppr.red = false;
                  xp.red = false;
                  xpp.red = true;
                  x = xpp; // 转到这个flip后持有红边的祖先节点
              }
              else {
                  // 进入else说明 祖先节点xpp的右儿子不是红边
                  if (x == xp.right) {
                      // x是右儿子,
                  /*
                    xpp
                    /
                  xp红
                     \
                     x红
                     需要先左旋, 再右旋
                   */
                      root = rotateLeft(root, x = xp);
                      xpp = (xp = x.parent) == null ? null : xp.parent;
                  }
                  if (xp != null) {
                      /*
                            xpp
                            /
                          xp红
                           /
                         x红
                     需要xp右旋, flip
                   */
                      xp.red = false;
                      if (xpp != null) {
                          xpp.red = true;
                          root = rotateRight(root, xpp);
                      }
                  }
              }
          }
          // 父节点是祖先节点的右儿子, 且父节点为红节点
          else {
              // flip
              if (xppl != null && xppl.red) {
                  xppl.red = false;
                  xp.red = false;
                  xpp.red = true;
                  x = xpp;
              }
              else {
                  if (x == xp.left) {
                      /*
                        xpp
                          \
                          xp红
                          /
                         x红
                         需要先x右旋, 再xp左旋
                       */
                      root = rotateRight(root, x = xp);
                      xpp = (xp = x.parent) == null ? null : xp.parent;
                  }
                  if (xp != null) {
                      /*
                        xpp
                          \
                          xp红
                             \
                             x红
                         需要xp左旋, 再flip
                       */
                      xp.red = false;
                      if (xpp != null) {
                          xpp.red = true;
                          root = rotateLeft(root, xpp);
                      }
                  }
              }
          }
      }
  }
  
 static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                       TreeNode<K,V> p) {
     /*
           pp          pp
           /           /
           p          r
            \        /
             r      p
            /        \
           rl         rl
           
           或
           
           pp          pp
            \           \
             p          r
              \        /
              r       p
              /        \
             rl         rl
            
     */
     TreeNode<K,V> r, pp, rl;
     if (p != null && (r = p.right) != null) {
         if ((rl = p.right = r.left) != null)
             rl.parent = p;
         if ((pp = r.parent = p.parent) == null) // 说明p就是root
             (root = r).red = false; 
         else if (pp.left == p)
             pp.left = r;
         else
             pp.right = r;
         r.left = p;
         p.parent = r;
     }
     return root;
 }

HashMap常考知识点总结

  • 为什么要保证容量是2的幂?
    对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
    但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式: x m o d 2 n = x & ( 2 n − 1 ) x mod 2^n = x \& (2^n - 1) xmod2n=x&(2n1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。

  • 为什么要链表要从头插法变成尾插法?
    头插法还是尾插法指的都是resize函数, 在将节点移动到新的桶中时, 节点如何插入的方法, 头插法的考量是后插入的数据可能是热点数据, 头插更容易访问, 但是存在的问题是头插法在每次resize的时候, 节点之间的前后关系都是倒置, 所以这种优化并不成立. 然而, 在下面的竞态条件下,头插法可能引起链表成环, 而尾插法不会, 因此当被错误用在并发情形下, 尾插法只有可能丢失一部分数据, 而头插法会导致死循环, 彻底不可用.

  • 如果把HashMap用在并发情形下, 会导致的问题?
    出现很典型的竞态条件, 在resize中将链表变成环导致resize无限循环, cpu占用, HashMap无法正常继续.具体情形这篇博客讲的很清楚, 还带有配图.

其他参考资料
Java集合:HashMap详解(JDK 1.8)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值