带你整理面试中关于HashCodeHashMapConcurrnetHashMapHashTableTreeMapLinkedHashMap的相关知识点

Posted 南淮北安

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你整理面试中关于HashCodeHashMapConcurrnetHashMapHashTableTreeMapLinkedHashMap的相关知识点相关的知识,希望对你有一定的参考价值。

文章目录

关于 Map 的简单使用可参考:一篇文章带你搞定 Java 中 Map 接口的学习

一、HashCode

问题就是在 hashCode 的计算逻辑中,为什么选择 31 作为乘数?

(1)31 是一个奇质数,如果选择偶数会导致乘积运算时数据溢出。
(2)另外在二进制中,2个5次方是32,那么也就是 31 * i == (i << 5) - i。这主要是说乘积运算可以使用位移提升性能,同时目前的JVM虚拟机也会自动支持此类的优化。
(3)通过Hash碰撞概率计算

想计算碰撞很简单,也就是计算那些出现相同哈希值的数量,计算出碰撞总量即可,通过大量实验发现乘数是31的时候,碰撞的概率很低,基本稳定,范围值也更为适合。

(4)对于散列表来说,选择31,Hash 值散列分布也更为合理
(5)也在 int 的取值范围之内( − 2 31 -2^31 231 ~ 2 31 2^31 231-1)

详细内容学习可参考:HashCode

二、HashMap:数组+链表存储数据,线程不安全

HashMap基于键的HashCode值唯一标识一条数据,同时基于键的HashCode值进行数据的存取,因此可以快速地更新和查询数据,但其每次遍历的顺序无法保证相同

HashMap的key和value允许为null,允许最多一个key为null,但可以多个value为null。

HashMap是非线程安全的,即在同一时刻有多个线程同时写HashMap时将可能导致数据的不一致。如果需要满足线程安全的条件,则可以用Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

Map m = Collections.synchronizedMap(new HashMap(...)); 

HashMap是数组+链表+红黑树(jdk1.8增加了红黑树部分)实现的。

1. 扰动函数

在HashMap存放元素时候有这样一段代码来处理哈希值,这是java 8的散列值扰动函数,用于优化散列效果

static final int hash(Object key) 
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。

我们默认初始化的Map大小是16个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。

那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了「随机性」。计算方式如下图;

使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞

2. 初始化容量

散列数组需要一个2的倍数的长度(上面的16),因为只有2的倍数在减1的时候,才会出现0111这样的值。

每次扩容两倍,保证 length 为2的n次方,这样 length-1后的二进制表示中每一位都是1,散列码与length-1操作后等于散列码的每一位,所以只要散列码在length-1的二进制表示的长度上的值不同的话,那么在桶中的位置就不同。如果不是扩容两倍,length-1的二进制表示中就会有0,0与任何数的结果都是0,所以更容易出现散列码不同,却映射到同一个桶中的情况。

那么这里就有一个问题,我们在初始化HashMap的时候,如果传一个17个的值new HashMap<>(17);,它会怎么处理呢?

在HashMap的初始化中,有这样一段方法;

public HashMap(int initialCapacity, float loadFactor) 
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);

  • 阀值threshold,通过方法tableSizeFor进行计算,是根据初始化来计算的。
  • 这个方法也就是要寻找比初始值大的,最小的那个2进制数值。比如传了17,我应该找到的是32。

计算阀值大小的方法;

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;

  • MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最大的Map集合。
  • 乍一看可能有点晕😵怎么都在向右移位1、2、4、8、16,这主要是为了把二进制的各个位置都填上1,当二进制的各个位置都是1以后,就是一个标准的2的倍数减1了,最后把结果加1再返回即可。

3. 负载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子,可以理解成一辆车可承重重量超过某个阀值时,把货放到新的车上

那么在HashMap中,负载因子决定了数据量多少了以后进行扩容。

要选择一个合理的大小下进行扩容,默认值0.75就是说当阀值容量占了3/4时赶紧扩容,减少Hash碰撞

同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

4. 扩容元素拆分

为什么扩容,因为数组长度不足了。那扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不在需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。

对于 jdk1.7当数组元素超过负载因子之后,会扩容

对于jdk1.8,当数组长度小于64时,超过负载因子直接扩容,当数组长度大于64时,超过负载因子之后,会检查该元素的哈希值对应的数组中链表长度是否大于8,如果大于8,则对这个链表进行树化,树化之后如果该红黑树节点大于32则再次进行扩容,如果节点小于8,则转为链表

5. 其他知识

