我们都知道,要查找一个元素,如果是顺序查找,那么时间复杂度会达到O(n) ,比如:顺序表。也有可能达到 O(logn),如:平衡树。那么要想更快,我们就可以考虑另外一种数据结构,它可以达到时间复杂度为 O(1)。如:HashMap。
什么是HashMap?
HashMap 是一个 key-value模型,具有映射关系,通过 key值可以找到 value值。并允许使用 null值和 null键,HashMap 不保证映射的顺序。
一.HashMap基本知识
1.哈希冲突的定义
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址后,然后要进行插入的时候,发现该位置已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。由此可见哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(当发生冲突时,继续寻找下一块未被占用的存储地址,然后再放入元素即可),再散列函数法,链地址法,而 HashMap 即是采用了链地址法,也就是数组+链表的方式。
2.hashmap的实现原理
HashMap 的主干是一个 Entry 数组。Entry 是 HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。
HashMap的总体结构如下:
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
3.HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值:initialCapacity默认为16,loadFactory默认为0.75
二.具体问题如下
1.HashMap的内部数据结构
数组 + 链表/红黑树
2.HashMap允许空键空值么
HashMap最多只允许一个键为Null(多条会覆盖),但允许多个值为Null
3.影响HashMap性能的重要参数
- 初始容量:创建哈希表时桶的数量(数组的大小),默认为 16
- 负载因子:哈希表在其容量扩容之前可以达到一种尺度,默认为 0.75
4.HashMap的工作原理
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象
5.HashMap中put()的工作原理
6.HashMap 的底层数组长度为何总是2的n次方
- 使数据分布均匀,减少碰撞
- 当length为2的n次方时,hash&(length - 1) 就相当于对length取模,而且在速度效率上比直接取模要快得多
具体解释:
这里我觉得可以用逆向思维来解释这个问题,我们计算桶的位置完全可以使用h % length,如果这个length是随便设定值的话当然也可以,但是如果你对它进行研究,设计一个合理的值得话,那么将对HashMap的性能发生翻天覆地的变化。
没错,JDK源码作者就发现了,那就是当length为2的N次方的时候,那么,为什么这么说呢?
第一:当length为2的N次方的时候,h & (length-1) = h % length
为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高
第二:当length为2的N次方的时候,数据分布均匀,减少冲突
此时我们基于第一个原因进行分析,此时hash策略为h & (length-1)。
我们来举例当length为奇数、偶数时的情况:
从上面的图表中我们可以看到,当 length 为15时总共发生了8次碰撞,同时发现空间浪费非常大,因为在 1、3、5、7、9、11、13、15 这八处没有存放数据。
这是因为hash值在与14(即 1110)进行&运算时,得到的结果最后一位永远都是0,那么最后一位为1的位置即 0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的。这样,空间的减少会导致碰撞几率的进一步增加,从而就会导致查询速度慢。
而当length为16时,length – 1 = 15, 即 1111,那么,在进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以,当 length=2^n 时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。
如果上面这句话大家还看不明白的话,可以多试一些数,就可以发现规律。当length为奇数时,length-1为偶数,而偶数二进制的最后一位永远为0,那么与其进行 & 运算,得到的二进制数最后一位永远为0,那么结果一定是偶数,那么就会导致下标为奇数的桶永远不会放置数据,这就不符合我们均匀放置,减少冲突的要求了。
那么为什么默认是16呢?怎么不是4?不是8?
关于这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。所以,16就作为一个经验值被采用了。
7. 1.8中做了哪些优化优化?
- 数组+链表 改成了 数组+链表或红黑树
- 链表的插入方式从头插法改成了尾插法
- 扩容的时候1.7需要对原数组中的元素进行重新hash,定位在新数组中的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
8.HashMap线程安全方面会出现什么问题
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
- 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况
9.HashMap线程安全方面会出现什么问题
(1)put的时候导致的多线程数据不一致,产生错误情况
比如有两个线程 A和 B,首先 A希望插入一个 key-valul 对到 HashMap 中,首先计算记录所要落到的 hash 桶的索引坐标,然后获取到该桶里面的链表头结点,而此时刚好线程 A 的时间片用完了,此时线程 B 被调度得以执行,和线程 A一样的执行过程 ,只不过线程 B 成功的将记录插到了桶里面(假设线程 A 插入的记录计算出来的 hash 桶索引和线程 B 要插入的记录计算出来的 hash 桶索引是一样的)那么当线程 B 成功插入之后, 线程 A 再次被调度运行时,它依然持有过期的链表头但是它对此一无所知(头插法),以至于它认为它应该继续采用头插法插入数据(它拿到的头节点刚好是刚刚线程B插入的节点的下一个节点),如此一来就覆盖了线程 B 插入的记录,这样线程 B 插入的记录就凭空消失了,造成了数据不一致的行为。
(2)resize引起死循环
这种情况发生在 HashMap 自动扩容时,当 2 个线程同时检测到元素个数超过 数组大小 ×负载因子 的时候。此时 2 个线程会在 put() 方法中调用resize() ,那么此时若两个线程同时修改一个链表结构会产生一个循环链表,接下来再想通过get()获取某一个元素,就会出现死循环。
10.为什么1.8改用红黑树
比如某些人恶意找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同 index 位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能十分不好。java8 使用红黑树来替代超过 8个节点数 的链表后,查询方式性能得到了很好的提升,从原来的是 O(n) 到 O(logn)。
原文来自:https://potato.blog.youkuaiyun.com/article/details/106835525 感觉总结的十分好,学到了不少东西。
11.负载因子
HashMap 的默认负载因子为 0.75,如果超过 0.75,会重新 resize一个原来长度两倍的 HashMap,并且重新调用 hash方法 ,对原来的数据进行重新 hash。
负载因子=存储的元素个数 / 数组的长度
12. HashMap中的equals()和hashCode()的都有什么作用?
通过 key.hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets 的位置。如果产生碰撞,则利用 key.equals()方法去链表或树中去查找对应的节点 ,另外也是为了保持数据一致性。
13. HashMap和HashTable的区别有哪些?
- Hashtable 是线程安全的,HashMap 是非线程安全的。
- HashMap 可以使用 null作为 key,不过建议还是尽量避免这样使用。-HashMap 以 null作为 key时,总是存储在 table数组的第一个节点上。而Hashtable 则不允许 null作为 key
- HashMap的初始容量为 16,Hashtable初始容量为 11
- Hashtable 计算 hash是直接使用 key的 hashcode对 table数组的长度直接进行取模,HashMap 计算 hash对 key的 hashcode进行了二次 hash,以获得更好的散列值,然后对table数组长度取模。
14. 使用场景
非并发场景使用 HashMap,并发场景可以使用 Hashtable,但是推荐使用 ConcurrentHashMap(锁粒度更低、效率更高)。
另外在使用 HashMap时要注意 null值的判断,Hashtable 也要注意防止 put null key和 null value。