Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析

原文传送门:https://blog.csdn.net/carson_ho/article/details/79373026 非常感谢作者!

1、文章基于 JDK 1.7,即 Java 7
2、关于 JDK 1.8,即 Java 8,具体请看文章

目录:

1、简介

  • 1.1 类定义
public class HashMap<K, V>
	 extends AbstractMap<K, V> 
	 implements Map<K, V>, Clonable, Serializable
  • 1.2 主要介绍
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

2、数据结构

2.1 具体描述

HashMap 采用的数据结构 = 数组(主) + 单链表(副)
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

2.2 示意图

《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

2.3 存储流程

《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

2.4 数组元素 & 链表节点的实现类

  • HashMap 中的数组元素 & 链表节点采用 Entry 类实现,如下图示:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

1、HashMap 的本质 = 1 个存储 Entry 类对象的数组 + 多个单链表
2、Entry 对象本质 = 1 个映射(键-值对),属性包括:键(key)、值(value)& 下1节点(next) = 单链表的指针 = 也是一个 Entry 对象,用于解决 hash 冲突

源码如下:

/** 
 * Entry类实现了Map.Entry接口
 * 即 实现了getKey()、getValue()、equals(Object o)和hashCode()等方法
**/ 
static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        public final int hashCode() {
            return (key==null   ? 0 : key.hashCode()) ^
                   (value==null ? 0 : value.hashCode());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) {
        }

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

3、具体使用

3.1 主要使用 API(方法、函数)

V get(Object key); // 获得指定键的值 V put(K key, V value); // 添加键值对 
void putAll(Map<? extends K, ? extends V> m); // 将指定Map中的键值对 复制到 此Map中 
V remove(Object key); // 删除该键值对 
boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true 
boolean containsValue(Object value); // 判断是否存在该值的键值对;是 则返回true 
Set<K> keySet(); // 单独抽取key序列,将所有key生成一个Set 
Collection<V> values(); // 单独value序列,将所有value生成一个Collection 
void clear(); // 清除哈希表中的所有键值对 
int size(); // 返回哈希表中所有 键值对

3.2 遍历 HashMap

方式一: 使用 entrySet()

Map<String, Object> map = new HashMap<>();
Iterator iter = map.entrySet().iterator();
while (iter.hasNext()) {
    Map.Entry entry = (Map.Entry)iter.next();
    Object key = entry.getKey(); // 获取key值
    Object value = entry.getValue(); // 获取value值
}

也可以使用 foreach 循环的形式遍历

Map<String, Object> map = new HashMap<>();
for (Map.Entry<String, Object> entry: map.entrySet()) {
    entry.getKey();
    entry.getValue();
}

**方式二:**使用 keySet()

Map<String, Object> map = new HashMap<>();
Iterator iter = map.keySet().iterator();
while (iter.hasNext()) {
    Object key = iter,next();
    Object value = map.get(key);
}

也可以使用 foreach 循环的形式遍历

Map<String, Object> map = new HashMap<>();
for (String key: map.keySet()) {
    map.getKey(key); // 获取value值
}

两种方式效率对比:
keySet 方式其实是遍历了 2 次,一次是转为 iterator, 一次是从HashMap 中取出 key 对应的 value,而 entrySet 方式只遍历了 1次,把 keyvalue 都放到了 entry

4、基础知识:HashMap 中的重要参数

  • HashMap 中的主要参数:容量、加载因子、扩容阈值
  • 源码分析:
// 1. 容量(capacity): HashMap中数组的长度
// a. 容量范围:必须是2的幂 & <最大容量(2的30次方)
// b. 初始容量 = 哈希表创建时的容量
	// 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
	// 最大容量 =  2的30次方(若传入的容量过大,将被最大值替换)
	static final int MAXIMUM_CAPACITY = 1 << 30;

// 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
// a. 加载因子越大、填满的元素越多 = 空间利用率高、但冲突的机会加大、查找效率变低(因为链表变长了) 
// b. 加载因子越小、填满的元素越少 = 空间利用率小、冲突的机会减小、查找效率高(链表不长)
	// 实际加载因子
  	final float loadFactor;
  	// 默认加载因子 = 0.75
  	static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量) 
// a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
// b. 扩容阈值 = 容量 x 加载因子
	int threshold;

// 4. 其他
 // 存储数据的Entry类型 数组,长度 = 2的幂
 // HashMap的实现方式 = 拉链法,Entry数组上的每个元素本质上是一个单向链表
 	transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; 
 	 // HashMap的大小,即 HashMap中存储的键值对的数量
 	 transient int size;
  • 参数示意图
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
  • 详细说明加载因子
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

5、源码分析

  • 主要内容:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

步骤1:声明 1 个 HashMap 的对象
4 个构造函数

/**
   * 源码分析:主要是HashMap的构造函数 = 4个
   * 仅贴出关于HashMap构造函数的源码
   */