HashMap是基于数组+链表和红黑树实现的,但用于存放key值得的数组桶的长度是固定的,由初始化决定。

HashMap常用的参数如下:

  • capacity:当前数组的容量,默认为16,可以扩容,扩容后数组的大小为当前的两倍,因此该值始终为 2 n 2^n 2n
  • loadFactor:负载因子,默认为0.75。
  • threshold:扩容的阈值,其值等于capacity×loadFactor。

HashMap在查找数据时,根据HashMap的Hash值可以快速定位到数组的具体下标,但是在找到数组下标后需要对链表进行顺序遍历直到找到需要的数据,时间复杂度为O(n)

为了减少链表遍历的开销,Java 8对HashMap进行了优化,将数据结构修改为数组+链表或红黑树。在链表中的元素超过8个以后,HashMap会将链表结构转换为红黑树结构以提高查询效率,因此其时间复杂度为O(log N)。Java 8 HashMap的数据结构如图2-3所示。

HashMap这种散列表的数据结构,最大的性能在于可以O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么jdk1.8之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因为在jdk1.8中把过长的链表也就是8个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为8才进行红黑树转换操作。

(1)链表树化的条件有两点;链表长度大于等于8、桶容量大于64,否则只是扩容,不会树化。
(2)链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。
(3)链表转换成树完成后,在进行红黑树的转换。

设定链表长度大于等于k时,会转化为红黑树,k 的值不能太大,因为链表长度过大会影响插入和查找的效率,k 也不能太小,因为树化也不是一个廉价的操作,所以需要链表长度大于等于K的概率足够小,这样就可以尽量避免树化。

阈值之所以为8是因为在hashCode离散型很好的时候,数据会均匀分布在每个桶中,树化的概率很小。随机hashCode下,离散型可能会变差,导致不均匀的数据分布,通过概率统计分布,8个键值对同时分布在一个桶中的概率最小,所以阈值选择了8

HashMap中的key只能是引用类型,不能是基本数据类型

存储元素采用的是hash表存储数据,每存储一个对象的时候,都会调用其hashCode()方法,算出其hash值,如果相同,则认为是相同的数据,直接不存储,如果hash值不同,则再调用其equals方法进行比较,如果返回true,则认为是相同的对象,不存储,如果返回false,则认为是不同的对象,可以存储到HashMap集合中。
之所以key不能为基本数据类型,则是因为基本数据类型不能调用其hashcode()方法和equals()方法,进行比较,所以HashMap集合的key只能为引用数据类型,不能为基本数据类型,可以使用基本数据类型的包装类,例如Integer Double等。

不能使用数组或者链表作为key,因为将它们put到内存中时,存在的是一个具体的地址,就算下一个数组存储内容相同,也是两个不同的值,没法由key定位

二、ConcurrentHashMap:分段锁实现,线程安全

与HashMap不同,ConcurrentHashMap采用分段锁的思想实现并发操作,因此是线程安全的

ConcurrentHashMap由多个Segment组成(Segment的数量也是锁的并发度),每个Segment均继承自ReentrantLock并单独加锁,所以每次进行加锁操作时锁住的都是一个Segment,这样只要保证每个Segment都是线程安全的,也就实现了全局的线程安全。ConcurrentHashMap的数据结构如图2-4所示。


在ConcurrentHashMap中有个concurrencyLevel参数表示并行级别,默认是16,也就是说ConcurrentHashMap默认由16个Segments组成,在这种情况下最多同时支持16个线程并发执行写操作,只要它们的操作分布在不同的Segment上即可。并行级别concurrencyLevel可以在初始化时设置,一旦初始化就不可更改。ConcurrentHashMap的每个Segment内部的数据结构都和HashMap相同。

Java 8在ConcurrentHashMap中引入了红黑树,具体的数据结构如图2-5所示。

三、HashTable:线程安全

HashTable是遗留类,很多映射的常用功能都与HashMap类似,不同的是它继承自Dictionary类,并且是线程安全的,同一时刻只有一个线程能写HashTable,并发性不如ConcurrentHashMap

Hashtable和HashMap很类似,它也是一个散列表,存储的内容是键值对映射,不同之处在于,Hashtable是继承自Dictionary的,Hashtable中的函数都是同步的,这意味着它也是线程安全的,另外,Hashtable中key和value都不可以为null。

四、TreeMap:基于二叉树数据结构

TreeMap基于二叉树数据结构存储数据(底层是红黑树),同时实现了SortedMap接口以保障元素的顺序存取,默认按键值的升序排序,也可以自定义排序比较器。

