java并发编程-HashMap底层原理与面试分析

前言

写博客即是为了记录自己的学习历程,也希望能够遇到志同道合的朋友一起学习。文章在撰写过程中难免有疏漏和错误,欢迎指出文章的不足之处!更多内容请关注: 小布丁.

一、HashMap的数据结构与基本特性

HashMap是应用更广泛的哈希表实现,而且大部分情况下,都能在常数时间性能的情况下进行put和get操,HashMap基本特性包括:

  • 1.7版本中HashMap底层数据结构是数组+链表的方式;1.8版本中是数组+链表+红黑树实现
  • HashMap中put操作是根据key的hash值来确定存入的数组的位置,通常来说是哪个桶位置(bucket)
  • 如果计算hash(key)时存在相同的值时,会在该桶位形成链表依次存入该链表中
  • HashMap初始容量是16,也就是数组长度是16,如果初始化的时候指定容量,必须是2的指数次幂(如果不是,HashMap内部会重新计算出最接近并且大于该数值的2的指数次幂,简单理解如果传入17,那么实际初始化容量是32)
  • HashMap会在达到默认的负载因子0.75f(总容量的0.75倍)后进行扩容,扩容为原来的两倍
  • 允许空键和空值(但空键只有一个,且放在第一位)
  • 元素是无序的,而且顺序会不定时改变
  • key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法
  • 当链表的长度大于8会转红黑树(JDK1.8版本)
  1. JDK1.7中HashMap数据结构如下
    在这里插入图片描述
  2. JDK1.8中HashMap数据结构如下
    在这里插入图片描述

二、JDK1.7版本中HashMap源码分析

1.HashMap的重要属性

  • DEFAULT_INITIAL_CAPACITY = 1 << 4; HashMap表默认初始容量(16)
  • MAXIMUM_CAPACITY = 1 << 30; 最大Hash表容量(2^32)
  • DEFAULT_LOAD_FACTOR = 0.75f;默认加载因子
  • TREEIFY_THRESHOLD = 8;链表转红黑树阈值
  • UNTREEIFY_THRESHOLD = 6;红黑树转链表阈值
  • MIN_TREEIFY_CAPACITY = 64;链表转红黑树时hash表最小容量阈值,达不
    到优先扩容

2.HashMap初始化

HashMap在初始化的时候如果在构造方法中没有传入初始容量,那么初始的HashMap容量是默认的容量DEFAULT_INITIAL_CAPACITY = 1 << 4(位运算16),如果传入了初始化容量,会重新计算初始容量,计算标准是最接近并且大于传入初始容量的2的指数次幂,代码如下:

 public HashMap(int initialCapacity) {
        this(initialCapacity, 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;
        this.threshold = tableSizeFor(initialCapacity);
    }

大家主要看tableSizeFor(initialCapacity),initCapacity是我们传入的初始化容量的值

/**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

实际返回是最接近并且大于该数值的2的指数次幂,传11实际是16,传17实际是32,以此类推

3.HashMap的重要方法

  • HashMap的put(k,v)、get(k)方法
    HashMap底层数的数据结构是数据+链表的结构,正常来讲,对于数组的插入是直接从头部开始直接以此往后放,取值时候遍历数组如果相等则返回。但是HashMap并不是这样的,HashMap会先将key进行hashCode运算,得出来一个正负整数,这个时候这个正负整数并不能直接确定数组下标的位置。因为hash(k)运算出来的值可能比16大,也有可能比16小,那么怎么能刚好落在数组的0-15之间呢?那么对16进行取模运算hashCode(k)%16=[0-15]就能获得数组下标,如果通过key取值时,也是先计算key的hashCode的值进行取模运算来确定数组的下标位置,这样完美put/get操作。但是中间会存在一个问题,key的hashCode值进行16取模运算可能不同的key最后计算的下标都是相同的,这就是所谓的hash冲突。在JDK1.7HashMap中对于产生hash冲突的值引入了链表的数据结构将产生冲突的这些值采用头部插入法的方式放入这个数组对应下标的链表中。引入链表的数据结构中对于取值操作如果刚好在数组中那么此时的时间复杂度是O(1),如果是在链表的数据结构中那么时间复杂度是O(n)。
    但是HashMap对于计算数组下标并不是进行的取模运算,而是用的[位运算^],因为位运算的效率要远远高于取模运算。下面以JDK1.7版本为例:

     public V put(K key, V value) {
            if (key == null)
                return putForNullKey(value);
            int hash = hash(key); //此处先进行key的再hash运算,使这个key更均匀的落在数组中
            int i = indexFor(hash, table.length); //此处根据再hash的值进行位运算
            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;
                }
            }
    
            modCount++;
            addEntry(hash, key, value, i);  //进行新增具体操作
            return null;
        }
    
    

    上面的HashMap在put中,先将key先进行再hash运算(注意1.7和1.8版本运算不一样,此处不深究),关键是下面那个indexFor(hash,table.length)方法,此处实际上是通过ket的再hash值和数组长度-1通过&运算符计算出数组的下标位置,具体代码如下:

     /**
         * Returns index for hash code h.
         */
        static int indexFor(int h, int length) {
            return h & (length-1);
        }
    

    注意,此处有个关键点,为什么是&的数组长度减一,而不是直接&数组的长度,原因在于HahsMap中此处数组的长度实际上是HashMap的容量,而我们知道HashMap的容量总是2的指数次幂,那么如果一个正负整数与2的指数幂进行&运算结果则要么是0,要么就是这个2的指数次幂这个数,比如初始容量为16,这样造成的结果就是如果存入数值那么存的位置要么就是数组的开头位置,如果是16可能存的时候还会数组越界,这样的结果肯定不是我们所期望的,具体的&运算可以参考博文最后扩展部分。

  • HashMap的resize()方法
    大家仔细看HashMap的put方法中最后的 addEntry(hash, key, value, i);这个方法,此处是put的具体实现,代码如下:

    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);
        }
    

    解释一下上面的代码,if语句里面的size指的是当前数组中包换的键值对的数量,threshold指的是整个数组容量乘以负载因子0.75f以后的数量,很显然这里是判断如果大于容量的四分之三进行扩容。那么再看下具体的扩容代码:

    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 = newTable;
            threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
        }
    
    

    上面的代码中没什么好说的,如果达到最大容量了直接返回,否则将老数组的数据转移到新的数组中,具体实现是靠transfer()方法,实现过程如下:

    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;   //第一行
                    if (rehash) {
                        e.hash = null == e.key ? 0 : hash(e.key);
                    }//此处还是和最开始的一样,通过新的key值再hash和新的容量进行与位运算,只不过此前是16,此处是32,算出来的结果在0-31之间
                    int i = indexFor(e.hash, newCapacity); //第二行
                    e.next = newTable[i];   //第三行
                    newTable[i] = e; //第四行号
                    e = next;  //第五行
                }
            }
        }
    
    

    代码已经很明显了,通过遍历老的数组,重新计算key的hash值通过与新的数组长度-1进行与&运算取得新的数组下表进行值的转移。

