Java:手把手带你源码分析 HashMap 1.7

本文深入解析了HashMap在Java 7中的实现原理,包括其数据结构、主要参数、添加及查询数据流程、扩容机制等内容。

前言

  • HashMap 在 Java 和 Android 开发中非常常见
  • 今天,我将带来HashMap 的全部源码分析,希望你们会喜欢。 
    1. 本文基于版本 JDK 1.7,即 Java 7 
    2. 关于版本 JDK 1.8,即 Java 8,具体请看文章Java源码分析:关于 HashMap 1.8 的重大更新

 


目录

示意图


1. 简介

  • 类定义
 
  1. public class HashMap<K,V>

  2. extends AbstractMap<K,V>

  3. implements Map<K,V>, Cloneable, Serializable

  •  
  • 主要介绍

示意图


2. 数据结构

2.1 具体描述

HashMap 采用的数据结构 = 数组(主) + 单链表(副),具体描述如下

该数据结构方式也称:拉链法

示意图

2.2 示意图

示意图

2.3 存储流程

注:为了让大家有个感性的认识,只是简单的画出存储流程,更加详细 & 具体的存储流程会在下面源码分析中给出

示意图

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

  • HashMap中的数组元素 & 链表节点 采用 Entry类 实现,如下图所示

示意图

  1. 即 HashMap的本质 = 1个存储Entry类对象的数组 + 多个单链表
  2. Entry对象本质 = 1个映射(键 - 值对),属性包括:键(key)、值(value) & 下1节点( next) = 单链表的指针 = 也是一个Entry对象,用于解决hash冲突
  • 该类的源码分析如下 
    具体分析请看注释
 
  1. /**

  2. * Entry类实现了Map.Entry接口

  3. * 即 实现了getKey()、getValue()、equals(Object o)和hashCode()等方法

  4. **/

  5. static class Entry<K,V> implements Map.Entry<K,V> {

  6. final K key; // 键

  7. V value; // 值

  8. Entry<K,V> next; // 指向下一个节点 ,也是一个Entry对象,从而形成解决hash冲突的单链表

  9. int hash; // hash值

  10.  
  11. /**

  12. * 构造方法,创建一个Entry

  13. * 参数:哈希值h,键值k,值v、下一个节点n

  14. */

  15. Entry(int h, K k, V v, Entry<K,V> n) {

  16. value = v;

  17. next = n;

  18. key = k;

  19. hash = h;

  20. }

  21.  
  22. // 返回 与 此项 对应的键

  23. public final K getKey() {

  24. return key;

  25. }

  26.  
  27. // 返回 与 此项 对应的值

  28. public final V getValue() {

  29. return value;

  30. }

  31.  
  32. public final V setValue(V newValue) {

  33. V oldValue = value;

  34. value = newValue;

  35. return oldValue;

  36. }

  37.  
  38. /**

  39. * equals()

  40. * 作用:判断2个Entry是否相等,必须key和value都相等,才返回true

  41. */

  42. public final boolean equals(Object o) {

  43. if (!(o instanceof Map.Entry))

  44. return false;

  45. Map.Entry e = (Map.Entry)o;

  46. Object k1 = getKey();

  47. Object k2 = e.getKey();

  48. if (k1 == k2 || (k1 != null && k1.equals(k2))) {

  49. Object v1 = getValue();

  50. Object v2 = e.getValue();

  51. if (v1 == v2 || (v1 != null && v1.equals(v2)))

  52. return true;

  53. }

  54. return false;

  55. }

  56.  
  57. /**

  58. * hashCode()

  59. */

  60. public final int hashCode() {

  61. return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());

  62. }

  63.  
  64. public final String toString() {

  65. return getKey() + "=" + getValue();

  66. }

  67.  
  68. /**

  69. * 当向HashMap中添加元素时,即调用put(k,v)时,

  70. * 对已经在HashMap中k位置进行v的覆盖时,会调用此方法

  71. * 此处没做任何处理

  72. */

  73. void recordAccess(HashMap<K,V> m) {

  74. }

  75.  
  76. /**

  77. * 当从HashMap中删除了一个Entry时,会调用该函数

  78. * 此处没做任何处理

  79. */

  80. void recordRemoval(HashMap<K,V> m) {

  81. }

  82.  
  83. }

  •  

