HashMap 面试盘点

                                           HashMap 面试盘点

潇潇基本上每去一家公司都会被问到HashMap,该数据结构涉及的技能和知识点~~~~~~

比如说:

  1. HashMap 原理
  2. HashMap put原理
  3. HashMap 怎么设置初始化容量大小
  4. HashMap hash函数怎么设计? 与1.7hash函数有何区别
  5. HashMap 1.8还做了那些优化
  6. HashMap 的线程安全吗

有些事情,往往不能剥开洋葱的💗看本质,可能是每剥开"洋葱"的一层都有点辣眼睛了吧,代码也是一样 ,过程当然也相当痛苦,但是每一个事情结束之后,往往是一种解脱和升华把~~~~

 

HashMap原理

OK 请看下列图~~~~~~~

1.8 HashMap得实现原理 红黑树+链表/数组

 

 

 

1.7 HashMap得实现原理 链表+数组

 

嘿嘿 细心的同学会发现都是基于数组,为什么jdk1.8采用的对象数组是Node 而JDK1.7采用的对象是Entry,他们有何区别呢????

在JDK1.7中Entry用key的hashCode多次异或与余来决定key会被放到数组里的位置,如果hashCode相同及取余相同,放到Entry同一格子里,然后形成一个链表,如果hashcode都相同,链表长度会增加,进行put/get 空间复杂会增加O(n),

int hash = (key == null) ? 0 : hash(key); //计算hash值
 // 获取index 下标
    static int indexFor(int h, int length) {
        return h & (length-1);
    }
    final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
        h ^= k.hashCode();   // 异或
        h ^= (h >>> 20) ^ (h >>> 12);     //高位补零, 与>>计算差不多                                                                                     
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

在JDK1.8中Node对象不知道存储的对象是链表还是红黑树结构,如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里,如果同一个格子里的key不超过8个和链表长度小于64,使用链表结构存储,否则调用treeifyBin函数,将链表转换为红黑树,就算hashcode相同,红黑树查询特定的元素空间复杂度O(log n) 具体原理请看下文HashMap put的原理。

 

HashMap put原理!

/**
 * The next size value at which to resize (capacity * load factor).
 * 下一个要调整大小的大小值(容量*负载系数)
**/
int threshold;
/**
 * The load factor for the hash table.
 * 负载因子
 */
final float loadFactor;

final int capacity() {
    return (table != null) ? table.length :
        (threshold > 0) ? threshold :
        DEFAULT_INITIAL_CAPACITY;
}
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
简单文字描述下 :
table 数组大小是由 capacity 这个参数确定的,默认是(DEFAULT_INITIAL_CAPACITY )16,
也可以构造时传入,最大限制是1<<30
loadFactor 装载因子,table是否需要动态扩展,假设使用默认构造函数,那么默认加载因子
0.75f,数组大小默认16 ,那么threshold就是12 ,如果table长度大于12则需要进行扩容,
扩容时调用resize() ,table的长度是原来的俩倍,这个俩倍指得不是threshold俩倍
,必须是2次幂(至于hashmap为什么这么设计后续慢慢为你颇析)

嘿嘿 先看潇某人的一张草图,然后再看源码 我想应该会心有所止吧!!!!

 

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    1 .  判断table是否为空,为空进行初始化 resize
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    2.   计算 (n - 1) & hash index  是否为空,为空进行插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
   3 .  Index 不为空,计算key是否存在, key存在进行覆盖 
         e = p  ---->if (e != null) existing mapping for key
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
   4  . 判断是否是红黑树节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
   5   . 加入链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
   6   .  链表长度大于8 自动转换红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin有段代码如果数组小于64 不进行转换
                        treeifyBin(tab, hash);
                    break;
                }
   7    .  链表中的key是否存在 , 存在覆盖该值,多线程情况下,可能会覆盖该值
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key  key存在进行覆盖
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
   8 .   mod 计算++  添加成功   
    ++modCount;
   9 .   是否需要扩容, 注意多线程的情况下,可能会执行多次
    if (++size > threshold)    
        resize();
     // 允许LinkedHashMap后处理的回调, 只是一个空的方法
    afterNodeInsertion(evict);  
    return null;
}

HashMap 怎么设置初始化容量大小

