Java基础:HashMap详解二

前言

承接前文,继续来。


在了解 如何计算存放数组table 中的位置 后,所谓 知其然 而 需知其所以然,下面我将讲解为什么要这样计算,即主要解答以下3个问题:

  1. 为什么不直接采用经过hashCode()处理的哈希码 作为 存储数组table的下标位置?
  2. 为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
  3. 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

在回答这3个问题前,请大家记住一个核心思想:

所有处理的根本目的,都是为了提高 存储key-value的数组下标位置 的随机性 & 分布均匀性,尽量避免出现hash值冲突。即:对于不同key,存储的数组下标位置要尽可能不一样

问题1:为什么不直接采用经过hashCode()处理的哈希码 作为 存储数组table的下标位置?

  • 结论:容易出现 哈希码 与 数组大小范围不匹配的情况,即 计算出来的哈希码可能 不在数组大小范围内,从而导致无法匹配存储位置
  • 原因描述
image
  • 为了解决 “哈希码与数组大小范围不匹配” 的问题,HashMap给出了解决方案:哈希码 与运算(&) (数组长度-1);请继续问题2

问题2:为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?

  • 结论:根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题

  • 具体解决方案描述

image

问题3:为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

  • 结论:加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突

  • 具体描述

image

至此,关于怎么计算 key-value 值存储在HashMap数组位置 & 为什么要这么计算,讲解完毕。


分析4:若对应的key已存在,则 使用 新value 替换 旧value

注:当发生 Hash冲突时,为了保证 键key的唯一性哈希表并不会马上在链表中插入新数据,而是先查找该 key是否已存在,若已存在,则替换即可

   /**
     * 函数使用原型
     */
// 2\. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 2.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue; //并返回旧的value
            }
        }

        modCount++;

        // 2.2 若 该key不存在,则将“key-value”添加到table中
        addEntry(hash, key, value, i);
        return null;

  • 此处无复杂的源码分析,但此处的分析点主要有2个:替换流程 & key是否存在(即key值的对比)

分析1:替换流程

具体如下图:

image

分析2:key值的比较

采用 equals() 或 "==" 进行比较,下面给出其介绍 & 与 “==”使用的对比

image

分析5:若对应的key不存在,则将该“key-value”添加到数组table的对应位置中

  • 函数源码分析如下
      /**
        * 函数使用原型
        */
       // 2\. 判断该key对应的值是否已存在
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 2.1 若该key对应的值已存在,则用新的value取代旧的value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this); 
                return oldValue; 
            }
        }

        modCount++;

        // 2.2 若 该key对应的值不存在,则将“key-value”添加到table中
        addEntry(hash, key, value, i);

   /**
     * 源码分析:addEntry(hash, key, value, i)
     * 作用:添加键值对(Entry )到 HashMap中
     */
      void addEntry(int hash, K key, V value, int bucketIndex) {  
          // 参数3 = 插入数组table的索引位置 = 数组下标

          // 1\. 插入前,先判断容量是否足够
          // 1.1 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
          if ((size >= threshold) && (null != table[bucketIndex])) {  
            resize(2 * table.length); // a. 扩容2倍  --> 分析1
            hash = (null != key) ? hash(key) : 0;  // b. 重新计算该Key对应的hash值
            bucketIndex = indexFor(hash, table.length);  // c. 重新计算该Key对应的hash值的存储数组下标位置
    }  

    // 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中--> 分析2
    createEntry(hash, key, value, bucketIndex);  
}  

 /**
   * 分析1:resize(2 * table.length)
   * 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
   */ 
   void resize(int newCapacity) {  

    // 1\. 保存旧数组(old table) 
    Entry[] oldTable = table;  

    // 2\. 保存旧容量(old capacity ),即数组长度
    int oldCapacity = oldTable.length; 

    // 3\. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  

    // 4\. 根据新容量(2倍容量)新建1个数组,即新table  
    Entry[] newTable = new Entry[newCapacity];  

    // 5\. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 
    transfer(newTable); 

    // 6\. 新数组table引用到HashMap的table属性上
    table = newTable;  

    // 7\. 重新设置阈值  
    threshold = (int)(newCapacity * loadFactor); 
} 

 /**
   * 分析1.1:transfer(newTable); 
   * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
   * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
   */ 