3. 具体使用

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

 
  1. V get(Object key); // 获得指定键的值

  2. V put(K key, V value); // 添加键值对

  3. void putAll(Map<? extends K, ? extends V> m); // 将指定Map中的键值对 复制到 此Map中

  4. V remove(Object key); // 删除该键值对

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

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

  8.  
  9. Set<K> keySet(); // 单独抽取key序列,将所有key生成一个Set

  10. Collection<V> values(); // 单独value序列,将所有value生成一个Collection

  11.  
  12. void clear(); // 清除哈希表中的所有键值对

  13. int size(); // 返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对

  14. boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为 空

  •  

3.2 使用流程

  • 在具体使用时,主要流程是:

    1. 声明1个 HashMap的对象
    2. 向 HashMap 添加数据(成对 放入 键 - 值对)
    3. 获取 HashMap 的某个数据
    4. 获取 HashMap 的全部数据:遍历HashMap
  • 示例代码

 
  1. import java.util.Collection;

  2. import java.util.HashMap;

  3. import java.util.Iterator;

  4. import java.util.Map;

  5. import java.util.Set;

  6.  
  7. public class HashMapTest {

  8.  
  9. public static void main(String[] args) {

  10. /**

  11. * 1. 声明1个 HashMap的对象

  12. */

  13. Map<String, Integer> map = new HashMap<String, Integer>();

  14.  
  15. /**

  16. * 2. 向HashMap添加数据(成对 放入 键 - 值对)

  17. */

  18. map.put("Android", 1);

  19. map.put("Java", 2);

  20. map.put("iOS", 3);

  21. map.put("数据挖掘", 4);

  22. map.put("产品经理", 5);

  23.  
  24. /**

  25. * 3. 获取 HashMap 的某个数据

  26. */

  27. System.out.println("key = 产品经理时的值为:" + map.get("产品经理"));

  28.  
  29. /**

  30. * 4. 获取 HashMap 的全部数据:遍历HashMap

  31. * 核心思想:

  32. * 步骤1:获得key-value对(Entry) 或 key 或 value的Set集合

  33. * 步骤2:遍历上述Set集合(使用for循环 、 迭代器(Iterator)均可)

  34. * 方法共有3种:分别针对 key-value对(Entry) 或 key 或 value

  35. */

  36.  
  37. // 方法1:获得key-value的Set集合 再遍历

  38. System.out.println("方法1");

  39. // 1. 获得key-value对(Entry)的Set集合

  40. Set<Map.Entry<String, Integer>> entrySet = map.entrySet();

  41.  
  42. // 2. 遍历Set集合,从而获取key-value

  43. // 2.1 通过for循环

  44. for(Map.Entry<String, Integer> entry : entrySet){

  45. System.out.print(entry.getKey());

  46. System.out.println(entry.getValue());

  47. }

  48. System.out.println("----------");

  49. // 2.2 通过迭代器:先获得key-value对(Entry)的Iterator,再循环遍历

  50. Iterator iter1 = entrySet.iterator();

  51. while (iter1.hasNext()) {

  52. // 遍历时,需先获取entry,再分别获取key、value

  53. Map.Entry entry = (Map.Entry) iter1.next();

  54. System.out.print((String) entry.getKey());

  55. System.out.println((Integer) entry.getValue());

  56. }

  57.  
  58.  
  59. // 方法2:获得key的Set集合 再遍历

  60. System.out.println("方法2");

  61.  
  62. // 1. 获得key的Set集合

  63. Set<String> keySet = map.keySet();

  64.  
  65. // 2. 遍历Set集合,从而获取key,再获取value

  66. // 2.1 通过for循环

  67. for(String key : keySet){

  68. System.out.print(key);

  69. System.out.println(map.get(key));

  70. }

  71.  
  72. System.out.println("----------");

  73.  
  74. // 2.2 通过迭代器:先获得key的Iterator,再循环遍历

  75. Iterator iter2 = keySet.iterator();

  76. String key = null;

  77. while (iter2.hasNext()) {

  78. key = (String)iter2.next();

  79. System.out.print(key);

  80. System.out.println(map.get(key));

  81. }

  82.  
  83.  
  84. // 方法3:获得value的Set集合 再遍历

  85. System.out.println("方法3");

  86.  
  87. // 1. 获得value的Set集合

  88. Collection valueSet = map.values();

  89.  
  90. // 2. 遍历Set集合,从而获取value

  91. // 2.1 获得values 的Iterator

  92. Iterator iter3 = valueSet.iterator();

  93. // 2.2 通过遍历,直接获取value

  94. while (iter3.hasNext()) {

  95. System.out.println(iter3.next());

  96. }

  97.  
  98. }

  99.  
  100.  
  101. }

  102.  
  103. // 注:对于遍历方式,推荐使用针对 key-value对(Entry)的方式:效率高

  104. // 原因:

  105. // 1. 对于 遍历keySet 、valueSet,实质上 = 遍历了2次:1 = 转为 iterator 迭代器遍历、2 = 从 HashMap 中取出 key 的 value 操作(通过 key 值 hashCode 和 equals 索引)

  106. // 2. 对于 遍历 entrySet ,实质 = 遍历了1次 = 获取存储实体Entry(存储了key 和 value )

  •  
  • 运行结果
 
  1. 方法1

  2. Java2

  3. iOS3

  4. 数据挖掘4

  5. Android1

  6. 产品经理5

  7. ----------

  8. Java2

  9. iOS3

  10. 数据挖掘4

  11. Android1

  12. 产品经理5

  13. 方法2

  14. Java2

  15. iOS3

  16. 数据挖掘4

  17. Android1

  18. 产品经理5

  19. ----------

  20. Java2

  21. iOS3

  22. 数据挖掘4

  23. Android1

  24. 产品经理5

  25. 方法3

  26. 2

  27. 3

  28. 4

  29. 1

  30. 5

  •  

