zoukankan      html  css  js  c++  java
  • java中HashMap原理?

    参考:https://www.cnblogs.com/yuanblog/p/4441017.html(推荐)

      https://blog.csdn.net/a745233700/article/details/83108880(有hash的数据结构详解)

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

      全网把Map中的hash()分析的最透彻的文章,别无二家

    HashMap原理?

      首先,HashMap 是 Map 的一个实现类,它代表的是一种键值对的数据存储形式。Key 不允许重复出现,Value 随意。

      jdk 8 之前,其内部是由数组+链表来实现的,而 jdk 8 对于链表长度超过 8 的链表将转储为红黑树

      HashMap底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个HashMap的时候,就会初始化一个数组。Entry就是数组中的元素,每个 Map.Entry 其实就是一个key-value对,它持有一个指向下一个元素的引用,这就构成了链表。

    transient Entry[] table;
     
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        final int hash;
        ……

      对于存值和取值,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。

    • 具体的put过程(JDK1.8版):
      1. 对Key求Hash值,然后再计算下标(即决定该Entry的存储位置);
      2. 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个桶中);
      3. 如果碰撞了,通过equals比较这两个 Entry 的 key ,如果返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry的 value,但key不会覆盖。如果返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部;
      4. 如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表;
      5. 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排);
    public V put(K key, V value) {
         // HashMap允许存放null键和null值。
         // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。
         if (key == null)
             return putForNullKey(value);
         // 根据key的keyCode重新计算hash值。
         int hash = hash(key.hashCode());
         // 搜索指定hash值在对应table中的索引。
         int i = indexFor(hash, table.length);
         // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。
         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;
            }
         }
         // 如果i索引处的Entry为null,表明此处还没有Entry。
         modCount++;
         // 将key、value添加到i索引处。
         addEntry(hash, key, value, i);
         return null;
     }
    •  具体get过程:
      • 当调用get()方法,首先计算key的hashcode来找到桶位置(数组中对应位置的某一元素),找到桶位置之后,调用key的equals()方法在对应位置的链表中找到需要的元素

          

    归纳起来简单地说:

      HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry

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

       在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。   前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表,这样就大大优化了查询的效率。

      JDK1.8的源码是怎么做的:

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

        

    1. 高16bt不变,低16bit和高16bit做了一个异或;
    2. (n·1)&hash=->得到下标。

      HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到他应该保存在数组的哪个下标中。而这个通过键值求取下标的操作就叫做哈希。

      HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。

      求哈希简单的做法是先求取出键值的hashcode,然后在将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作

    取模:(n·1)&hash位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:

    X % 2^k = X & (2^k - 1),2^k表示2的k次方,也就是说,一个数对2^k取模 == 一个数和(2^k - 1)做按位与运算

    所以,(n·1)&hash只要保证n的长度是2^k的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。

    HashMap是如何解决key冲突的(碰撞处理)?

      拉链法。

      当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key的 hashCode() 返回值决定该 Entry 的存储位置(也就是key在Entry数组的索引)。

      如果两个元素有相同的hashcode,它们会被放在同一个索引上,此时存储方式是以链表的形式来存储,将新添加的 Entry 将与集合中原有 Entry 形成 Entry 链。

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

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

     说说你对红黑树的见解?

     

    1. 每个节点非红即黑
    2. 根节点总是黑色的
    3. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
    4. 每个叶子节点都是黑色的空节点(NIL节点)
    5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

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

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

    HashMap的扩容

           当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作。而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

    1. 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
    2. ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

           那么hashmap什么时候进行扩容呢?

      当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍(会创建一个是数组2倍的新的空数组),然后重新计算每个元素在数组中的位置而这是一个非常消耗性能的操作

      如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

     HashMap、HashTable、ConcurrentHashMap

    (1)线程不安全的HashMap

      在多线程环境下,使用HashMap进行put操作会引起死循环(因为多线程会导致HashMap的Entry链表形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。),导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

    (2)效率低下的HashTable

      HashTable容器使用sychronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。

    (3)ConcurrentHashMap的锁分段技术可有效提升并发访问率

      HashTable容器在竞争激烈的并发环境下表现效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁。假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段的数据也能被其他线程访问

      在JDK8中彻底抛弃了JDK7的分段锁的机制,新的版本主要使用了Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发

      ConcurrentHashMap的JDK8与JDK7版本的并发实现相比,最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数,在JDK7里面最大并发个数就是Segment的个数,默认值是16,可以通过构造函数改变一经创建不可更改,这个值就是并发的粒度,每一个segment下面管理一个table数组,加锁的时候其实锁住的是整个segment,这样设计的好处在于数组的扩容是不会影响其他的segment的,简化了并发设计,不足之处在于并发的粒度稍粗

      所以在JDK8里面,去掉了分段锁,将锁的级别控制在了更细粒度的table元素级别,也就是说只需要锁住这个链表的head节点,并不会影响其他的table元素的读写,好处在于并发的粒度更细,影响更小,从而并发效率更好,但不足之处在于并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点,好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类。在一个线程发起扩容的时候,就会改变sizeCtl这个值。扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd,否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后 给旧table原位置赋值fwd。直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。

    在扩容时读写操作如何进行:
    (1)对于get读操作,如果当前节点有数据,还没迁移完成,此时不影响读,能够正常进行。如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时get线程会帮助扩容。

    (2)对于put/remove写操作,如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时写线程会帮助扩容,如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。

    (问题:如何在不影响读写的情况下进行扩容? 看上面)

    理解Java7和8里面HashMap+ConcurrentHashMap的扩容策略

    ConcurrentHashMap的结构:

      ConcurrentHashMap由Segment数组结构HashEntry数组结构组成。Segment是可重入锁,扮演锁的角色;HashEntry存储键值对数据。 
      一个ConcurrentHashMap里包含一个Segment数组,Segment的结构与HashMap类似,是一种数组和链表结构。一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护一个HashEntry数组里的元素。当对HashEntry数组的数据进行修改的时候,必须首先获得与它对应的Segment锁

    ConcurrentHashMap的操作:

      get操作get过程不需要加锁,只有值为空值的时候才加锁重读。(如何做到不加锁的?get方法里将要使用的共享变量都定义为volatile类型。)

      put操作put过程必须加锁(由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁)。put方法首先定位到Segment,然后在segment里进行插入操作

      插入操作步骤:第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放到HashEntry数组里。

      • 是否需要扩容? 在插入元素前先判断Segment里的HashEntry数组是否超过容量(threadshold),如果超过阈值,则对数组进行扩容。
      • 如何扩容? 在扩容时,首先会创建一个容量为原来容量2倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

    解决hash冲突的几种方法?

    1.开放地址法

      当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。

      一个通用的再散列函数形式:Hi=(H(key) + d% m ,i=1,2,…,n

      其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:

    • 线性探测再散列
      • dii=1,2,3,…,m-1, 这种方法的特点是:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。
    • 二次探测再散列
      • di=12-1222-22…,k2-k2    ( k<=m/2 )   这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。
    • 伪随机探测再散列
      • di=伪随机数序列。

    2.拉链法

      将所有哈希地址相同的元素都链接在同一链表中。

      链地址法适用于经常进行插入和删除的情况。

    3.再哈希法

      这种方法是同时构造多个不同的哈希函数:Hi=RH1(key)  i=1,2,…,k

      当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

    4.建立公共溢出区

      这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

    优缺点:

    开放散列(open hashing)/ 拉链法(针对桶链结构):

    1)优点: ①对于记录总数频繁可变的情况,处理的比较好(也就是避免了动态调整的开销) ②由于记录存储在结点中,而结点是动态分配,不会造成内存的浪费,所以尤其适合那种记录本身尺寸(size)很大的情况,因为此时指针的开销可以忽略不计了 ③删除记录时,比较方便,直接通过指针操作即可
    2)缺点: ①存储的记录是随机分布在内存中的,这样在查询记录时,相比结构紧凑的数据类型(比如数组),哈希表的跳转访问会带来额外的时间开销 ②如果所有的 key-value 对是可以提前预知,并之后不会发生变化时(即不允许插入和删除),可以人为创建一个不会产生冲突的完美哈希函数(perfect hash function),此时封闭散列的性能将远高于开放散列 ③由于使用指针,记录不容易进行序列化(serialize)操作

    封闭散列(closed hashing)/ 开放定址法:

    1)优点: ①记录更容易进行序列化(serialize)操作 ②如果记录总数可以预知,可以创建完美哈希函数,此时处理数据的效率是非常高的
    2)缺点: ①存储记录的数目不能超过桶数组的长度,如果超过就需要扩容,而扩容会导致某次操作的时间成本飙升,这在实时或者交互式应用中可能会是一个严重的缺陷 ②使用探测序列,有可能其计算的时间成本过高,导致哈希表的处理性能降低 ③由于记录是存放在桶数组中的,而桶数组必然存在空槽,所以当记录本身尺寸(size)很大并且记录总数规模很大时,空槽占用的空间会导致明显的内存浪费 ④删除记录时,比较麻烦。比如需要删除记录a,记录b是在a之后插入桶数组的,但是和记录a有冲突,是通过探测序列再次跳转找到的地址,所以如果直接删除a,a的位置变为空槽,而空槽是查询记录失败的终止条件,这样会导致记录b在a的位置重新插入数据前不可见,所以不能直接删除a,而是设置删除标记。这就需要额外的空间和操作。

    https://www.cnblogs.com/wuchaodzxx/p/7396599.html

  • 相关阅读:
    JVM中java类的加载时机(转载:http://blog.csdn.net/chenleixing/article/details/47099725)
    自定义filter包
    Tomcat中Listener的使用范例(转载http://cywhoyi.iteye.com/blog/2075848)
    Quartz简单使用
    PAT A1119 Pre- and Post-order Traversals [前序后序求中序]
    PAT A1115 Counting Nodes in a BST [二叉搜索树]
    PAT A1110 Complete Binary Tree [完全二叉树]
    PAT A1102 Invert a Binary Tree [反转二叉树]
    PAT A1099 Build A Binary Search Tree [二叉搜索树]
    PAT A1094 The Largest Generation [树的遍历]
  • 原文地址:https://www.cnblogs.com/toria/p/HashMap.html
Copyright © 2011-2022 走看看