void transfer(Entry[] newTable) {
      // 1\. src引用了旧数组
      Entry[] src = table; 

      // 2\. 获取新数组的大小 = 获取新容量大小                 
      int newCapacity = newTable.length;

      // 3\. 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中
      for (int j = 0; j < src.length; j++) { 
          // 3.1 取得旧数组的每个元素  
          Entry<K,V> e = src[j];           
          if (e != null) {
              // 3.2 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
              src[j] = null; 

              do { 
                  // 3.3 遍历 以该数组元素为首 的链表
                  // 注:转移链表时,因是单链表,故要保存下1个结点,否则转移后链表会断开
                  Entry<K,V> next = e.next; 
                 // 3.4 重新计算每个元素的存储位置
                 int i = indexFor(e.hash, newCapacity); 
                 // 3.5 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中
                 // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
                 e.next = newTable[i]; 
                 newTable[i] = e;  
                 // 3.6 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
                 e = next;             
             } while (e != null);
             // 如此不断循环,直到遍历完数组上的所有数据元素
         }
     }
 }

 /**
   * 分析2:createEntry(hash, key, value, bucketIndex);  
   * 作用: 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
   */  
void createEntry(int hash, K key, V value, int bucketIndex) { 

    // 1\. 把table中该位置原来的Entry保存  
    Entry<K,V> e = table[bucketIndex];

    // 2\. 在table中该位置新建一个Entry:将原头结点位置(数组上)的键值对 放入到(链表)后1个节点中、将需插入的键值对 放入到头结点中(数组上)-> 从而形成链表
    // 即 在插入元素时,是在链表头插入的,table中的每个位置永远只保存最新插入的Entry,旧的Entry则放入到链表中(即 解决Hash冲突)
    table[bucketIndex] = new Entry<>(hash, key, value, e);  

    // 3\. 哈希表的键值对数量计数增加
    size++;  
}   

此处有2点需特别注意:键值对的添加方式 & 扩容机制

1. 键值对的添加方式:单链表的头插法

  • 即 将该位置(数组上)原来的数据放在该位置的(链表)下1个节点中(next)、在该位置(数组上)放入需插入的数据-> 从而形成链表
  • 如下示意图
image

2. 扩容机制

  • 具体流程如下:
image
  • 扩容过程中的转移数据示意图如下
image

在扩容resize()过程中,在将旧数组上的数据 转移到 新数组上时,转移操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况

设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1

  • 此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态 = 线程不安全

下面最后1节会对上述情况详细说明

总结

  • HashMap 添加数据(成对 放入 键 - 值对)的全流程
image
  • 示意图

    image

至此,关于 “向 HashMap 添加数据(成对 放入 键 - 值对)“讲解完毕


步骤3:从HashMap中获取数据

  • 假如理解了上述put()函数的原理,那么get()函数非常好理解,因为二者的过程原理几乎相同
  • get()函数的流程如下:
image
  • 具体源码分析如下
/**
   * 函数原型
   * 作用:根据键key,向HashMap获取对应的值
   */ 
   map.get(key);

 /**
   * 源码分析
   */ 
   public V get(Object key) {  

    // 1\. 当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键
    if (key == null)  
        return getForNullKey(); --> 分析1

    // 2\. 当key ≠ null时,去获得对应值 -->分析2
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();  
}  

 /**
   * 分析1:getForNullKey()
   * 作用:当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键
   */ 
private V getForNullKey() {  

    if (size == 0) {  
        return null;  
    }  

    // 遍历以table[0]为头结点的链表,寻找 key==null 对应的值
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {  

        // 从table[0]中取key==null的value值 
        if (e.key == null)  
            return e.value; 
    }  
    return null;  
}  

 /**
   * 分析2:getEntry(key)
   * 作用:当key ≠ null时,去获得对应值
   */  
final Entry<K,V> getEntry(Object key) {  

    if (size == 0) {  
        return null;  
    }  

    // 1\. 根据key值,通过hash()计算出对应的hash值
    int hash = (key == null) ? 0 : hash(key);  

    // 2\. 根据hash值计算出对应的数组下标
    // 3\. 遍历 以该数组下标的数组元素为头结点的链表所有节点,寻找该key对应的值
    for (Entry<K,V> e = table[indexFor(hash, table.length)];  e != null;  e = e.next) {  

        Object k;  
        // 若 hash值 & key 相等,则证明该Entry = 我们要的键值对
        // 通过equals()判断key是否相等
        if (e.hash == hash &&  
            ((k = e.key) == key || (key != null && key.equals(k))))  
            return e;  
    }  
    return null;  
}  

至此,关于 “向 HashMap 获取数据 “讲解完毕