下面,我们按照上述的使用过程,对一个个步骤进行源码解析


4. 基础知识:HashMap中的重要参数(变量)

  • 在进行真正的源码分析前,先讲解HashMap中的重要参数(变量)
  • HashMap中的主要参数 = 容量、加载因子、扩容阈值
  • 具体介绍如下
 
  1. // 1. 容量(capacity): HashMap中数组的长度

  2. // a. 容量范围:必须是2的幂 & <最大容量(2的30次方)

  3. // b. 初始容量 = 哈希表创建时的容量

  4. // 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16

  5. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

  6. // 最大容量 = 2的30次方(若传入的容量过大,将被最大值替换)

  7. static final int MAXIMUM_CAPACITY = 1 << 30;

  8.  
  9. // 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度

  10. // a. 加载因子越大、填满的元素越多 = 空间利用率高、但冲突的机会加大、查找效率变低(因为链表变长了)

  11. // b. 加载因子越小、填满的元素越少 = 空间利用率小、冲突的机会减小、查找效率高(链表不长)

  12. // 实际加载因子

  13. final float loadFactor;

  14. // 默认加载因子 = 0.75

  15. static final float DEFAULT_LOAD_FACTOR = 0.75f;

  16.  
  17. // 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量)

  18. // a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数

  19. // b. 扩容阈值 = 容量 x 加载因子

  20. int threshold;

  21.  
  22. // 4. 其他

  23. // 存储数据的Entry类型 数组,长度 = 2的幂

  24. // HashMap的实现方式 = 拉链法,Entry数组上的每个元素本质上是一个单向链表

  25. transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

  26. // HashMap的大小,即 HashMap中存储的键值对的数量

  27. transient int size;

  28.  
  •  
  • 参数示意图

示意图

  • 此处 详细说明 加载因子

示意图


5. 源码分析

  • 本次的源码分析主要是根据 使用步骤 进行相关函数的详细分析
  • 主要分析内容如下:

示意图

  • 下面,我将对每个步骤内容的主要方法进行详细分析