public class HashMap<K,V>
      extends AbstractMap<K,V>
      implements Map<K,V>, Cloneable, Serializable{

/**
     * 构造函数1:默认构造函数(无参)
     * 加载因子 & 容量 = 默认 = 0.75、16
     */
public HashMap() {
   this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

/**
     * 构造函数2:指定“容量大小”的构造函数
     * 加载因子 = 默认 = 0.75 、容量 = 指定大小
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

/**
     * 构造函数3:指定“容量大小”和“加载因子”的构造函数
     * 加载因子 & 容量 = 自己指定
     */
    public HashMap(int initialCapacity, float loadFactor) {
        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);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        init();
    }

 /**
     * 构造函数4:包含“子Map”的构造函数
     * 即 构造出来的HashMap包含传入Map的映射关系
     * 加载因子 & 容量 = 默认
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        putAllForCreate(m);
    }
 }

步骤2:向 HashMap 添加数据

  • 添加数据流程如下:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
  • 源码分析:
public V put(K key, V value) {
(分析1)// 1. 若 哈希表未初始化(即 table为空) 
        // 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table 
	if (table == EMPTY_TABLE) { 
	        inflateTable(threshold); 
	 }  

(分析2)// 2. 判断key是否为空值null 
// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
// (本质:key = Null时,hash值 = 0,故存放到table[0]中) 
// 该位置永远只有1个value,新传进来的value会覆盖旧的value
        if (key == null)
            return putForNullKey(value);

(分析3) // 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
        // a. 根据键值key计算hash值
        int hash = hash(key);
        // b. 根据hash值 最终获得 key对应存放的数组Table中位置
        int i = indexFor(hash, table.length);

	// 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
 (分析4)// 3.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++;

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

/**
     * 键值为 null,调用函数将 null 值存入
     */
    private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

对应的流程图:
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

  • 根据上述流程的 5 个分析点进行详细讲解
    分析1:初始化哈希表
    即初始化数组(table)、扩容阈值(threshold
/**
     * 函数使用原型
     */
      if (table == EMPTY_TABLE) { 
        inflateTable(threshold); 
    } 

/**
     * 源码分析:inflateTable(threshold); 
     */
private void inflateTable(int toSize) {
    // 1. 将传入的容量大小转化为:>传入容量大小的最小的2的次幂
    // 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
    int capacity = roundUpToPowerOf2(toSize);->>分析1 

    // 2. 重新计算阈值 threshold = 容量 * 加载因子  
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

    // 3. 使用计算后的初始容量(已经是2的次幂) 初始化数组table(作为数组长度)
    // 即 哈希表的容量大小 = 数组大小(长度)
    table = new Entry[capacity]; //用该容量初始化table
	
    initHashSeedAsNeeded(capacity);
}

真正初始化哈希表(初始化存储数组 table)是在第一次添加键值对时,即第一次调用 put 方法

分析 2:当 key ==null时,将该 key-value 的存储位置规定为数组table 中的第 1 个位置,即 table [0]

/**
     * 函数使用原型
     */
     if (key == null)
           return putForNullKey(value);
     
    /**
     * 源码分析:putForNullKey(value)
     */ 
     private V putForNullKey(V value) { 
	     // 遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对
	      // 1. 若有:则用新value 替换 旧value;同时返回旧的value值 
	      for (Entry<K,V> e = table[0]; e != null; e = e.next) {
	      	 if (e.key == null) { 
	      	 	V oldValue = e.value; 
	      	 	e.value = value; 
	      	 	e.recordAccess(this);
	      	 	 return oldValue; 
	      	 	 } 
	      }
		
	     modCount++;

	    // 2 .若无key==null的键,那么调用addEntry(),将空键 & 对应的值封装到Entry中,并放到table[0]中
   	    addEntry(0, null, value, 0);

	   // 注: 
	   // a. addEntry()的第1个参数 = hash值 = 传入0 
	   // b. 即 说明:当key = null时,也有hash值 = 0,所以HashMap的key 可为null
	   // c. 对比HashTable,由于HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null 
	   // d. 此处只需知道是将 key-value 添加到HashMap中即可,关于addEntry()的源码分析将等到下面再详细说明, 
	   return null; 
}

说明:

  • HashMap的键 key 可为 null(区别于 Hashtablekey 不可为 null)
  • HashMap 的键 key 可为 null 且只能为 1 个,但值 value 可为 null 且为多个

分析 3:计算存放数组 table 中的位置(即 数组下标 or 索引)

/**
     * 函数使用原型
     * 主要分为2步:计算hash值、根据hash值再计算得出最后数组位置
     */
     // a. 根据键值key计算hash值 ->> 分析1
     int hash = hash(key);
     // b. 根据hash值 最终获得 key对应存放的数组Table中位置 ->> 分析2
     int i = indexFor(hash, table.length);

/**
     * 源码分析1:hash(key)
     * 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
     * JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
     * JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
     */
// JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作  = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
     static final int hash(int h) {
     	h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
     }

// JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动) 
// 1. 取hashCode值: h = key.hashCode() 
//  2. 高位参与低位的运算:h ^ (h >>> 16)  
static final int hash(Object key) { 
	int h; 
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
	// a. 当key = null时,hash值 = 0,所以HashMap的key 可为null      
	// 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null       // b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行 扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制 
}

/**
     * 函数源码分析2:indexFor(hash, table.length)
     * JDK 1.8中实际上无该函数,但原理相同,即具备类似作用的函数
     */ 
     static int indexFor(int h, int length) { 
	     	return h & (length-1); 
	     	// 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引) 
     	}
  • 总结 计算存放在数组 table 中的位置(即数组下标、索引)的过程
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

在了解如何计算存放数组 table 中的位置,下面 3 个问题讲解为什么要这样计算:
1、为什么不直接采用经过 hashcode() 处理的哈希吗作为存储数组 table 的下标位置?
2、为什么采用哈希码与运算(&)(数组长度-1)计算数组下标?
3、为什么在计算数组下标前,需要对哈希码进行二次处理:扰动处理?

记住核心思想:

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

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

  • 结论:容易出现哈希码与数组大小范围不匹配的情况,即计算出来的哈希码可能不在数组大小范围内,导致无法匹配存储位置
  • 原因描述:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
  • 为了解决 “哈希码与数组大小范围不匹配” 的问题,HashMap 给出了解决方案:哈希码 与运算(&) (数组长度-1);

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

  • 结论:根据 HashMap 的容量大小(数组长度),按需取哈希码一定数量的低位作为存储的数组下标位置,从而解决“哈希码与数组大小范围不匹配”的问题
  • 具体解决方案描述:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

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

  • 结论:加大哈希码低位的随机性,使得分布均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少 Hash 冲突
  • 具体描述
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
    分析4:若对应的 key 已存在,则使用新 value 替换旧 value

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

// 1. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            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);
return null;

替换流程:

初始状态:
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
分析5:若对应的 key 值不存在,则将该“key-value”添加到数组 table 对应的位置中
源码如下:

/**
     * 源码分析: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];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;

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

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

	 // 7. 重新设置阈值 
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

/**
   * 分析1.1:transfer(newTable); 
   * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
   * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
   */
   void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

/**
   * 分析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);
        size++;
    }

需要注意:键值对的添加方式 & 扩容机制

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

  • 即 将该位置(数组上)原来的数据放在该位置的(链表)下 1 个节点中(next)、在该位置(数组上)放入需插入的数据从而形成链表
  • 如下示意图
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

2、扩容机制

  • 流程如下:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

  • 扩容过程中的转移数据示意图如下
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
    在扩容 resize() 过程中,将旧的数组上的数组转移到新数组上时,转移操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况

扩容前 = 1 -> 2 ->3, 扩容后 = 3 -> 2 -> 1

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

总结

  • HashMap 添加数据(成对放入键-值对)的全流程
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
  • 示意图
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

步骤3:从 HashMap 中获取数据

  • 假如理解了上述 put() 函数的原理,那么 get() 函数非常好的理解,二者的过程原理几乎相同
  • get() 函数流程如下:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
  • 具体源码如下:
/**
   * 函数原型
   * 作用:根据键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() {
   	// 遍历以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) {
   	// 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 的其它操作

  • 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 

对应源码就不具体分析了

6、源码总结

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

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

  • 数据结构 & 主要参数

《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

  • 添加 & 查询数据流程
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

  • 扩容机制
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

7、与 JDK 1.8 的区别

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

JDK 1.8 的优化目的主要是:减少 Hash 冲突 & 提高哈希表的存、取效率

7.1 数据结构
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

7.2 获取数据时
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

7.2 扩容机制
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

8、额外补充:关于 HashMap 的其它问题

  • 几个小问题
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

8.1 哈希表如何解决 Hash 冲突
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

8.2 为什么 HashMap 具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

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

  • 扩容的源码分析 resize()

/**
 - 源码分析:resize(2 * table.length)
 - 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
   */
   void resize(int newCapacity) {
   	// 1、保存旧数组
        Entry[] oldTable = table;

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

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

	// 4、新建一个数组 table,容量为原来的 2 倍
        Entry[] newTable = new Entry[newCapacity];

        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;

	// 5、将旧数组上的数据(键值对)转移到新 table 中,从而完成扩容
        transfer(newTable, rehash);
        
        // 6、新数组 table 引用到 HashMap 的 table 属性上
        table = newTable;

	// 7、重新设置阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

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

	// 2、通过遍历旧数组,将旧数组上的数据(键值对)转移到新数组中
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                // 2.1 重新计算每个元素的存储位置
                int i = indexFor(e.hash, newCapacity);
                // 2.1 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中,即扩容之后,可能出现逆序,按旧链表的正序遍历链表、在新链表的头部依次插入
                e.next = newTable[i];
                newTable[i] = e;
                // 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
                e = next;
            }
        }
    }

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

  • 此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态,具体请看下图:
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
    《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》
    注:由于 JDK 1.8 转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。

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

8.3 为什么 HashMapStringInteger 这样的包装类适合作为 key
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

8.4 HashMap 中的 keyObject 类型, 则需实现哪些方法?
《Java程序员从笨鸟到菜鸟(六十六)HashMap 1.7源码分析》

    原文作者:明割啦
    原文地址: https://blog.csdn.net/u013090299/article/details/84664587
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