4.HashMap扩容在多线程环境下形成链表环造成的死锁问题

死锁问题的核心在于多线程扩容的时候会形成链表环,大家看上一小节的代码,主要作用如下:

  • 第一行:记录oldhash表中e.next
  • 第二行:rehash计算出数组的位置(hash表中桶的位置)
  • 第三行:e要插入链表的头部, 所以要先将e.next指向new hash表中的第一个元素
  • 第四行:将e放入到new hash表的头部
  • 第五行: 转移e到下一个节点, 继续循环下去

个人分析链表环形成原因是这样的,比如现在存在链表上的数据分别是1234,扩容后假设存入新的链表是4321。假设线程1在存2的时候,线程2开始扩容,那么它要存的话就是3412,中间指针的next会有值,循环指着下去,死循环。

这块理解的不太透彻,希望来个大佬帮我好好分析一下。

三、总结与面试分析

1.HashMap的容量为什么是2的指数次幂?

首先对于HashMap的put方法中寻找桶的下标地址按照我们正常思维必须要保证下面几个方面:

  • 尽可能的均匀分布,最好是满足取模16的结果分布
  • 不能超过数组的下标
  • 运算效率要尽可能的高

大家都知道HashMap对于寻找数组下标是通过位运算&来操作的,hashCode(key)&s(size-1),假设size-1为偶数,转换为二进制数末位数总是0,比如2、4、8、16对应的2进制分别是10、100、1000、10000,此时如果作&的位运算,最后的二进制结果肯定就是尾数肯定是0,不可能是1,那么最后计算桶的位置不用说,永远也不会到奇数桶位置上去,不符合均匀分布的原则。这样一来,偶数去掉,那么为什么是size-1呢,为什么不是减3呢,或者5呢?其实这个还是一个均匀分布的为题,只有在2的指数-1这种极限数值其二进制的占位才最多可能是1,如果是其它奇数,总有那么几个桶位是算不出来的,不符合均匀分布。

2.HashMap扩容因子是0.75f有什么讲究?

首先来考虑一个问题,如果扩容因子是0.5,也就是达到容量的一半就进行扩容,这个很明显空间利用率不够。那么如果达到百分之百扩容又会怎样呢,百分百扩容那么在put操作的寻址算法很明显肯定会更复杂。0.75是一个兼顾了时间和空间的一个数值,也是一个符合泊松分布的数值(这问题应该问的不多吧)。关于泊松分布这么理解吧,就是这个链表的长度出现的概率,在java中出现的概率大约如下,每一种语言都不一样,但是大致都差不多,在0.75的是时候最合适。在这里插入图片描述

3.说说HashMap中对于桶的寻址算法的实现过程或者如何计算hash值?

根据源码是先进行左位移后再与数组容量-1进行&位运算

4.JDK1.7和JDK1.8中HashMap的区别有哪些?

  • 对产生hash冲突的处理。 1.7版本中如果发生hash碰撞会将值放在该数组下标的链表结构上,1.8版本中最开始会放在链表中,如果链表长度超过8并且容量要大于64,链表会转成红黑树的数据结构.注意容量如果没有超过64,优先扩容
  • 扩容的不同。1.7版本扩容采取的是头插法的形式,多线程环境下扩容可能会形成链表环产生死锁的问题。1.8版本采用高低位算法的方式,不会产生链表环的问题
  • 数据插入方式的不同。

总结

HashMap是一个键值对的集合,1.7版本底层采用数组+链表、1.8版本采用数组+链表+红黑树的数据结构存储数据。HashMap的初始容量如果没有指定是16,负载因子是0.75f,也就是达到总容量的0.75f会进行扩容。HashMap在1.7版本中由于头插法的原因在多线程的环境下会形成链表环造成死锁的问题,1.8版本采用高低位算法修复。HashMap在put的时候并没有采取加锁等同步方式的操作,所以是线程不安全的。如果需要线程安全可以使用ConcurentHashMap或者HashTable等集合

以上就是全部,其中有部分章节由于个人理解不深没有详细展开,比如链表环的形成过程、1.7版本和1.8版本中hash算法的不同、put方法的差异,还有高低位算法解决链表环的问题等,后期可能会逐一完善。以上内容如有错误之处,欢迎留言指正,不胜感激!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值