步骤1:声明1个 HashMap的对象

 
  1. /**

  2. * 函数使用原型

  3. */

  4. Map<String,Integer> map = new HashMap<String,Integer>();

  5.  
  6. /**

  7. * 源码分析:主要是HashMap的构造函数 = 4个

  8. * 仅贴出关于HashMap构造函数的源码

  9. */

  10. public class HashMap<K,V>

  11. extends AbstractMap<K,V>

  12. implements Map<K,V>, Cloneable, Serializable{

  13.  
  14. // 省略上节阐述的参数

  15.  
  16. /**

  17. * 构造函数1:默认构造函数(无参)

  18. * 加载因子 & 容量 = 默认 = 0.75、16

  19. */

  20. public HashMap() {

  21. // 实际上是调用构造函数3:指定“容量大小”和“加载因子”的构造函数

  22. // 传入的指定容量 & 加载因子 = 默认

  23. this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);

  24. }

  25.  
  26. /**

  27. * 构造函数2:指定“容量大小”的构造函数

  28. * 加载因子 = 默认 = 0.75 、容量 = 指定大小

  29. */

  30. public HashMap(int initialCapacity) {

  31. // 实际上是调用指定“容量大小”和“加载因子”的构造函数

  32. // 只是在传入的加载因子参数 = 默认加载因子

  33. this(initialCapacity, DEFAULT_LOAD_FACTOR);

  34.  
  35. }

  36.  
  37. /**

  38. * 构造函数3:指定“容量大小”和“加载因子”的构造函数

  39. * 加载因子 & 容量 = 自己指定

  40. */

  41. public HashMap(int initialCapacity, float loadFactor) {

  42.  
  43. // HashMap的最大容量只能是MAXIMUM_CAPACITY,哪怕传入的 > 最大容量

  44. if (initialCapacity > MAXIMUM_CAPACITY)

  45. initialCapacity = MAXIMUM_CAPACITY;

  46.  
  47. // 设置 加载因子

  48. this.loadFactor = loadFactor;

  49. // 设置 扩容阈值 = 初始容量

  50. // 注:此处不是真正的阈值,是为了扩展table,该阈值后面会重新计算,下面会详细讲解

  51. threshold = initialCapacity;

  52.  
  53. init(); // 一个空方法用于未来的子对象扩展

  54. }

  55.  
  56. /**

  57. * 构造函数4:包含“子Map”的构造函数

  58. * 即 构造出来的HashMap包含传入Map的映射关系

  59. * 加载因子 & 容量 = 默认

  60. */

  61.  
  62. public HashMap(Map<? extends K, ? extends V> m) {

  63.  
  64. // 设置容量大小 & 加载因子 = 默认

  65. this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,

  66. DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);

  67.  
  68. // 该方法用于初始化 数组 & 阈值,下面会详细说明

  69. inflateTable(threshold);

  70.  
  71. // 将传入的子Map中的全部元素逐个添加到HashMap中

  72. putAllForCreate(m);

  73. }

  74. }

  •  
  • 注: 
    1. 此处仅用于接收初始容量大小(capacity)、加载因子(Load factor),但仍无真正初始化哈希表,即初始化存储数组table
    2. 此处先给出结论:真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()时。下面会详细说明

至此,关于HashMap的构造函数讲解完毕。


步骤2:向HashMap添加数据(成对 放入 键 - 值对)

  • 添加数据的流程如下 

    注:为了让大家有个感性的认识,只是简单的画出存储流程,更加详细 & 具体的存储流程会在下面源码分析中给出


示意图

  • 源码分析
 
  1. /**

  2. * 函数使用原型

  3. */

  4. map.put("Android", 1);

  5. map.put("Java", 2);

  6. map.put("iOS", 3);

  7. map.put("数据挖掘", 4);

  8. map.put("产品经理", 5);

  9.  
  10. /**

  11. * 源码分析:主要分析: HashMap的put函数

  12. */

  13. public V put(K key, V value)

  14. (分析1)// 1. 若 哈希表未初始化(即 table为空)

  15. // 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table

  16. if (table == EMPTY_TABLE) {

  17. inflateTable(threshold);

  18. }

  19. // 2. 判断key是否为空值null

  20. (分析2)// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]

  21. // (本质:key = Null时,hash值 = 0,故存放到table[0]中)

  22. // 该位置永远只有1个value,新传进来的value会覆盖旧的value

  23. if (key == null)

  24. return putForNullKey(value);

  25.  
  26. (分析3) // 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)

  27. // a. 根据键值key计算hash值

  28. int hash = hash(key);

  29. // b. 根据hash值 最终获得 key对应存放的数组Table中位置

  30. int i = indexFor(hash, table.length);

  31.  
  32. // 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)

  33. for (Entry<K,V> e = table[i]; e != null; e = e.next) {

  34. Object k;

  35. (分析4)// 3.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value

  36. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

  37. V oldValue = e.value;

  38. e.value = value;

  39. e.recordAccess(this);

  40. return oldValue; //并返回旧的value

  41. }

  42. }

  43.  
  44. modCount++;

  45.  
  46. (分析5)// 3.2 若 该key不存在,则将“key-value”添加到table中

  47. addEntry(hash, key, value, i);

  48. return null;

  49. }

  •  
  • 根据源码分析所作出的流程图