步骤4:对HashMap的其他操作

即 对其余使用API(函数、方法)的源码分析

  • HashMap除了核心的put()get()函数,还有以下主要使用的函数方法
void clear(); // 清除哈希表中的所有键值对
int size();  // 返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对
boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为 空 

void putAll(Map<? extends K, ? extends V> m);  // 将指定Map中的键值对 复制到 此Map中
V remove(Object key);  // 删除该键值对

boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true
boolean containsValue(Object value);  // 判断是否存在该值的键值对;是 则返回true

  • 下面将简单介绍上面几个函数的源码分析
  /**
   * 函数:isEmpty()
   * 作用:判断HashMap是否为空,即无键值对;size == 0时 表示为 空 
   */

public boolean isEmpty() {  
    return size == 0;  
} 

 /**
   * 函数:size()
   * 作用:返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对
   */

   public int size() {  
    return size;  
}  

 /**
   * 函数:clear()
   * 作用:清空哈希表,即删除所有键值对
   * 原理:将数组table中存储的Entry全部置为null、size置为0
   */ 
public void clear() {  
    modCount++;  
    Arrays.fill(table, null);
    size = 0;
}  

/**
   * 函数:putAll(Map<? extends K, ? extends V> m)
   * 作用:将指定Map中的键值对 复制到 此Map中
   * 原理:类似Put函数
   */ 

    public void putAll(Map<? extends K, ? extends V> m) {  
    // 1\. 统计需复制多少个键值对  
    int numKeysToBeAdded = m.size();  
    if (numKeysToBeAdded == 0)  
        return; 

    // 2\. 若table还没初始化,先用刚刚统计的复制数去初始化table  
    if (table == EMPTY_TABLE) {  
        inflateTable((int) Math.max(numKeysToBeAdded * loadFactor, threshold));  
    }  

    // 3\. 若需复制的数目 > 阈值,则需先扩容 
    if (numKeysToBeAdded > threshold) {  
        int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1);  
        if (targetCapacity > MAXIMUM_CAPACITY)  
            targetCapacity = MAXIMUM_CAPACITY;  
        int newCapacity = table.length;  
        while (newCapacity < targetCapacity)  
            newCapacity <<= 1;  
        if (newCapacity > table.length)  
            resize(newCapacity);  
    }  
    // 4\. 开始复制(实际上不断调用Put函数插入)  
    for (Map.Entry<? extends K, ? extends V> e : m.entrySet())  
        put(e.getKey(), e.getValue());
}  

 /**
   * 函数:remove(Object key)
   * 作用:删除该键值对
   */ 

public V remove(Object key) {  
    Entry<K,V> e = removeEntryForKey(key);  
    return (e == null ? null : e.value);  
}  

final Entry<K,V> removeEntryForKey(Object key) {  
    if (size == 0) {  
        return null;  
    }  
    // 1\. 计算hash值
    int hash = (key == null) ? 0 : hash(key);  
    // 2\. 计算存储的数组下标位置
    int i = indexFor(hash, table.length);  
    Entry<K,V> prev = table[i];  
    Entry<K,V> e = prev;  

    while (e != null) {  
        Entry<K,V> next = e.next;  
        Object k;  
        if (e.hash == hash &&  
            ((k = e.key) == key || (key != null && key.equals(k)))) {  
            modCount++;  
            size--; 
            // 若删除的是table数组中的元素(即链表的头结点) 
            // 则删除操作 = 将头结点的next引用存入table[i]中  
            if (prev == e) 
                table[i] = next;

            //否则 将以table[i]为头结点的链表中,当前Entry的前1个Entry中的next 设置为 当前Entry的next(即删除当前Entry = 直接跳过当前Entry)
            else  
                prev.next = next;   
            e.recordRemoval(this);  
            return e;  
        }  
        prev = e;  
        e = next;  
    }  

    return e;  
} 

 /**
   * 函数:containsKey(Object key)
   * 作用:判断是否存在该键的键值对;是 则返回true
   * 原理:调用get(),判断是否为Null
   */
   public boolean containsKey(Object key) {  
    return getEntry(key) != null; 
} 

 /**
   * 函数:containsValue(Object value)
   * 作用:判断是否存在该值的键值对;是 则返回true
   */   
