美文网首页
Java基础:HashMap详解一

Java基础:HashMap详解一

作者: IT前沿技术分享 | 来源:发表于2019-05-08 15:22 被阅读0次

    前言

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

    目录

    image

    1. 简介

    • 类定义
    public class HashMap<K,V>
             extends AbstractMap<K,V> 
             implements Map<K,V>, Cloneable, Serializable
    
    
    • 主要介绍
    image
    • HashMap 的实现在 JDK 1.7JDK 1.8 差别较大
    • 今天,我将主要讲解 JDK 1.7HashMap 的源码解析

    关于 JDK 1.8HashMap 的源码解析请看文章:Java源码分析:关于 HashMap 1.8 的重大更新


    2. 数据结构

    2.1 具体描述

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

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

    image

    2.2 示意图

    image

    2.3 存储流程

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

    image

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

    • HashMap中的数组元素 & 链表节点 采用 Entry类 实现,如下图所示
    image
    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; // 指向下一个节点 ,也是一个Entry对象,从而形成解决hash冲突的单链表
        int hash;  // hash值
    
        /** 
         * 构造方法,创建一个Entry 
         * 参数:哈希值h,键值k,值v、下一个节点n 
         */  
        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;  
        }  
    
       /** 
         * equals()
         * 作用:判断2个Entry是否相等,必须key和value都相等,才返回true  
         */ 
          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;  
        }  
    
        /** 
         * hashCode() 
         */ 
        public final int hashCode() { 
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());  
        }  
    
        public final String toString() {  
            return getKey() + "=" + getValue();  
        }  
    
        /** 
         * 当向HashMap中添加元素时,即调用put(k,v)时, 
         * 对已经在HashMap中k位置进行v的覆盖时,会调用此方法 
         * 此处没做任何处理 
         */  
        void recordAccess(HashMap<K,V> m) {  
        }  
    
        /** 
         * 当从HashMap中删除了一个Entry时,会调用该函数 
         * 此处没做任何处理 
         */  
        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();  // 返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对
    boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为 空 
    
    

    3.2 使用流程

    • 在具体使用时,主要流程是:
    1. 声明1个 HashMap的对象
    2. HashMap 添加数据(成对 放入 键 - 值对)
    3. 获取 HashMap 的某个数据
    4. 获取 HashMap 的全部数据:遍历HashMap
    • 示例代码
    import java.util.Collection;
    import java.util.HashMap;
    import java.util.Iterator;
    import java.util.Map;
    import java.util.Set;
    
    public class HashMapTest {
    
        public static void main(String[] args) {
          /**
            * 1\. 声明1个 HashMap的对象
            */
            Map<String, Integer> map = new HashMap<String, Integer>();
    
          /**
            * 2\. 向HashMap添加数据(成对 放入 键 - 值对)
            */
            map.put("Android", 1);
            map.put("Java", 2);
            map.put("iOS", 3);
            map.put("数据挖掘", 4);
            map.put("产品经理", 5);
    
           /**
            * 3\. 获取 HashMap 的某个数据
            */
            System.out.println("key = 产品经理时的值为:" + map.get("产品经理"));
    
          /**
            * 4\. 获取 HashMap 的全部数据:遍历HashMap
            * 核心思想:
            * 步骤1:获得key-value对(Entry) 或 key 或 value的Set集合
            * 步骤2:遍历上述Set集合(使用for循环 、 迭代器(Iterator)均可)
            * 方法共有3种:分别针对 key-value对(Entry) 或 key 或 value
            */
    
            // 方法1:获得key-value的Set集合 再遍历
            System.out.println("方法1");
            // 1\. 获得key-value对(Entry)的Set集合
            Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
    
            // 2\. 遍历Set集合,从而获取key-value
            // 2.1 通过for循环
            for(Map.Entry<String, Integer> entry : entrySet){
                System.out.print(entry.getKey());
                System.out.println(entry.getValue());
            }
            System.out.println("----------");
            // 2.2 通过迭代器:先获得key-value对(Entry)的Iterator,再循环遍历
            Iterator iter1 = entrySet.iterator();
            while (iter1.hasNext()) {
                // 遍历时,需先获取entry,再分别获取key、value
                Map.Entry entry = (Map.Entry) iter1.next();
                System.out.print((String) entry.getKey());
                System.out.println((Integer) entry.getValue());
            }
    
            // 方法2:获得key的Set集合 再遍历
            System.out.println("方法2");
    
            // 1\. 获得key的Set集合
            Set<String> keySet = map.keySet();
    
            // 2\. 遍历Set集合,从而获取key,再获取value
            // 2.1 通过for循环
            for(String key : keySet){
                System.out.print(key);
                System.out.println(map.get(key));
            }
    
            System.out.println("----------");
    
            // 2.2 通过迭代器:先获得key的Iterator,再循环遍历
            Iterator iter2 = keySet.iterator();
            String key = null;
            while (iter2.hasNext()) {
                key = (String)iter2.next();
                System.out.print(key);
                System.out.println(map.get(key));
            }
    
            // 方法3:获得value的Set集合 再遍历
            System.out.println("方法3");
    
            // 1\. 获得value的Set集合
            Collection valueSet = map.values();
    
            // 2\. 遍历Set集合,从而获取value
            // 2.1 获得values 的Iterator
            Iterator iter3 = valueSet.iterator();
            // 2.2 通过遍历,直接获取value
            while (iter3.hasNext()) {
                System.out.println(iter3.next());
            }
    
        }
    
    }
    
    // 注:对于遍历方式,推荐使用针对 key-value对(Entry)的方式:效率高
    // 原因:
       // 1\. 对于 遍历keySet 、valueSet,实质上 = 遍历了2次:1 = 转为 iterator 迭代器遍历、2 = 从 HashMap 中取出 key 的 value 操作(通过 key 值 hashCode 和 equals 索引)
       // 2\. 对于 遍历 entrySet ,实质 = 遍历了1次 = 获取存储实体Entry(存储了key 和 value )
    
    
    • 运行结果
    方法1
    Java2
    iOS3
    数据挖掘4
    Android1
    产品经理5
    ----------
    Java2
    iOS3
    数据挖掘4
    Android1
    产品经理5
    方法2
    Java2
    iOS3
    数据挖掘4
    Android1
    产品经理5
    ----------
    Java2
    iOS3
    数据挖掘4
    Android1
    产品经理5
    方法3
    2
    3
    4
    1
    5
    
    

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


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

    • 在进行真正的源码分析前,先讲解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;
    
    
    • 参数示意图
    image
    • 此处 详细说明 加载因子
    image

    5. 源码分析

    • 本次的源码分析主要是根据 使用步骤 进行相关函数的详细分析
    • 主要分析内容如下:
    image
    • 下面,我将对每个步骤内容的主要方法进行详细分析

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

    /**
      * 函数使用原型
      */
      Map<String,Integer> map = new HashMap<String,Integer>();
    
     /**
       * 源码分析:主要是HashMap的构造函数 = 4个
       * 仅贴出关于HashMap构造函数的源码
       */
      public class HashMap<K,V>
          extends AbstractMap<K,V>
          implements Map<K,V>, Cloneable, Serializable{
    
        // 省略上节阐述的参数
    
      /**
         * 构造函数1:默认构造函数(无参)
         * 加载因子 & 容量 = 默认 = 0.75、16
         */
        public HashMap() {
            // 实际上是调用构造函数3:指定“容量大小”和“加载因子”的构造函数
            // 传入的指定容量 & 加载因子 = 默认
            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) {
    
            // HashMap的最大容量只能是MAXIMUM_CAPACITY,哪怕传入的 > 最大容量
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
    
            // 设置 加载因子
            this.loadFactor = loadFactor;
            // 设置 扩容阈值 = 初始容量
            // 注:此处不是真正的阈值,是为了扩展table,该阈值后面会重新计算,下面会详细讲解  
            threshold = initialCapacity;   
    
            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);
    
            // 该方法用于初始化 数组 & 阈值,下面会详细说明
            inflateTable(threshold);
    
            // 将传入的子Map中的全部元素逐个添加到HashMap中
            putAllForCreate(m);
        }
    }
    
    
    • 注:
      1. 此处仅用于接收初始容量大小(capacity)、加载因子(Load factor),但仍无真正初始化哈希表,即初始化存储数组table
      2. 此处先给出结论:真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()时。下面会详细说明

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


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

    • 添加数据的流程如下

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

    image
    • 源码分析
     /**
       * 函数使用原型
       */
       map.put("Android", 1);
            map.put("Java", 2);
            map.put("iOS", 3);
            map.put("数据挖掘", 4);
            map.put("产品经理", 5);
    
       /**
         * 源码分析:主要分析: HashMap的put函数
         */
        public V put(K key, V value)
    (分析1)// 1\. 若 哈希表未初始化(即 table为空) 
            // 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table  
            if (table == EMPTY_TABLE) { 
            inflateTable(threshold); 
        }  
            // 2\. 判断key是否为空值null
    (分析2)// 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;
        }
    
    
    • 根据源码分析所作出的流程图
    image
    • 下面,我将根据上述流程的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);  
    }  
    
        /**
         * 分析1:roundUpToPowerOf2(toSize)
         * 作用:将传入的容量大小转化为:>传入容量大小的最小的2的幂
         * 特别注意:容量大小必须为2的幂,该原因在下面的讲解会详细分析
         */
    
         private static int roundUpToPowerOf2(int number) {  
    
           //若 容量超过了最大值,初始化容量设置为最大值 ;否则,设置为:>传入容量大小的最小的2的次幂
           return number >= MAXIMUM_CAPACITY  ? 
                MAXIMUM_CAPACITY  : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;  
    
    
    • 再次强调:真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用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 中的位置(即数组下标、索引)的过程

    image

    ————————————————————————————————
    作者:Carson_Ho
    链接:https://www.jianshu.com/p/e5c8a814c0ca

    相关文章

      网友评论

          本文标题:Java基础:HashMap详解一

          本文链接:https://www.haomeiwen.com/subject/qiomoqtx.html