看过源码的同学都知道,不知道得也没有关系,SEE SEE 千万可别see you la la 了 咳咳咳

new HashMap(); 那么默认的增长因子为0.75f , 默认初始化大小16 (1<<4) 最大容量不得超过 (1<<30 ) 按照正常得逻辑走threshold(12)如果table>12需要扩容,但是呢,程序得设置总不可能是死得吧,它是可扩展性,有撸壮性得。。 哼 , 呵呵 潇潇就知道没有那么简单,要不然面试官也不会精彩问别人得秘密,毕竟一个成熟有意义得东西,人们往往会记住,去学习。 感觉不自觉得偏离了话题,可能是潇潇生性活泼吧,回归正题

 

如果自己传入容量大小,程序会有什么样得变化呢?带着疑惑, 还是先偷窥下Code吧

public HashMap(int initialCapacity, float loadFactor) {
    //容量大小不能小于0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                          initialCapacity);
     //容量大小小于 1 << 30 
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    // 返回自定义容量 2倍幂的大小
    this.threshold = tableSizeFor(initialCapacity);
}
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;
}

算法就是让初始二进制分别右移1,2,4,8,16位,与自己位或,把高位第一个为1的数通

过不断右移,然后把右移数字于自己之前做位或,从而保证符合大于cap并且是2的整数次幂 

算法有点牛逼 我自己画的图太丑了 0.0. 于是乎借鉴了一张 嗷嗷嗷

 

HashMap hash函数有何区别? 于1.7有何区别

hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。

为什么要这么设计?

这块函数设计也叫扰动函数,目的是为了让哈希函数映射得比较均匀松散,降低hash之间的碰撞,采用计算机语言COR(异或)进行操作,提高程序运算效率

重点说道说道,它与1.7hash函数有何区别,这个过程可能有点漫长,需要追溯到丛林时代,那个荒古,风雨飘摇的时代。在很久很久以前.............. 流传着这么一个故事~~~~~~

 

话说key.hashCode() 函数调用的是key键值类型自带的哈希函数,返回int型散列值,32位2的进制范围-2147483648到2147483648 前后范围大约40亿空间,只要hash函数分配松散,很难出现hash碰撞,问题是40亿长度数组,内存肯定是放不下的,HashMap的默认扩容16,所以这个散列不能拿来进行使用,所以1.7中对数组的长度进行取模,得到的余数用来访问下表

bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) { 
    return h & (length-1);
}

补充一下,位运算比余运算要快0.0

这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

 10100101 11000100 00100101
 & 
 00000000 00000000 00001111---------------------------------- 
 00000000 00000000 00000101    //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。

这时候 hash 函数(“扰动函数”)的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

 

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

 

结果显示,当HashMap数组长度为512的时候(),也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

另外Java1.8相比1.7做了调整,1.7做了四次右移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。

下面是1.7的hash代码:

static int hash(int h) {  
  h ^= (h >>> 20) ^ (h >>> 12); 
  return h ^ (h >>> 7) ^ (h >>> 4);
}

HashMap 1.8还做了那些优化

  1. 数组+链表改成了数组+链表或红黑树;
  2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
  3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
  4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容
  5. hash函数使用扰动函数,提高计算效率(以描述)

为什么要做这么优化呢?

  1. 减少发生hash冲突,链表长度过长使用红黑树将时间复杂度由O(n)降为O(logn)提交程序效率
  2. 1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环

补充:A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,形成了循环引用

3.这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,举个例子:

   扩容前长度为16,用于计算 (n-1) & hash 的二进制n - 1为0000 1111,  

   扩容后为32后的二进制就高位多了1,============>为0001 1111。

因为是& (与或)运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;

第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)

HashMap 的线程安全吗

在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题

具体可看put源码分析

 

后续有时慢慢整理HashSet(内部实现了HashMap) TreeMap LindedHashMap(基于HashMap) ,后续再整理JUC 线程池 ,CAS 相关

后续打算编写工作中使用的Es ,Redsi ,netty,webSocket,代码集群相关代码及注意细节。

参考:

JDK 源码中 HashMap 的 hash 方法原理是什么?

一个HashMap跟面试官扯了半个小时?(安琪拉,以前上海一个领导写得公众号)

 

 

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值