zoukankan      html  css  js  c++  java
  • HashMap 源码分析

    HashMap 介绍

    HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。

    HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。

    HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。

    HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。

    通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。

    HashMap是通过"拉链法"实现的哈希表。它包括几个重要的成员变量:table, size, threshold, loadFactor, modCount。

      table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。

      size是HashMap的大小,它是HashMap保存的键值对的数量。

      threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值="容量*加载因子",当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。

      loadFactor就是加载因子。

      modCount是用来实现fail-fast机制的。

    源码分析

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
    
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
    
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
    
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
    

    put源码解析


    1. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

    2. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

    3. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

    4. 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

    5. 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

    6. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

    Resize 扩容

    扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

    我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。

     1 void resize(int newCapacity) {   //传入新的容量
     2     Entry[] oldTable = table;    //引用扩容前的Entry数组
     3     int oldCapacity = oldTable.length;         
     4     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
     5         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
     6         return;
     7     }
     8  
     9     Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
    10     transfer(newTable);                         //!!将数据转移到新的Entry数组里
    11     table = newTable;                           //HashMap的table属性引用新的Entry数组
    12     threshold = (int)(newCapacity * loadFactor);//修改阈值
    13 }
    

    这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

     1 void transfer(Entry[] newTable) {
     2     Entry[] src = table;                   //src引用了旧的Entry数组
     3     int newCapacity = newTable.length;
     4     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
     5         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
     6         if (e != null) {
     7             src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
     8             do {
     9                 Entry<K,V> next = e.next;
    10                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
    11                 e.next = newTable[i]; //标记[1]
    12                 newTable[i] = e;      //将元素放在数组上
    13                 e = next;             //访问下一个Entry链上的元素
    14             } while (e != null);
    15         }
    16     }
    17 }
    

    newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

    下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。

    jdk1.8 做的优化

    下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    length 设计成2倍的好处

    这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。

    1. 一方面 indexFor 寻找的时候,h & (length-1) 比 取余运算%快很多
    static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
         return h & (length-1);  //第三步 取模运算
    }
    
    1. 另一方面, 设计成2的幂次,有利于resize时候,链表移动时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

    HashMap 多线程操作导致死循环问题

    在多线程下,进行 put 操作会导致 HashMap 死循环,原因在于 HashMap 的扩容 resize()方法。由于扩容是新建一个数组,复制原数据到数组。由于数组下标挂有链表,所以需要复制链表,但是多线程操作有可能导致环形链表。复制链表过程如下:
    以下模拟2个线程同时扩容。假设,当前 HashMap 的空间为2(临界值为1),hashcode 分别为 0 和 1,在散列地址 0 处有元素 A 和 B,这时候要添加元素 C,C 经过 hash 运算,得到散列地址为 1,这时候由于超过了临界值,空间不够,需要调用 resize 方法进行扩容,那么在多线程条件下,会出现条件竞争,模拟过程如下:

    线程一:读取到当前的 HashMap 情况,在准备扩容时,线程二介入

    线程二:读取 HashMap,进行扩容

    线程一:继续执行

    这个过程为,先将 A 复制到新的 hash 表中,然后接着复制 B 到链头(A 的前边:B.next=A),本来 B.next=null,到此也就结束了(跟线程二一样的过程),但是,由于线程二扩容的原因,将 B.next=A,所以,这里继续复制A,让 A.next=B,由此,环形链表出现:B.next=A; A.next=B

    HashMap常用方法测试

    package map;
    
    import java.util.Collection;
    import java.util.HashMap;
    import java.util.Set;
    
    public class HashMapDemo {
    
        public static void main(String[] args) {
            HashMap<String, String> map = new HashMap<String, String>();
            // 键不能重复,值可以重复
            map.put("san", "张三");
            map.put("si", "李四");
            map.put("wu", "王五");
            map.put("wang", "老王");
            map.put("wang", "老王2");// 老王被覆盖
            map.put("lao", "老王");
            System.out.println("-------直接输出hashmap:-------");
            System.out.println(map);
            /**
             * 遍历HashMap
             */
            // 1.获取Map中的所有键
            System.out.println("-------foreach获取Map中所有的键:------");
            Set<String> keys = map.keySet();
            for (String key : keys) {
                System.out.print(key+"  ");
            }
            System.out.println();//换行
            // 2.获取Map中所有值
            System.out.println("-------foreach获取Map中所有的值:------");
            Collection<String> values = map.values();
            for (String value : values) {
                System.out.print(value+"  ");
            }
            System.out.println();//换行
            // 3.得到key的值的同时得到key所对应的值
            System.out.println("-------得到key的值的同时得到key所对应的值:-------");
            Set<String> keys2 = map.keySet();
            for (String key : keys2) {
                System.out.print(key + ":" + map.get(key)+"   ");
    
            }
            /**
             * 另外一种不常用的遍历方式
             */
            // 当我调用put(key,value)方法的时候,首先会把key和value封装到
            // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取
            // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来
            // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了
            Set<java.util.Map.Entry<String, String>> entrys = map.entrySet();
            for (java.util.Map.Entry<String, String> entry : entrys) {
                System.out.println(entry.getKey() + "--" + entry.getValue());
            }
            
            /**
             * HashMap其他常用方法
             */
            System.out.println("after map.size():"+map.size());
            System.out.println("after map.isEmpty():"+map.isEmpty());
            System.out.println(map.remove("san"));
            System.out.println("after map.remove():"+map);
            System.out.println("after map.get(si):"+map.get("si"));
            System.out.println("after map.containsKey(si):"+map.containsKey("si"));
            System.out.println("after containsValue(李四):"+map.containsValue("李四"));
            System.out.println(map.replace("si", "李四2"));
            System.out.println("after map.replace(si, 李四2):"+map);
        }
    
    }
    
    

    参考文章

    Java 8系列之重新认识HashMap

    Java集合:HashMap详解(JDK 1.8)

  • 相关阅读:
    gateway dblink transport mssql image datatype to oracle blob datatype
    Sql server 数据库备份、恢复等
    sql full left right inner cross 基础
    真的发现自己已不再年轻
    利用日志备份恢复时,提示 该 LSN 太晚,无法应用到数据库
    系统调用原理(转)
    Linux添加自定义系统调用
    libusb 介绍
    用户空间与内核空间数据交换的方式(4)relayfs
    用户空间与内核空间数据交换的方式(2)procfs
  • 原文地址:https://www.cnblogs.com/Draymonder/p/10356043.html
Copyright © 2011-2022 走看看