public boolean containsValue(Object value) {  
    // 若value为空,则调用containsNullValue()  
    if (value == null)
        return containsNullValue();  

    // 若value不为空,则遍历链表中的每个Entry,通过equals()比较values 判断是否存在
    Entry[] tab = table;
    for (int i = 0; i < tab.length ; i++)  
        for (Entry e = tab[i] ; e != null ; e = e.next)  
            if (value.equals(e.value)) 
                return true;//返回true  
    return false;  
}  

// value为空时调用的方法  
private boolean containsNullValue() {  
    Entry[] tab = table;  
    for (int i = 0; i < tab.length ; i++)  
        for (Entry e = tab[i] ; e != null ; e = e.next)  
            if (e.value == null)
                return true;  
    return false;  
} 

至此,关于HashMap的底层原理 & 主要使用API(函数、方法)讲解完毕。


6. 源码总结

下面,用3个图总结整个源码内容:

总结内容 = 数据结构、主要参数、添加 & 查询数据流程、扩容机制

  • 数据结构 & 主要参数

    image
  • 添加 & 查询数据流程

    image
  • 扩容机制

    image

7. 与 JDK 1.8的区别

HashMap 的实现在 JDK 1.7JDK 1.8 差别较大,具体区别如下

JDK 1.8 的优化目的主要是:减少 Hash冲突 & 提高哈希表的存、取效率;关于 JDK 1.8HashMap 的源码解析请看文章:Java源码分析:关于 HashMap 1.8 的重大更新

7.1 数据结构

image

7.2 获取数据时(获取数据 类似)

image

7.3 扩容机制

image

8. 额外补充:关于HashMap的其他问题

  • 有几个小问题需要在此补充
image
  • 具体如下

8.1 哈希表如何解决Hash冲突

image

8.2 为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

  • 具体解答如下
image
  • 下面主要讲解 HashMap 线程不安全的其中一个重要原因:多线程下容易出现resize()死循环
    本质 = 并发 执行 put()操作导致触发 扩容行为,从而导致 环形链表,使得在获取数据遍历链表时形成死循环,即Infinite Loop

  • 先看扩容的源码分析resize()

关于resize()的源码分析已在上文详细分析,此处仅作重点分析:transfer()

/**
   * 源码分析:resize(2 * table.length)
   * 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
   */ 
   void resize(int newCapacity) {  

    // 1\. 保存旧数组(old table) 
    Entry[] oldTable = table;  

    // 2\. 保存旧容量(old capacity ),即数组长度
    int oldCapacity = oldTable.length; 

    // 3\. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  

    // 4\. 根据新容量(2倍容量)新建1个数组,即新table  
    Entry[] newTable = new Entry[newCapacity];  

    // 5\. (重点分析)将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 
    transfer(newTable); 

    // 6\. 新数组table引用到HashMap的table属性上
    table = newTable;  

    // 7\. 重新设置阈值  
    threshold = (int)(newCapacity * loadFactor); 
} 

 /**
   * 分析1.1:transfer(newTable); 
   * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
   * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
   */ 
void transfer(Entry[] newTable) {
      // 1\. src引用了旧数组
      Entry[] src = table; 

      // 2\. 获取新数组的大小 = 获取新容量大小                 
      int newCapacity = newTable.length;

      // 3\. 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中
      for (int j = 0; j < src.length; j++) { 
          // 3.1 取得旧数组的每个元素  
          Entry<K,V> e = src[j];           
          if (e != null) {
              // 3.2 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
              src[j] = null; 

              do { 
                  // 3.3 遍历 以该数组元素为首 的链表
                  // 注:转移链表时,因是单链表,故要保存下1个结点,否则转移后链表会断开
                  Entry<K,V> next = e.next; 
                 // 3.3 重新计算每个元素的存储位置
                 int i = indexFor(e.hash, newCapacity); 
                 // 3.4 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中
                 // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
                 e.next = newTable[i]; 
                 newTable[i] = e;  
                 // 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
                 e = next;             
             } while (e != null);
             // 如此不断循环,直到遍历完数组上的所有数据元素
         }
     }
 }

从上面可看出:在扩容resize()过程中,在将旧数组上的数据 转移到 新数组上时,转移数据操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况

设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1

  • 此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态,具体请看下图:

初始状态、步骤1、步骤2

image
image
image

注:由于 JDK 1.8 转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。

JDK 1.8 还是线程不安全,因为 无加同步锁保护

8.3 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

image

8.4 HashMap 中的 keyObject类型, 则需实现哪些方法?

image

至此,关于HashMap的所有知识讲解完毕。

作者:Carson_Ho
链接:https://www.jianshu.com/p/e5c8a814c0ca
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容