由浅入深了解HashMap
HashMap概述
HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的Entry( key、value) 都可以为 null,但是最多只允许一条Entry的键为Null(多条会覆盖),允许多条Entry的值为Null,此外,HashMap 中的映射不是有序的。
先介绍一下HashMap体系中提到的三个基本存储概念:
名称 | 说明 |
---|---|
table | 存储所有节点数据的数组 |
slot | 哈希槽。即table[i]这个位置 |
bucket | 哈希桶。table[i]上所有元素形成的表或树的集合 |
HashMap实现原理
HashMap基于哈希算法实现,通过put()和get()方法储存和获取对象。
当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标。如果出现hash值相同的key,则调用equals方法进一步比较key,有两种情况:
- 如果key相同,那么覆盖原始值
- 如果key不同,则出现了哈希冲突,那么将当前key-value放入到链表中
获取时,我们直接找hash值对应的下标,进一步判断key是否相同,进而找到对应的值。
HashMap底层数据结构
相信大家已经有过了解了:
JDK1.8之前,HashMap是由数组+链表组成,数组是HashMap的主体,而链表主要是为了解决哈希冲突而存在的。
在此我们简单介绍一下数组和链表的特点:
数组特点
- 存储区间是连续,且占用内存严重,空间复杂也很大,时间复杂为O(1)。
- 优点:是随机读取效率很高,原因数组是连续(随机访问性强,查找速度快)。
- 缺点:插入和删除数据效率低,因插入数据,这个位置后面的数据在内存中要往后移的,且大小固定不易动态扩展。
链表特点
- 区间离散,占用内存宽松,空间复杂度小,时间复杂度O(N)。
- 优点:插入删除速度快,内存利用率高,没有大小固定,扩展灵活。
- 缺点:不能随机查找,每次都是从第一个开始遍历(查询效率低)。
HashMap解决哈希冲突的方式,就是通过数组和链表各自的特点,两者相结合
而JDK1.8之后HashMap是由数组+链表/红黑树组成,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大达到64 时,此时此索引位置上的所有数据改为使用红黑树存储
如图所示:
HashMap存储的过程
HashMap<String, Integer> map = new HashMap<>();
map.put("玛卡巴卡",3 );
map.put("依古比古",4 );
map.put("唔西迪西",5 );
map.put("玛卡巴卡",2 );
输出结果
{依古比古=4, 玛卡巴卡=2, 唔西迪西=5}
可以看到,最终存入的key(玛卡巴卡)对应的value是后来put进去的2,而不是最开始的3,这就体现了HashMap中存储数据的过程:
过程:
当我们向HashMap中put元素时,利用key的hashCode再根据hash计算,算出当前元素在数组中的下标。在存储时,如果hash计算后的索引位置为空,就直接存储;可能会遇到hash计算后索引相同的key,那么这时有两种解决办法:
- 如果key相同,则覆盖原始值;
- 如果key不同,则划出一个结点存储数据,如果结点长度即链表长度大于阈值 8 并且数组长度达到64 则将链表变为红黑树。
很明显,本例中的出现了key相同的“玛卡巴卡”,按照HashMap的存储原理,其对应的新的value要把旧的value覆盖掉。
那么计算下标的具体流程是什么呢?
- 调用put()方法往HashMap中添加键值对
- 调用key.hashCode()方法计算出当前key的hash值(int类型32位)
- 调用hash()算法让hash值的高16位和低16位进行异或运算,目的是减少碰撞(异或运算规则:0^0=0; 0^1=1; 1^0=1; 1^1=0)
- 将第三步算出的hash值与table.length-1进行与(&)运算(与运算规则:0&0=0;0&1=0;1&0=0;1&1=1)
- 最终得到的下标一定是小于数组长度的
也就是说:
当你对HashMap中put 键值对时,会算出key的hashCode,然后经过一次扰动,再算出一个hash值,然后用这个hash值去跟(table.length-1)做与运算,运算出来的就是数组下标
另外,在不断存储数据的过程中,会涉及到扩容问题。
在JDK1.8中,resize方法是在HashMap中的键值对大于阈值时或者初始化时,就调用resize方法进行扩容。值得注意的是:
每次扩展的时候都是扩展2倍,并将原有的数据复制过来
扩展后对象的位置要么在原位置,要么移动到原偏移量两倍的位置
区分十分眼熟却容易混淆的HashMap相关概念值
名称 | 说明 |
---|---|
length | table数组的长度 |
size | 成功通过put方法添加到HashMap中的所有元素的个数 |
hashCode | Object.hashCode()返回的int值,尽可能地离散均匀分布 |
hash | Object.hashCode()与当前集合的table.length进行位运算的结果,以确定哈希槽的位置 |
理想的哈希集合对象的存放应该符合:
- 只要对象不一样,hashCode就不一样
- 只要hashCode不一样,得到的位运算后的hash就不一样
- 只要hash不一样,存放在数组上的slot就不一样
HashMap的扩容
(参考CyC2018——CS-Notes)
设HashMap的table长度为M,需要存储的键值对数量为N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为N/M,因此查找的复杂度为O(N/M)。
为了让查找的成本降低,应该使N/M尽可能小,因此需要保证M尽可能大,也就是说table要尽可能大。HashMap采用动态扩容来根据当前的N值来调整M值,使得空间效率和时间效率都能得到保证。
和扩容相关的参数主要有:capacity、size、threshold和loadFactor
参数 | 含义 |
---|---|
capacity | table容量的大小,默认为16.需要注意的是capacity必须保证为2的n次方 |
size | 键值对数量 |
threshold | size的临界值,当size大于等于threshold就必须进行扩容操作 |
loadFacter | 装载因子,table能够使用的比例,threshold = (int)(capacity*loadFactor) |
当需要扩容时,令capacity为原来的两倍。
扩容使用resize()实现,需要注意的是,扩容操作同样需要把旧的table数组所有的键值对重新插入到新的table数组中,这一步是很费时的。
而把原table的Node放到新的table中需要用到transfer()方法
重新计算数组的下标
在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上
★扩容带来的影响
在JDK1.7中,HashMap线程不安全主要体现在死循环和数据丢失。插入元素是按照头插法。
采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。
比如:原链表中数据是1→2→3,那么用头插法转移到新的链表中的顺序是3→2→1,发生了顺序反转
在JDK1.8中不再用头插法,改为了尾插法,在扩容时会保持链表原本的顺序,但仍会出现数据覆盖的问题。
几种排序方式
- 用value排序:将entrySet转为List,然后用List.sort或者Collections.sort
- 用key排序:得到键的集合keySet,转换为数组,用Arrays.sort
LinkedHashMap
继承自HashMap,底层与HashMap类似,具有和HashMap一样的快速查找特性,不过在此基础上添加了一条双向链表(本质上,LinkedHashMap = HashMap + 双向链表),用来维护插入顺序或者访问顺序,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题
LinkedHashMap 在不对HashMap做任何改变的基础上,给HashMap的任意两个节点间加了两条连线(before指针和after指针),使这些节点形成一个双向链表。
LinkedHashMap中最多允许有一个键为null(多的会覆盖),允许多个值为null
LinkedHashMap的排序
插入顺序:按照数据插入的顺序进行排序
访问顺序(也称最近最少使用顺序(LRU顺序)):指定了按元素读取顺序输出时会按照读取顺序将读取的元素添加到链表的末尾
Hashtable
与新的集合实现不同,Hashtable是同步的。如果不需要线程安全实现,建议使用HashMap代替Hashtable。如果需要线程安全的高并发实现,那么建议使用ConcurrentHashMap代替Hashtable。
事实上,Hashtable已经不建议使用了,因为他效率很低,一个线程访问他的同步方法时,其他线程访问这个同步方法就会进入阻塞或者轮询的状态。所有访问它的线程都必须竞争同一把锁。
Hashtable 利于用键值快速查找,却没有提供排序的方法,所以它的排序需要借助数组或其它集合来实现。
★HashMap与Hashtable的区别
- null值存入:HashMap允许存null,Hashtable不允许存null,会抛出空指针异常
- 线程安全:HashMap线程是不安全的,Hashtable是通过使用了 synchronized 关键字来保证其线程安全
- 扩容:HashMap默认初始容量是16,负载因子为0.75,新数组的容量是旧数组的2倍;Hashtable默认初始容量是11,负载因子为0.75,新数组的容量是旧数组的2倍+1。
HashMap需要重新计算- 底层结构:都是数组+链表
- 添加key-value的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法,而HashTable是直接采用key的hashCode()
★Hashtable为什么不能存null?
TreeMap
基于红黑树(自平衡的二叉查找树,是一种有序的树形结构,优势在于查找),继承于abstractmap
TreeMap可排序,根据创建映射时提供的comparator进行排序,可以把它保存的记录根据键排序,它默认是按键的升序排序(由小到大),可以指定排序的比较器,当用迭代器(Iterator)遍历TreeMap时,得到的记录是排序过的。
TreeMap插入和删除的效率远没有HashMap和ConcurrectHashMap高,但是在key有排序要求的场景下,使用TreeMap可以事半功倍。
正常情况下TreeMap不能存null键,但是可以自定义一个比较器存入null,但是不能正常访问,只能在迭代遍历的时候看见
注意:HashMap是使用hashCode和equals实现去重的。而TreeMap依靠Comparable或Comparator来实现对key的去重。
ConcurrentHashMap
ConcurrentHashMap和HashMap实现上类似,最主要的差别是ConcurrentHashMap采用了分段锁(Segment),每个分段锁维护着几个桶,多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是Segment的个数)
ConcurrentHashMap默认的并发级别是16,也就是说默认创建16个Segment
- JDK1.7使用分段锁机制来实现并发更新操作,核心类为Segment,并发度与Segment数量相等。
- JDK1.8摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。
并且JDK1.8的实现也在链表过长时会转换为红黑树
ConcurrentHashMap的分段锁技术
Hashtable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问Hashtable的线程都必须竞争同一把锁,但如果容器中有多把锁,每一把锁锁住容器中的一部分数据,那么当多线程访问容器里不同的数据段的数据时,线程间就不会存在锁竞争,从而可以提高并发访问效率。
这就是ConcurrentHashMap使用的分段锁技术:就是将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
CAS(Compare And Swap)
是解决轻微冲突的多线程并发场景下使用锁造成性能损耗的一种机制。
CAS就是先比较,如果不符合预期,则进行重试。CAS操作包含三个操作要素:内存位置、预期原值和新值。如果内存位置的值与预期原值相等,则处理器将该位置值更新为新值。如果不相等,则获取当前值,然后进行不断的轮询操作,直到成功或达到某个阈值退出。
你可能会遇到的面试题(后续更新)
★为什么JDK1.8要变成数组+链表/红黑树?
为了提升在 hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)
当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64 时,此时此索引位置上的所有数据改为使用红黑树存储。
将链表转换成红黑树前会判断,即便阈值大于 8,但是数组长度小于 64,此时并不会将链表变为红黑树,而是选择逬行数组扩容。
★为什么HashMap转红黑树条件要加上数组长度达到64?
当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。因为高碰撞率是因为桶数组容量较小引起的,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,最好优先扩容。
一旦转换为红黑树,那么时间复杂度由O(n)变为O(log n )
★为什么HashMap中要用红黑树不用二叉查找树?
为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了),遍历查找会非常慢。
引入红黑树就是为了查找数据快,红黑树在插入新数据后可能需要通过左旋,右旋,变色这些操作来保持平衡。
★HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
因为hashCode()方法返回的是int整数类型,大约有40亿个映射空间,但是HashMap初始容量一般默认是16~2^30,HashMap通常情况下取不到最大值,设备上也很难提供这么大的存储空间,从而导致hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置
★为什么重写equals后还必须重写hashCode?
Object.hashCode()的实现是默认为每一个对象生成不同的int数值,它本身是native方法,一般与对象的内存地址有关。如果没有复写hashCode方法,那么两个key的hashCode无论如何对不会相同
★什么是哈希冲突(哈希碰撞)?如何解决哈希冲突?
哈希冲突:
对应不同的关键字可能获得相同的hash地址,即 key1≠key2,但是f(key1)=f(key2)。这种现象就是冲突,而且这种冲突只能尽可能的减少,不能完全避免。因为哈希函数是从关键字集合和地址集合的映像,通常关键字集合比较大,而地址集合的元素仅为哈希表中的地址值。
解决哈希冲突主要方法:
- 拉链法
- 开放定址法
★HashMap为什么线程不安全?
在HashMap进行扩容重哈希时导致Entry链形成环。一旦Entry链中有环,会导致在同一个桶中进行插入、查询、删除等操作时陷入死循环。
高并发下,HashMap新增数据也会出现数据丢失和数据覆盖。
觉得此篇文章帮助到你的朋友,可以点赞、关注、收藏哦~
相关推荐:
《普歌-码灵团队——Java进阶之List知识详解(上)List概述&ArrayList&LinkedList详解》
《普歌-码灵团队——Java进阶之Set集合详解,概述、常用子类、底层原理》