美文网首页
2.JDK1.7中HashMap底层原理分析

2.JDK1.7中HashMap底层原理分析

作者: Junma_c631 | 来源:发表于2020-09-09 20:15 被阅读0次

    一、hashmap的简单介绍

    JDK1.7中,hashmap的数据结构,采用数组加链表的形式存在;
    我们都知道hashmap中最常用的两个方法put(key,value)和get(key)
    put方法在进行添加元素是怎么做的呢?这里会涉及到哪些逻辑?
    1.table数组长度计算
    1.根据key调用hash()算法生成hash值-h
    2.根据h和数组长度计算数组下标
    3.利用头插法把元素插入entriy链表中
    4.数组的扩容机制 等等。
    

    1.1hashMap的数据结构可以用下图表示

    image.png

    1.2hashMap中的属性介绍

        //transient修饰的变量不参与序列化
        //默认table数组的初始长度16
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
        //table数组可扩展的最大长度2的30次方1073741824
        static final int MAXIMUM_CAPACITY = 1 << 30;
        //默认加载因子和table数组的扩容有关
        //threshold=table数组长度*loadFactory
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        //空table常量
        static final Entry<?,?>[] EMPTY_TABLE = {};
        //键值对数组,默认指向空数组
        transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
        //keyvalue总个数
        transient int size;
        //数组需要扩容时的临界值,当插入元素时的size达到临界值时,需要对数组进行扩容
        //threshold=loadFactor*table数组长度
        int threshold;
        //加载因子
        final float loadFactor;
        //修改次数fail-fast机制有关
        transient int modCount;
        static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
        //hash种子,数组扩容时,是否需要重新计算hash值的判断因子
        transient int hashSeed = 0;
    

    1.3hashMap构造器简单介绍

    //在不指定参数创建hashmap时会调用此构造函数
    public HashMap() {
            //调用有参构造器传参为(16,0.75f)
            this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
     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);
            //给加载因子和扩展临界值赋值。
            this.loadFactor = loadFactor;
            threshold = initialCapacity;
            init();
        }
    

    二、HashMap的put方法以及由此延伸的问题点介绍

    2.1put方法介绍

    public V put(K key, V value) {
        //第一次put数组是空数组    
        if (table == EMPTY_TABLE) {
                //根据扩展临界值创建定长数组
                inflateTable(threshold);
            }
            if (key == null)
               //处理空值
                return putForNullKey(value);
            //根据key生成hash值
            int hash = hash(key);
            //根据hash值和table数组长度生成需要存入元素的数组下标
            int i = indexFor(hash, table.length);
            //根据下标获取头节点并遍历,如果key已经存在,替换成新value值并返回
            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;
                }
            }
            //修改次数,跟fast-fail机制有关,每次put、remove等修改操作modCount都会++
            modCount++;
            //上面循环没有找到存在的key的情况下就会选择添加节点。
            addEntry(hash, key, value, i);
            return null;
        }
    

    2.2第一次put时生成一个2的幂次方数组并让table指向该数组

     private void inflateTable(int toSize) {
            // 获取一个大于等于toSize的最近的2的幂次方数
            int capacity = roundUpToPowerOf2(toSize);
           //根据数组长度和加载因子计算扩展临界值
            threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
            //table引用指向新数组
            table = new Entry[capacity];
            initHashSeedAsNeeded(capacity);
        }
    
    //找到一个最近的大于等于numer的2的幂次方数,作为table数组容量,所以table的容量是一个2的幂次方数.
        private static int roundUpToPowerOf2(int number) {
            // assert number >= 0 : "number must be non-negative";
            return number >= MAXIMUM_CAPACITY
                    ? MAXIMUM_CAPACITY
                    : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
        }
    

    调用该方法前number-1后先左移一位,相当于翻了倍
    Integer.highestOneBit((number - 1) << 1)
    那17举例来查看计算规律
    17 -----0001 0001

    1-----0000 1000
    |-------0001 1001
    2-----0000 0110
    |-------0001 1111
    4-----0000 0001
    |-------0001 1111
    8-----0000 0000
    |-------0001 1111
    ...
    ...
    |--------0001 1111

    上面的过程可以看出规律,就是最终把高位一下的所有数字变成1:
    0001 0001---------->0001 1111
    然后:
    --------------0001 1111
    -->>>1-----0000 1111
    相减--------0001 0000
    最终结果为:16

    public static int highestOneBit(int i) {
            // HD, Figure 3-1
            i |= (i >>  1);
            i |= (i >>  2);
            i |= (i >>  4);
            i |= (i >>  8);
            i |= (i >> 16);
            return i - (i >>> 1);
        }
    

    2.3当key为null的情况put方法怎么处理

    hashMap支持key为null的情况
    会把key为null的值放到table[0]链表里面。

     private V putForNullKey(V value) {
        //获取数组下标为0的entry头节点进行遍历
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //如果已经存在key为null的值,更新最新的值,更新后返回
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //如果原来不存在key为null的值,添加到table[0]的Entry链表中
        addEntry(0, null, value, 0);
        return null;
    }
    

    2.4key的hash算法

     为什么需要对hashcode再次进行那么多位运算?
      我们看下面获取下标的运算
      高位[0111] &运算任意修改都不影响获取的数组下标
      比如 0111 0010 、0110 0010、0011 0010 获取到的
      数组下标没有任何变化,这就导致在算数组下标时,不那么离散
      也就是高位没有参与数组下标的运算。
      进行一些位运算后使hashcode更离散,让高位也参与到数组下标的运算。
      290       0111 0010         
      (16-1)15  0000 1111 
    
    final int hash(Object k) {
        int h = hashSeed;//默认0
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        
        //异或运算相同则为0,不相同则为1
        h ^= k.hashCode();
        //
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    

    2.5当前需要插入或更新的<key,value>数组下标的计算

     获取数组下标的两个特性:
     1.小于数组长度 默认0-15
     2.下标需要充分离散
          与运算:两个为1才为1
          例如如下运算:
          290       0111 0010
          (16-1)15  0000 1111
          与运算    0000 0010  最终结果为2  
        1560&(16-1)-----8
        1261&(16-1)-----13
        1131&(16-1)-----11
        8510&(16-1)-----14
        1530&(16-1)-----10
        1246&(16-1)-----14
        从上面运算结果看,无论h是多大的数,和(2的幂次方-1)做与运算后都会在 【0~(2的幂次方-1)】范围内
    从而可以看出来为什么table数组大小一定是2的幂次方数:
    因为在计算数组下标的时候会用到2的幂次方-1的低位特性,与操作计算数组下标要比取余%操作效率更高
    
    static int indexFor(int h, int length) {
        //对hash值和(数组下标-1)进行&运算
        return h & (length-1);
     }
    

    2.6添加Entry节点以及hashMap的table数组扩容

    void addEntry(int hash, K key, V value, int bucketIndex) {
            if ((size >= threshold) && (null != table[bucketIndex])) {
                //数组扩容
                resize(2 * table.length);
                hash = (null != key) ? hash(key) : 0;
                //数组扩容后对当前需要插入的元素重新计算下标
                bucketIndex = indexFor(hash, table.length);
            }
            //插入元素
            createEntry(hash, key, value, bucketIndex);
    }
    

    hashmap的扩容
    size:所有链表中node元素的总个数.
    扩容条件:
    1.当元素总个数大于等于扩展因子
    2.当前需要插入的table[index]中链表不为空
    为什么要满足这个条件呢?
    最大限度的靠近,使一个table[index]中只存储一个entrynode,
    使链表的长度最小化。这样会最大限度的增加get(key)的效率。
    扩容的长度是原数组大小的2倍:[2 * table.length]

    /**
    数组扩容
    */
     void resize(int newCapacity) {
            Entry[] oldTable = table;
            int oldCapacity = oldTable.length;
            //如果老数组已经是最大的极限值了,就不在扩容直接返回了
            if (oldCapacity == MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return;
            }
            //创建新数组
            Entry[] newTable = new Entry[newCapacity];
            //把老数组中元素转移到新的数组中。
            transfer(newTable, initHashSeedAsNeeded(newCapacity));
            //table指向新的table数组
            table = newTable;
            //给扩展因子重新赋值
            threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    

    数组扩容时老数组中元素转移到新数组,新下标的生成规律

    数组扩容后新的元素下标的规律:
        第一种扩容场景:
          old:
            hash:              0101 0101
            16-1 :             0000 1111 
            &操作之后:          0000 0101   index:5
         new:
            hash:              0101 0101
            32-1 :             0001 1111 
            &操作之后:          0001 0101   index=oldtable.length+5=16+5=31 
          
         第二种扩容场景:
         old:
            hash:              0100 0101
            16-1 :             0000 1111 
            &操作之后:          0000 0101   index:5
         new:
            hash:              0100 0101
            32-1 :             0001 1111 
            &操作之后:          0000 0101   index=5 
        扩容后下标只能是两个位置:要么是扩容后的原位置
        要么是:原数组长度+原位置下标号
    

    老数组元素转移到新数组中的转移过程
    1当前entry的next引用指向新数组的头元素
    2.再把新数组的头元素设置成当前entry(相当于是头插法移植到新数组)

    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;
                    //一般不进行重新计算hash值,key的hash正常是固定的
                    if (rehash) {
                        e.hash = null == e.key ? 0 : hash(e.key);
                    }
                    //根据新的数组长度和节点的hash值重新算下标
                    int i = indexFor(e.hash, newCapacity);
                    //e.next引用指向新数组的头元素
                    e.next = newTable[i];
                    //再把新数组的头元素设置成当前entry(相当于是头插法移植到新数组)
                    newTable[i] = e;
                    //循环条件
                    e = next;
                }
            }
        }
    

    2.7元素插入采用头插法

    void createEntry(int hash, K key, V value, int bucketIndex) {
            //获取table数组下标为bucketIndex的entry头节点
            Entry<K,V> e = table[bucketIndex];
            //利用头插法,插入链表元素。
            table[bucketIndex] = new Entry<>(hash, key, value, e);
            //元素个数+1
            size++;
    }
    

    三、HashMap的快速失败机制 fast-fail

    hashMap中维护了一个属性modCount,每次对hashMap的修改操作modCout都会modCout++
    在创建keySet().iterator()迭代器的时候,会把modCout属性赋值给迭代器expectedModCount,
    我们都知道hashMap是线程不安全的,使用迭代器提供的方法时会先判断modCout是否和expectedModCount相等,
    如果不相等证明在使用迭代器的同时有线程修改了hashmap的元素,这时候就会快速抛异常失败操作。
    

    四、头插法和尾插法的探讨?

    在put方法内部,会循环遍历Entry的链表,无论头插法还是尾插法对于put方法来说都一样,
    都避免不了循环节点来判断是否包含了这个key。
    

    五、多线程情况下调用put方法数组扩容会存在什么问题

    可能会出现死循环。
    数组扩容时,运用头插法进行插入新的列表,这时候链表的顺序和原数组的中链表的顺序
    是倒过来了,也就是entry中next的指向
    发生了变化,多线程情况下,扩容时,如果新增加了两个新的数组,再进行扩容时,某一个新增加的数组的链表元素指向了
    另外一个新增加的元素,这种情况下可能会出现一个封闭的循环链表一直循环。
    

    相关文章

      网友评论

          本文标题:2.JDK1.7中HashMap底层原理分析

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