zoukankan      html  css  js  c++  java
  • HashMap

     转自:https://baijiahao.baidu.com/s?id=1618550070727689060&wfr=spider&for=pc

    1、为什么用HashMap?

    HashMap是一个散列桶(数组和链表),

    它存储的内容是键值对(key-value)映射HashMap采用了数组和链表的数据结构能在查询和修改方便继承了数组的线性查找和链表的寻址修改,HashMap是非synchronized

    所以HashMap很快,HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以)

    HashMap的整体结构如下:

     简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,仅需简单改变引用链即可而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

     2、HashMap的工作原理是什么?

    HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。

    当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node 。

    以下是HashMap初始化 ,简单模拟数据结构Node[] table=new Node[16] 散列桶初始化,tableclass Node {hash;//hash值 key;//键 value;//值 node next;//用于指向链表的下一层(产生冲突,用拉链法)}

    以下是具体的put过程(JDK1.8版)

    1、对Key求Hash值,然后再计算下标

    2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)

    3、如果碰撞了,以链表的方式链接到后面

    4、如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

    5、如果节点已经存在就替换旧值

    6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

    以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

     

    //默认的初始容量16,且实际容量是2的整数幂
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
        //最大容量(传入容量过大将被这个值替换)
        static final int MAXIMUM_CAPACITY = 1 << 30;
        // 默认加载因子为0.75(当表达到3/4满时,才会再散列),这个因子在时间和空间代价之间达到了平衡.更高的因子可以降低表所需的空间,但是会增加查找代价,而查找是最频繁操作
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        //桶的树化阈值:即 链表转成红黑树的阈值,在存储数据时,当链表长度 >= 8时,则将链表转换成红黑树
        static final int TREEIFY_THRESHOLD = 8;
       // 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 <= 6时,则将 红黑树转换成链表
        static final int UNTREEIFY_THRESHOLD = 6;
       //最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)

    为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要

    链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短

    还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换
    假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

    为什么cap要保持为2的幂次方?

    主要与HashMap中的数据存储有关.

    在Java8中,HashMap中key的Hash值由Hash(key)方法计得 HashMap中存储数据table的index是由key的Hash值决定的.在HashMap存储数据时,我们期望数据能均匀分布,以防止哈希冲突.

    自然而然我们就会想到去用%取余操作来实现我们这一构想

     这也就解释了为什么一定要求cap要为2的幂次方.再来看看table的index的计算规则:

    等价于:

     采用二进制位操作&,相对于%,能够提高运算效率,这就是cap的值被要求为2幂次的原因。

    3、有什么方法可以减少碰撞?

     扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

    https://www.cnblogs.com/little-fly/p/7907935.html

    4、HashMap中hash函数怎么是是实现的?

    我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式,我们来看看JDK1.8的源码是怎么做的(被楼主修饰了一下)

    static final int hash(Object key) {if (key == null){ return 0; } int h; h=key.hashCode();返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 //其中n是数组的长度,即Map的数组部分初始化长度 return (n-1)&(h ^ (h >>> 16));}

    简单来说就是

    1、高16bt不变,低16bit和高16bit做了一个异或(得到的HASHCODE转化为32位的二进制,前16位和后16位低16bit和高16bit做了一个异或)

    2、(n·1)&hash=->得到下标

    5、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

    之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

    6、说说你对红黑树的见解?

    1、每个节点非红即黑

    2、根节点总是黑色的

    3、如果节点是红色的,则它的子节点必须是黑色的(反之不一定)

    4、每个叶子节点都是黑色的空节点(NIL节点)

    5、从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

    7、解决hash 碰撞还有那些办法?

    开放定址法。

    当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。

    按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。

    下面给一个线性探查法的例子  

    问题:已知一组关键字为(26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。

    解答:为了减少冲突,通常令装填因子α由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为(0,10,2,12,5,2,3,12,6,12)。

    前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入T[0],T[10),T[2],T[12]和T[5]中。

    当插入第6个关键字15时,其散列地址2(即h(15)=15%13=2)已被关键字41(15和41互为同义词)占用。故探查h1=(2+1)%13=3,此地址开放,所以将15放入T[3]中。

    当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。

    当插入第8个关键字12时,散列地址12已被同义词38占用,故探查hl=(12+1)%13=0,而T[0]亦被26占用,再探查h2=(12+2)%13=1,此地址开放,可将12插入其中。

    类似地,第9个关键字06直接插入T[6]中;而最后一个关键字51插人时,因探查的地址12,0,1,…,6均非空,故51插入T[7]中。

    8、如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

    (在介绍HashMap的内部实现机制时提到了两个参数,DEFAULT_INITIAL_CAPACITY和DEFAULT_LOAD_FACTOR,DEFAULT_INITIAL_CAPACITY是table数组的容量,DEFAULT_LOAD_FACTOR则是为了最大程度避免哈希冲突,提高HashMap效率而设置的一个影响因子,将其乘以DEFAULT_INITIAL_CAPACITY就得到了一个阈值threshold,当HashMap的容量达到threshold时就需要进行扩容,这个时候就要进行ReHash操作了,可以看到下面addEntry函数的实现,当size达到threshold时会调用resize函数进行扩容。)

    当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值---即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置

    9、重新调整HashMap大小存在什么问题吗?

    当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。(多线程的环境下不使用HashMap)为什么多线程会导致死循环,它是怎么发生的?HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。

    1.扩容:创建一个新的Entry空数组,长度是原数组的2倍。

    2.ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

    线程不安全的HashMap

    在多线程环境下使用HashMap进行put/get操作可能引起死循环,是因为多线程会导致HashMap的Entry 链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不会为空,就会产生死循环获取Entry。

    HashMap什么时候形成环

    1.7 在多个线程并发扩容时,会在执行transfer()方法转移键值对时,造成链表成环。

    • 在扩容resize方法中调用了transfer()方法,而这种方法实现的机制就是将每一个链表转化到新链表,而且链表中的位置发生反转,而这在多线程情况下是非常easy造成链表回路。从而发生get()死循环

    hashMap是根据key的hashCode来寻找存放位置的,那当key为null时, 怎么存储呢?

     在put方法里头,其实第一行就处理了key=null的情况:

    if (key == null)  
        return putForNullKey(value);  
    //那就看看这个putForNullKey是怎么处理的吧。  
    private V putForNullKey(V value) {  
        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++;  
        addEntry(0, null, value, 0);  
        return null;  
    }  

    如果key为null,则直接从哈希表的第一个位置table[0]对应的链表上查找。前面那个for循环,是在talbe[0]链表中查找key为null的元素,如果找到,则将value重新赋值给这个元素的value,并返回原来的value。如果上面for循环没找到则将这个元素添加到talbe[0]链表的表头。 

    待更新。。。

    HashMap 优化

    减小加载因子

    在构造函数里,设定加载因子是0.5甚至0.25。
    如果你的Map是一个长期存在而不是每次动态生成的,而里面的key又是没法预估的,那可以适当加大初始大小,同时减少加载因子,降低冲突的机率。毕竟如果是长期存在的map,浪费点数组大小不算啥,降低冲突概率,减少比较的次数更重要。

    Key的设计

    对于String型的Key,如果无法保证无冲突而且能用==来对比,那就尽量搞短点,否则一个个字符的equals还是花时间的。

    甚至,对于已知的预定义Key,可以自己试着放一下,看冲不冲突。比如,像”a1”,”a2”,”a3” 这种,hashCode是个小数字递增,绝对是不冲突的

    https://blog.csdn.net/zlfprogram/article/details/77369026

    初始化

    private static HashMap<Integer, String> map = new HashMap<Integer, String>(20.75f);
     

    用HashMap存1w条数据,构造时传10000会触发扩容吗

    https://www.toutiao.com/i6753171498158014989/

    HashMap 的构造方法,threshold 为扩容的阀值,在构造方法中由 tableSizeFor() 方法调整后直接赋值,所以在构造 HashMap 时,如果传递 1000,threshold 调整后的值确实是 1024,但 HashMap 并不直接使用它。

    我们在初始化时,传递进来的 initialCapacity 虽然被赋值给 threshold,但是它实际是 table 的尺寸,并且最终会通过 loadFactor 重新调整 threshold。

    那么回到之前的问题就有答案了,虽然 HashMap 初始容量指定为 1000,但是它只是表示 table 数组为 1000,扩容的重要依据扩容阀值会在 resize() 中调整为 768(1024 * 0.75)。

    例如想要用 HashMap 存放 1k 条数据,应该设置 1000 / 0.75,实际传递进去的值是 1333,然后会被 tableSizeFor() 方法调整到 2048,足够存储数据而不会触发扩容。

    当想用 HashMap 存放 1w 条数据时,依然设置 10000 / 0.75,实际传递进去的值是 13333,会被调整到 16384,和我们直接传递 10000 效果是一样的。

    概括一下: 构造方法传入的数值不是HashMap的真实容量,真实容量是传入值所在的2的次幂区间的上界,如10000,在左开右闭区间2^13~2^14内,那么真实容量取2^14,扩容阈值为2^14*0.75,又如1000,在左开右闭区间2^9~2^10内,真实容量取2^10,阈值2^10*0.75

    常见问题

    • hashmap如何解决hash冲突,为什么hashmap中的链表需要转成红黑树?
    • hashmap什么时候会触发扩容?
    • jdk1.8之前并发操作hashmap时为什么会有死循环的问题?
    • hashmap扩容时每个entry需要再计算一次hash吗?
    • hashmap的数组长度为什么要保证是2的幂?
    • 如何用LinkedHashMap实现LRU?
    • 如何用TreeMap实现一致性hash?
     
     
     
     
  • 相关阅读:
    Linux 命令
    Linux 命令
    Linux 命令
    Linux 命令
    121.Best Time to Buy and Sell Stock---dp
    136.Single Number---异或、位运算
    141.Linked List Cycle---双指针
    Restful接口设计
    socket网络编程
    107.Binary Tree Level Order Traversal II
  • 原文地址:https://www.cnblogs.com/dingpeng9055/p/10849008.html
Copyright © 2011-2022 走看看