HashMap源码分析

HashMap源码分析

数组结构

HashMap由数组+链表/红黑树 组成。当数组某一元素Hash冲突达到8时,会转变为红黑树。当长度小于6时,又会转变为链表

基本属性

// 实际存储的key-value键值对的个数
transient int size;

// 阈值,当table == {}时,该值为初始容量(16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory
// HashMap在进行扩容时需要参考threshold
int threshold;

// 负载因子,代表了table的填充度有多少,默认是0.75。加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了
final float loadFactor;

// HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

// HashMap的主干数组,初始值为空数组{},主干数组的长度一定是2的次幂。也是拉链的Head节点
transient Node<K,V>[] table;

Node链

HashMap的内部类Node——节点,实现了Map.Entry接口

static class Node<K,V> implements Map.Entry<K,V> {
  // key的哈希值
  final int hash;
  // 节点的key,类型和定义HashMap时的key相同
  final K key;
  // 节点的value,类型和定义HashMap时的value相同
  V value;
  // 该节点的下一节点
  Node<K,V> next
    // ... 
}

值得注意的是其中的next属性,记录的是下一个节点本身,也是一个Node节点,这个Node节点也有next属性,记录了下一个节点,于是,只要不断的调用Node.next.next.next……,就可以得到:

Node-->下个Node-->下下个Node……-->null

对于一个HashMap来说,只要明确记录每个链表的第一个节点,就能顺序遍历链表上的所有节点

// 红黑树节点  继承自LinkedHashMap.Entry,而LinkedHashMap.Entry继承自HashMap.Node
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
  // 父节点
  TreeNode<K,V> parent;  // red-black tree links
  // 左节点
  TreeNode<K,V> left;
  // 右节点
  TreeNode<K,V> right;
  // 看其父继承关系,其属性与LinkedHashMap顺序访问性有关
  TreeNode<K,V> prev;    // needed to unlink next upon deletion
  boolean red;
}

重要API分析

构造方法

HashMap有4个构造器,如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值

注意:构造函数中不会初始化HashMap中最重要的属性table,而是在第一次put时通过resize来完成初始化

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

看下最复杂的一个

public HashMap(int initialCapacity, float loadFactor) {
  //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " +
                                       initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " +
                                       loadFactor);
  this.loadFactor = loadFactor;
  this.threshold = tableSizeFor(initialCapacity);
}
// JDK11在这里存在变动,确保 threshold 为2的次幂
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;
}

PUT方法

public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
  int h;
  // 这里为什么要采用 异或 运算,而不是 与/或 运算之类的,因为只有异或运算能够保存0/1出现的概率各为50%(四种情况:00、01、10、11),可以让值分布的更加均匀
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里的Hash算法本质上就是三步:取key的hashCode值、高位运算

在JDK1.8的实现中,优化了高位运算算法,通过hashCode()的高16位异或低16位实现的,这样做可以在数组table的length比较小的时候,也能保证考虑到高低Bit参与到Hash计算中,同时不会有太大的开销

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  // tab为全局变量table的副本
  // p为待节点节点构造值
  // n为table数组长度
  // i为待插入元素所在数组下标
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  // <1> 第一次调用put时,tab为空,n为0      完成变量tab/n的赋值
  if ((tab = table) == null || (n = tab.length) == 0)
    // 通过resize方法来创建一个新的数组
    n = (tab = resize()).length;
  // <2> 判断插入位置是否冲突,如果不冲突就直接newNode,插入到数组中即可 完成变量p的赋值
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  // <3> 插入元素hash值冲突,处理冲突
  else {
    // e保存待插入元素信息   k保存链表中某一节点的key信息
    Node<K,V> e; K k;
    // <3.1> 判断table[i]中head是否与插入的key相同,若相同那就直接使用p替换掉旧的值e
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    // <3.2> 判断插入的数据结构是红黑树,那就直接putTreeVal到红黑树中
    else if (p instanceof TreeNode)
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
      for (int binCount = 0; ; ++binCount) {
        // <3.3> 遍历链表,若不存在就直接newNode
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
  				// 若长度达到8个,则达到转换为红黑树的条件
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  ++modCount;
  // <4> 判断一下实际存在的键值对数量size是否大于阈值threshold。如果大于那就开始扩容了
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}

最后附上一张图:
在这里插入图片描述

扩容

扩容分两步:

  1. 将数组的长度变为原来的两倍

  2. 将已经hash分布到数组中的所有元素重新计算hash值,分配到新的数组中

第一步操作只需增加一块存储区域而已,而第二步操作则需要消耗巨大的计算资源。如果扩容前已经存在5万个元素,则需要把这5万个元素的hash值重新计算一遍,并根据新的结果移动它的位置。该操作叫做重哈希操作,是一次代价极高的操作

注意:此步操作可能会导致提前触发Full GC,特别是对于CMS这种标记清除的GC可能会触发二次Full GC。所以可以确认长度,最好提前设定

长度设定标准为:长度/loadFactor 向上取整

final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table;
  // 初始时,oldTab为空
  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;
    }
    // 如果没有超过最大容量,就扩容为原来的2倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
      newThr = oldThr << 1; // double threshold
  }
  // ***  有参构造器,在这里完成赋值,newCap为16
  else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;
  else {               // zero initial threshold signifies using defaults
    // 空参构造器 在这里完成赋值,初始化一个默认的容量和阈值
    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)
          // 重新计算元素在新table中的位置
          // 注意:元素所处位置只有可能在 j 与 j+oldCap 处         ①
          newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof TreeNode)
           // 树元素重hash
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        // 链表元素重hash
        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) {
              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);
          // 原索引放到bucket里
          if (loTail != null) {
            loTail.next = null;
            newTab[j] = loHead;
          }
          // 原索引+oldCap放到bucket里
          if (hiTail != null) {
            hiTail.next = null;
            newTab[j + oldCap] = hiHead;
          }
        }
      }
    }
  }
  return newTab;
}

①使用的是2次幂的扩展。所以,元素的位置要么是在原位置,要么是在原位置+初始长度的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两key确定索引位置的示例,图(b)表示扩容后key1和key2两key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果
在这里插入图片描述

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”

这个设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突节点分散到新的bucket中。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置
在这里插入图片描述

针对JDK1.8中新增的API可以自行分析一下,具体使用可以参考

getOrDefault

replace

replaceAll

compute

computeIfAbsent

computeIfPresent

putIfAbsent

merge

小结

  1. 扩容是一个特别耗性能的操作,所以当coder在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容

  2. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊

  3. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap

OrDefault

replace

replaceAll

compute

computeIfAbsent

computeIfPresent

putIfAbsent

merge

小结

  1. 扩容是一个特别耗性能的操作,所以当coder在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容

  2. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊

  3. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap

  4. JDK1.8引入红黑树大程度优化了HashMap的性能

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

①笶侕濄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值