示意图

  • 下面,我将根据上述流程的5个分析点进行详细讲解

分析1:初始化哈希表

即 初始化数组(table)、扩容阈值(threshold

 
  1. /**

  2. * 函数使用原型

  3. */

  4. if (table == EMPTY_TABLE) {

  5. inflateTable(threshold);

  6. }

  7.  
  8. /**

  9. * 源码分析:inflateTable(threshold);

  10. */

  11. private void inflateTable(int toSize) {

  12.  
  13. // 1. 将传入的容量大小转化为:>传入容量大小的最小的2的次幂

  14. // 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)

  15. int capacity = roundUpToPowerOf2(toSize);->>分析1

  16.  
  17. // 2. 重新计算阈值 threshold = 容量 * 加载因子

  18. threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

  19.  
  20. // 3. 使用计算后的初始容量(已经是2的次幂) 初始化数组table(作为数组长度)

  21. // 即 哈希表的容量大小 = 数组大小(长度)

  22. table = new Entry[capacity]; //用该容量初始化table

  23.  
  24. initHashSeedAsNeeded(capacity);

  25. }

  26.  
  27. /**

  28. * 分析1:roundUpToPowerOf2(toSize)

  29. * 作用:将传入的容量大小转化为:>传入容量大小的最小的2的幂

  30. * 特别注意:容量大小必须为2的幂,该原因在下面的讲解会详细分析

  31. */

  32.  
  33. private static int roundUpToPowerOf2(int number) {

  34.  
  35. //若 容量超过了最大值,初始化容量设置为最大值 ;否则,设置为:>传入容量大小的最小的2的次幂

  36. return number >= MAXIMUM_CAPACITY ?

  37. MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;

  •  
  • 再次强调:真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()

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

 
  1. /**

  2. * 函数使用原型

  3. */

  4. if (key == null)

  5. return putForNullKey(value);

  6.  
  7. /**

  8. * 源码分析:putForNullKey(value)

  9. */

  10. private V putForNullKey(V value) {

  11. // 遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对

  12. // 1. 若有:则用新value 替换 旧value;同时返回旧的value值

  13. for (Entry<K,V> e = table[0]; e != null; e = e.next) {

  14. if (e.key == null) {

  15. V oldValue = e.value;

  16. e.value = value;

  17. e.recordAccess(this);

  18. return oldValue;

  19. }

  20. }

  21. modCount++;

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

  24. addEntry(0, null, value, 0);

  25. // 注:

  26. // a. addEntry()的第1个参数 = hash值 = 传入0

  27. // b. 即 说明:当key = null时,也有hash值 = 0,所以HashMap的key 可为null

  28. // c. 对比HashTable,由于HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null

  29. // d. 此处只需知道是将 key-value 添加到HashMap中即可,关于addEntry()的源码分析将等到下面再详细说明,

  30. return null;

  31.  
  32. }

  •  

从此处可以看出: 
HashMap的键key 可为null(区别于 HashTablekey 不可为null) 
HashMap的键key 可为null且只能为1个,但值value可为null且为多个


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

 
  1. /**

  2. * 函数使用原型

  3. * 主要分为2步:计算hash值、根据hash值再计算得出最后数组位置

  4. */

  5. // a. 根据键值key计算hash值 ->> 分析1

  6. int hash = hash(key);

  7. // b. 根据hash值 最终获得 key对应存放的数组Table中位置 ->> 分析2

  8. int i = indexFor(hash, table.length);

  9.  
  10. /**

  11. * 源码分析1:hash(key)

  12. * 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)

  13. * JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算

  14. * JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算

  15. */

  16.  
  17. // JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)

  18. static final int hash(int h) {

  19. h ^= k.hashCode();

  20. h ^= (h >>> 20) ^ (h >>> 12);

  21. return h ^ (h >>> 7) ^ (h >>> 4);

  22. }

  23.  
  24. // JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)

  25. // 1. 取hashCode值: h = key.hashCode()

  26. // 2. 高位参与低位的运算:h ^ (h >>> 16)

  27. static final int hash(Object key) {

  28. int h;

  29. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

  30. // a. 当key = null时,hash值 = 0,所以HashMap的key 可为null

  31. // 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null

  32. // b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行 扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制

  33. }

  34.  
  35. /**

  36. * 函数源码分析2:indexFor(hash, table.length)

  37. * JDK 1.8中实际上无该函数,但原理相同,即具备类似作用的函数

  38. */

  39. static int indexFor(int h, int length) {

  40. return h & (length-1);

  41. // 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)

  42. }

  •  
  • 总结 计算存放在数组 table 中的位置(即数组下标、索引)的过程 
    示意图

在了解 如何计算存放数组table 中的位置 后,所谓 知其然 而 需知其所以然,下面我将讲解为什么要这样计算,即主要解答以下3个问题: 
1. 为什么不直接采用经过hashCode()处理的哈希码 作为 存储数组table的下标位置? 
2. 为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标? 
3. 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

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

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

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

示意图

  • 为了解决 “哈希码与数组大小范围不匹配” 的问题,HashMap给出了解决方案:哈希码 与运算(&) (数组长度-1);请继续问题2

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

示意图

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

示意图

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


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

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

 
  1. /**

  2. * 函数使用原型

  3. */

  4. // 2. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)

  5. for (Entry<K,V> e = table[i]; e != null; e = e.next) {

  6. Object k;

  7. // 2.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value

  8. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

  9. V oldValue = e.value;

  10. e.value = value;

  11. e.recordAccess(this);

  12. return oldValue; //并返回旧的value

  13. }

  14. }

  15.  
  16. modCount++;

  17.  
  18. // 2.2 若 该key不存在,则将“key-value”添加到table中

  19. addEntry(hash, key, value, i);

  20. return null;

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

分析1:替换流程

具体如下图: 
示意图

分析2:key值的比较

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


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

  • 函数源码分析如下
 
  1. /**

  2. * 函数使用原型

  3. */

  4. // 2. 判断该key对应的值是否已存在

  5. for (Entry<K,V> e = table[i]; e != null; e = e.next) {

  6. Object k;

  7. // 2.1 若该key对应的值已存在,则用新的value取代旧的value

  8. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

  9. V oldValue = e.value;

  10. e.value = value;

  11. e.recordAccess(this);

  12. return oldValue;

  13. }

  14. }

  15.  
  16. modCount++;

  17.  
  18. // 2.2 若 该key对应的值不存在,则将“key-value”添加到table中

  19. addEntry(hash, key, value, i);

  20.  
  21. /**

  22. * 源码分析:addEntry(hash, key, value, i)

  23. * 作用:添加键值对(Entry )到 HashMap中

  24. */

  25. void addEntry(int hash, K key, V value, int bucketIndex) {

  26. // 参数3 = 插入数组table的索引位置 = 数组下标

  27.  
  28. // 1. 插入前,先判断容量是否足够

  29. // 1.1 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标

  30. if ((size >= threshold) && (null != table[bucketIndex])) {

  31. resize(2 * table.length); // a. 扩容2倍 --> 分析1

  32. hash = (null != key) ? hash(key) : 0; // b. 重新计算该Key对应的hash值

  33. bucketIndex = indexFor(hash, table.length); // c. 重新计算该Key对应的hash值的存储数组下标位置

  34. }

  35.  
  36. // 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中--> 分析2

  37. createEntry(hash, key, value, bucketIndex);

  38. }

  39.  
  40. /**

  41. * 分析1:resize(2 * table.length)

  42. * 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)

  43. */

  44. void resize(int newCapacity) {

  45.  
  46. // 1. 保存旧数组(old table)

  47. Entry[] oldTable = table;

  48.  
  49. // 2. 保存旧容量(old capacity ),即数组长度

  50. int oldCapacity = oldTable.length;

  51.  
  52. // 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出

  53. if (oldCapacity == MAXIMUM_CAPACITY) {

  54. threshold = Integer.MAX_VALUE;

  55. return;

  56. }

  57.  
  58. // 4. 根据新容量(2倍容量)新建1个数组,即新table

  59. Entry[] newTable = new Entry[newCapacity];

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

  62. transfer(newTable);

  63.  
  64. // 6. 新数组table引用到HashMap的table属性上

  65. table = newTable;

  66.  
  67. // 7. 重新设置阈值

  68. threshold = (int)(newCapacity * loadFactor);

  69. }

  70.  
  71. /**

  72. * 分析1.1:transfer(newTable);

  73. * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容

  74. * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入

  75. */

  76. void transfer(Entry[] newTable) {

  77. // 1. src引用了旧数组

  78. Entry[] src = table;

  79.  
  80. // 2. 获取新数组的大小 = 获取新容量大小

  81. int newCapacity = newTable.length;

  82.  
  83. // 3. 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中

  84. for (int j = 0; j < src.length; j++) {

  85. // 3.1 取得旧数组的每个元素

  86. Entry<K,V> e = src[j];

  87. if (e != null) {

  88. // 3.2 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)

  89. src[j] = null;

  90.  
  91. do {

  92. // 3.3 遍历 以该数组元素为首 的链表

  93. // 注:转移链表时,因是单链表,故要保存下1个结点,否则转移后链表会断开

  94. Entry<K,V> next = e.next;

  95. // 3.4 重新计算每个元素的存储位置

  96. int i = indexFor(e.hash, newCapacity);

  97. // 3.5 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中

  98. // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入

  99. e.next = newTable[i];

  100. newTable[i] = e;

  101. // 3.6 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点

  102. e = next;

  103. } while (e != null);

  104. // 如此不断循环,直到遍历完数组上的所有数据元素

  105. }

  106. }

  107. }

  108.  
  109. /**

  110. * 分析2:createEntry(hash, key, value, bucketIndex);

  111. * 作用: 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中

  112. */

  113. void createEntry(int hash, K key, V value, int bucketIndex) {

  114.  
  115. // 1. 把table中该位置原来的Entry保存

  116. Entry<K,V> e = table[bucketIndex];

  117.  
  118. // 2. 在table中该位置新建一个Entry:将原头结点位置(数组上)的键值对 放入到(链表)后1个节点中、将需插入的键值对 放入到头结点中(数组上)-> 从而形成链表

  119. // 即 在插入元素时,是在链表头插入的,table中的每个位置永远只保存最新插入的Entry,旧的Entry则放入到链表中(即 解决Hash冲突)

  120. table[bucketIndex] = new Entry<>(hash, key, value, e);

  121.  
  122. // 3. 哈希表的键值对数量计数增加

  123. size++;

  124. }

  •  

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

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

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

    2. 扩容机制

  • 具体流程如下:
  • 示意图

  • 扩容过程中的转移数据示意图如下
  • 示意图

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

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

  • 此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态 = 线程不安全 
    下面最后1节会对上述情况详细说明
  • 总结

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

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


    步骤3:从HashMap中获取数据

  • 假如理解了上述put()函数的原理,那么get()函数非常好理解,因为二者的过程原理几乎相同
  • get()函数的流程如下:
  • 示意图

  • 具体源码分析如下
  •  
  • /**

  • * 函数原型

  • * 作用:根据键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个图总结整个源码内容:

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

  • 数据结构 & 主要参数 
    示意图

  • 添加 & 查询数据流程 
    示意图

  • 扩容机制 
    示意图


  • 7. 与 JDK 1.8的区别

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

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

    7.1 数据结构

    示意图

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

    示意图

    7.3 扩容机制

    示意图


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

  • 有几个小问题需要在此补充
  • 示意图

  • 具体如下
  • 8.1 哈希表如何解决Hash冲突

    示意图

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

  • 具体解答如下
  • 示意图

  • 下面主要讲解 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),即 死锁的状态,具体请看下图:
  • 示意图

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

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

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

    示意图

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

    示意图

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

转:https://blog.youkuaiyun.com/Crystalqy/article/details/79414846

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值