TreeMap常用于实现排序的映射列表。在使用TreeMap时其key必须实现Comparable接口或采用自定义的比较器,否则会抛出java.lang.ClassCastException异常。

TreeMap 不允许出现空值,因为key为空就无法比较了

五、LinkedHashMap:基于HashTable数据结构,使用链表保存插入顺序

LinkedHashMap为HashMap的子类,其内部使用链表保存元素的插入顺序,在通过Iterator遍历LinkedHashMap时,会按照元素的插入顺序访问元素。

LinkedHashMap继承自HashMap,它主要是用链表实现来扩展HashMap类,HashMap中条目是没有顺序的,但是在LinkedHashMap中元素既可以按照它们插入的顺序排序,也可以按它们最后一次被访问的顺序排序。


在实际使用中,如果更新时不需要保持元素的顺序,就使用HashMap,如果需要保持元素的插入顺序或者访问顺序,就使用LinkedHashMap,如果需要使图按照键值排序,就使用TreeMap。

六、map 和 collection 的区别

map 和 collection的区别:

  • map存储的是键值对形式的元素,键唯一,值可以重复

  • collection存储的是单列元素,子接口set元素唯一,子接口list元素可以重复

  • map集合的数据结构值针对键有效,跟值无关,collection集合的数据结构针对的是元素有效

七、HashMap 的线程不安全问题

jdk7 以前扩容时用的是头插法,扩容会造成数据丢失和死循环,因为现在最低也都是JDK8了,所以就没怎么分析 jdk 7 HashMap 出现的线程不安全问题。

JDK 8对HashMap做了优化,发生 hash 碰撞时,不用头插法,采用的是尾插法,所以不会出现环形链表的情况,但是线程不安全的问题

比如:线程A和线程B都在进行 put 操作,并且 hash 函数计算的插入下标是相同的,此时线程A进行了hash碰撞之后,时间片耗尽,被挂起,线程B得到了时间B,在该下标出完成了正常的插入操作。然后A获得时间片,由于已经进行了hash碰撞检测,所以会直接插入,这就导致了线程B插入的数据被线程A覆盖了。

八、解决hash冲突的方式

(1)开放定址法:一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到

(2)再哈希法:再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,….,等哈希函数
计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。

(3)链地址法:链地址法的基本思想是:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向
链表连接起来

(4) 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表

九、put 方法和 get 方法的流程

HashMap 的 put 方法:首先对key进行hash计算,得到桶的位置,判断位置是否为空,为空直接放入,不为空发生冲突,然后判断两个key的equals方法是否相等,相等覆盖原来的值,否则出现hash冲突,然后 判断当前的节点是否是树节点,如果是加个分支,如果不是则是链表加入

时间复杂度是O(1)

HashMap 的 get 方法:会通过key的hash值获取table中的Node,并通过key的equals方法来确定要取的Node,在返回Node的value值。

时间 复杂度最好是O(1),最坏是O(n)

十、哪种方式遍历最快?

通过HashMap的存储结构可以发现当我们遍历HashMap时通过entrySet方法性能会高一点,因为它直接返回了存储的Map.Entry类,而遍历key方式是通过遍历Map.Entry取出key,我们在调用get(key)方法时又会去取一次Entry,所以性能会比较低

十一、containsKey 方法

首先hash计算,找到对应的桶,然后通过equals判断key,时间复杂度最好O(1),最坏就是发生碰撞,树化,红黑树的时间log(n)


【参考】

【1】https://www.cnblogs.com/developer_chan/p/10450908.html
【2】https://blog.csdn.net/glpghz/article/details/108302647

九、面试题

  1. 从 HashMap 源码角度解释下它的原理?(华为)
  2. HashMap 的线程不安全问题?(阿里)
  3. hashtable 和 concurrenthashmap 哪个效率高

【1】HashMap 夺命二十一问
【2】HashMap面试总结

以上是关于带你整理面试中关于HashCodeHashMapConcurrnetHashMapHashTableTreeMapLinkedHashMap的相关知识点的主要内容,如果未能解决你的问题,请参考以下文章

带你整理面试过程中关于Java 中多线程的创建方式的最全整理

带你整理面试过程中关于ThreadLocal的相关知识

带你整理面试过程中关于ARP 协议的相关知识点

带你整理面试过程中关于 Mybatis 底层的相关知识

带你整理面试过程中关于锁的相关知识点下

带你整理面试过程中关于多线程中的线程池的相关知识点