zoukankan      html  css  js  c++  java
  • Jdk1.7下的HashMap源码分析

    本文主要讨论jdk1.7下hashMap的源码实现,其中主要是在扩容时容易出现死循环的问题,以及put元素的整个过程。

    1、数组结构

    数组+链表
    

    示例图如下:

    常量属性

    /**
     * The default initial capacity - MUST be a power of two.
     * 默认初始容量大小
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
    /**
     * MUST be a power of two <= 1<<30.
     * hashMap最大容量,可装元素个数
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    /**
     * The load factor used when none specified in constructor.
     * 加载因子,如容量为16,默认阈值即为16*0.75=12,元素个数超过(包含)12且,扩容
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /**
     * 空数组,默认数组为空,初始化后才才有内存地址,第一次put元素时判断,延迟初始化
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};
    
    

    2、存在的死循环问题

    扩容导致的死循环,jdk1.7中在多线程高并发环境容易出死循环,导致cpu使用率过高问题,问题出在扩容方法resize()中,更具体内部的transfer方法:将旧数组元素转移到新数组过程中,源码如下:

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
     //1.如果原来数组容量等于最大值了,2^30,设置扩容阈值为Integer最大值,不需要再扩容
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
      //2.创建新数组对象 
        Entry[] newTable = new Entry[newCapacity];
     //3.将旧数组元素转移到新数组中,分析一
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
     //4.重新引用新数组对象和计算新的阈值
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    

    transfer方法

    /**
     * Transfers all entries from current table to newTable.
     * 从当前数组中转移所有的节点到新数组中
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍历旧数组
        for (Entry<K,V> e : table) {
        //1,首先获取数组下标元素
            while(null != e) {
                //2.获取数组该桶位置链表中下一个元素
                Entry<K,V> next = e.next;
         //3.是否需要重新该元素key的hash值
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
        //4,重新确定在新数组中下标位置
                int i = indexFor(e.hash, newCapacity);
        //5.头插法:插入新链表该桶位置,若有元素,就形成链表,每次新加入的节点都插在第一位,就数组下标位置
                 e.next = newTable[i];
                newTable[i] = e;
        //6.继续获取链表下一个元素        
                e = next;
            }
        }
    }
    
    
    //传入容量值返回是否需要对key重新Hash
    final boolean initHashSeedAsNeeded(int capacity) {
        //1.hashSeed默认为0,因此currentAltHashing为false
        boolean currentAltHashing = hashSeed != 0;
       //2,sun.misc.VM.isBooted()在类加载启动成功后,状态会修改为true
      // 因此变数在于,capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD,debug发现正常情况ALTERNATIVE_HASHING_THRESHOLD是一个很大的值,使用的是Integer的最大值
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        //3,两者异或,只有不相同时才为true,即useAltHashing =true时,dubug代码发现useAltHashing =false,
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
      //正常情况下是返回false,即不需要重新对key哈希
        return switching;
    }
    

    上面源码展示转移元素过程:

    以下模拟2个线程并发操作hashMap 在put元素时造成的死循环过程:

    链表死循环图例:

    3、put方法

    1.7的put方法,因没有红黑树结构,相比较1.8简单, 容易理解,流程图如下所示:

    代码如下:

    public V put(K key, V value) {
        //1,若当前数组为空,初始化
        if (table == EMPTY_TABLE) {
           //分析1
            inflateTable(threshold);
        }
        //2,若put的key为null,在放置在数组下标第一位,索引为0位置,从该源码可知
       // hashMap允许 键值对 key=null,但是只能有唯一一个
        if (key == null)
            // 分析2
            return putForNullKey(value);
        //3,计算key的hash,这里与1.8有区别 
        //分析3  
        int hash = hash(key);
        // 4,确定在数组下标位置,与1.8相同
        int i = indexFor(hash, table.length);
        // 5,遍历该数组位置,即该桶处遍历
        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))) {
            // 找到相同的key,则覆盖原value值,返回旧值
                V oldValue = e.value;
                e.value = value;
                //该方法为空,不用看
                e.recordAccess(this);
                return oldValue;
            }
        }
       //因为hashMap线程不安全,修改操作没有同步锁,
       //该字段值用于记录修改次数,用于快速失败机制 fail-fast,防止其他线程同时做了修改,抛出并发修改异常
        modCount++;
        // 6,原数组中没有相同的key,以头插法插入新的元素
        //分析4
        addEntry(hash, key, value, i);
        return null;
    }
    

    分析1: HashMap如何初始化数组的,延迟初始化有什么好处?

    结论: 1、1.7,1.8都是延迟初始化,在put第一个元素时创建数组,目的是为了节省内存。

    初始化代码:

    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        //1.该方法非常重要,目的为了得到一个比toSize最接近的2的幂次方的数,
       // 且该数要>=toSize,这个2的幂次方方便后面各种位运算
       // 如:new HashMap(15),指定15大小集合,内部实际 创建数组大小为2^4=16
       // 分析见下
        int capacity = roundUpToPowerOf2(toSize);
       //2,确定扩容阈值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //3,初始化数组对象
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    

    Q:如何确保获取到比toSize 最接近且大于等于它的2的幂次方的数?

    深入理解roundUpToPowerOf2方法:

    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
    //如果number大于等于最大值 2^30,赋值为最大,主要是防止传参越界,number一定是否非负的 
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
            //核心在于Integer.highestOneBit((number - 1) << 1) 此处
    }
    

    先抛出2个问题:

    1:这个 (number - 1) << 1 的作用是什么?

    2:这个方法highestOneBit肯定是为了获取到满足条件的2的幂次方的数,背后的原理呢?

    结论: Integer的方法highestOneBit(i) 这个方法是通过位运算,获取到i的二进制位最左边(最高位)的1,其余位都抹去,置为0,即获取的是小于等于i的2的幂次方的数.

    如果直接传入number,那么获取到的是2的幂次方的数,但是该数一定小于等于number,但这不是我们的目的;

    如highestOneBit(15)=8highestOneBit(21)=16而我们是想要获取一个刚刚大于等于number的2次方的数,(number-1)<<1 因此需要先将number 扩大二倍number <<1 , 为什么需要number-1,是考虑到临界值问题,恰好number本身就是2的幂次方,如 number=16,扩大2倍后为32, highestOneBit方法计算后结果还是32,这不符合需求。

    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的幂次方二进值特点:只有最高位为1,其他位全为0

    目的:将传入i的二进制最左边的1保留,其余低位的1全变为0

    原理:某数二进制: 0001 ,不关心其低位是什么,以*代替,进行运算

    • 右移1位
     i |= (i >> 1); 
     0001****
    |
     00001***  
    ----------
     00011***  #保证左边2位是1
    
    • 右移2位
     i |= (i >> 2); 
     00011***
    |
     0000011* 
    ----------
     0001111*  #保证左边4位是1
    
    • 右移4位
     i |= (i >> 4); 
     0001111*
    |
     00000001 
    ----------
     00011111  #把高位以下所有位变为1了,该数还是只有5位,该计算可将8位下所有的置为1
    

    Q:为什么要再执行右移8位,16位?

    因int类型 4个字节,32位,这样可以一定可以保证将低位全置为1;

    • 最后一步,大功告成!
    i - (i >>> 1);
    #此时 i= 00011111
     00011111
    -
     00001111 #无符号右移1位
    ---------
     00010000  #拿到值
    

    分析2: HashMap如何处理key 为null情况,value呢?

    结论:

    1. 允许key为null,但最多唯一存在一个,放在数组下标为0位置
    2. value为null的键值对可以有多个
    3. 由1,2 推得,键值对都为null的Entry对象可以有,但最多一个
    private V putForNullKey(V value) {
        //1.直接table[0] 位置获取,先遍历链表(这里对该数组位置统称为链表,可能没有元素,或者只有一个元素,或者链表)查找是否存在相同的key,存在覆盖原值 
        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++;
      //此时注意添加节点时,第一个0即代表数组下标位置,后面会分析该方法
        addEntry(0, null, value, 0);
        return null;
    }
    

    分析3:如何实现hash算法,保证key的hash值均匀分散,减少hash冲突?

    jdk1.7中为了尽可能的对key的hash后均匀分散,扰动函数实现采用了 5次异或+4次位移

    final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        //k的hashCode值 与hashSeed 异或 
        h ^= k.hashCode();
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    

    分析4:插入新的节点到map中,如果原数组总元素个数超过阈值,先扩容再插入节点

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //总元素个数大于等于阈值 且 当前数组下标已存在元素了: 扩容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //1,扩容,上面已分析过代码
            resize(2 * table.length);
            //2,计算新加key的hash值,key为null的hash值为0
            hash = (null != key) ? hash(key) : 0;
            //3,确保计算的数组下标一定在数组有效索引内,见分析5
            bucketIndex = indexFor(hash, table.length);
        }
        // 4,扩容后再插入新数组中
        createEntry(hash, key, value, bucketIndex);
    }
    //分析5
    static int indexFor(int h, int length) {
        // 与数组长度-1与运算,一定可以确保结果值在数组有效索引内,且均匀分散
        return h & (length-1);
    }
    // 进一步分析插入节点方法
    void createEntry(int hash, K key, V value, int bucketIndex) {
       //1,首先获取新数组索引位置元素
        Entry<K,V> e = table[bucketIndex];
        //2,头插法插入新节点, Entry构造方法第4个参数e表示指定当前新增节点的next指针指向该节点,形成链表
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        //3,map元素个数+1
        size++;
    }
    

    参考:

    一、1.7解析:https://blog.csdn.net/carson_ho/article/details/79373026

    二、1.8解析:https://www.jianshu.com/p/8324a34577a0

  • 相关阅读:
    163.扩展User模型-一对一方式扩展
    162.扩展User模型-使用Proxy模型
    161.内置User模型的基本使用
    160.验证和授权系统的概述
    159.SQL注入的实现和防御措施
    OS课程 ucore_lab2实验报告
    IdentityServer4专题之七:Authorization Code认证模式
    IdentityServer4专题之六:Resource Owner Password Credentials
    IdentityServer4专题之五:OpenID Connect及其Client Credentials流程模式
    IdentityServer4专题之四:Authorization Endpoint、Token Endpoint、scope、Access Token和Refresh Token、授权服务器发生错误
  • 原文地址:https://www.cnblogs.com/flydashpig/p/13492269.html
Copyright © 2011-2022 走看看