HashMap 面试盘点
潇潇基本上每去一家公司都会被问到HashMap,该数据结构涉及的技能和知识点~~~~~~
比如说:
- HashMap 原理
- HashMap put原理
- HashMap 怎么设置初始化容量大小
- HashMap hash函数怎么设计? 与1.7hash函数有何区别
- HashMap 1.8还做了那些优化
- HashMap 的线程安全吗
有些事情,往往不能剥开洋葱的💗看本质,可能是每剥开"洋葱"的一层都有点辣眼睛了吧,代码也是一样 ,过程当然也相当痛苦,但是每一个事情结束之后,往往是一种解脱和升华把~~~~
HashMap原理
OK 请看下列图~~~~~~~
1.8 HashMap得实现原理 红黑树+链表/数组
1.7 HashMap得实现原理 链表+数组
嘿嘿 细心的同学会发现都是基于数组,为什么jdk1.8采用的对象数组是Node 而JDK1.7采用的对象是Entry,他们有何区别呢????
在JDK1.7中Entry用key的hashCode多次异或与余来决定key会被放到数组里的位置,如果hashCode相同及取余相同,放到Entry同一格子里,然后形成一个链表,如果hashcode都相同,链表长度会增加,进行put/get 空间复杂会增加O(n),
int hash = (key == null) ? 0 : hash(key); //计算hash值
// 获取index 下标
static int indexFor(int h, int length) {
return h & (length-1);
}
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode(); // 异或
h ^= (h >>> 20) ^ (h >>> 12); //高位补零, 与>>计算差不多
return h ^ (h >>> 7) ^ (h >>> 4);
}
在JDK1.8中Node对象不知道存储的对象是链表还是红黑树结构,如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里,如果同一个格子里的key不超过8个和链表长度小于64,使用链表结构存储,否则调用treeifyBin函数,将链表转换为红黑树,就算hashcode相同,红黑树查询特定的元素空间复杂度O(log n) 具体原理请看下文HashMap put的原理。
HashMap put原理!
/**
* The next size value at which to resize (capacity * load factor).
* 下一个要调整大小的大小值(容量*负载系数)
**/
int threshold;
/**
* The load factor for the hash table.
* 负载因子
*/
final float loadFactor;
final int capacity() {
return (table != null) ? table.length :
(threshold > 0) ? threshold :
DEFAULT_INITIAL_CAPACITY;
}
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
简单文字描述下 :
table 数组大小是由 capacity 这个参数确定的,默认是(DEFAULT_INITIAL_CAPACITY )16,
也可以构造时传入,最大限制是1<<30
loadFactor 装载因子,table是否需要动态扩展,假设使用默认构造函数,那么默认加载因子
0.75f,数组大小默认16 ,那么threshold就是12 ,如果table长度大于12则需要进行扩容,
扩容时调用resize() ,table的长度是原来的俩倍,这个俩倍指得不是threshold俩倍
,必须是2次幂(至于hashmap为什么这么设计后续慢慢为你颇析)
嘿嘿 先看潇某人的一张草图,然后再看源码 我想应该会心有所止吧!!!!
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
1 . 判断table是否为空,为空进行初始化 resize
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
2. 计算 (n - 1) & hash index 是否为空,为空进行插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
3 . Index 不为空,计算key是否存在, key存在进行覆盖
e = p ---->if (e != null) existing mapping for key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
4 . 判断是否是红黑树节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
5 . 加入链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
6 . 链表长度大于8 自动转换红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin有段代码如果数组小于64 不进行转换
treeifyBin(tab, hash);
break;
}
7 . 链表中的key是否存在 , 存在覆盖该值,多线程情况下,可能会覆盖该值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key key存在进行覆盖
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
8 . mod 计算++ 添加成功
++modCount;
9 . 是否需要扩容, 注意多线程的情况下,可能会执行多次
if (++size > threshold)
resize();
// 允许LinkedHashMap后处理的回调, 只是一个空的方法
afterNodeInsertion(evict);
return null;
}
HashMap 怎么设置初始化容量大小
看过源码的同学都知道,不知道得也没有关系,SEE SEE 千万可别see you la la 了 咳咳咳
new HashMap(); 那么默认的增长因子为0.75f , 默认初始化大小16 (1<<4) 最大容量不得超过 (1<<30 ) 按照正常得逻辑走threshold(12)如果table>12需要扩容,但是呢,程序得设置总不可能是死得吧,它是可扩展性,有撸壮性得。。 哼 , 呵呵 潇潇就知道没有那么简单,要不然面试官也不会精彩问别人得秘密,毕竟一个成熟有意义得东西,人们往往会记住,去学习。 感觉不自觉得偏离了话题,可能是潇潇生性活泼吧,回归正题
如果自己传入容量大小,程序会有什么样得变化呢?带着疑惑, 还是先偷窥下Code吧
public HashMap(int initialCapacity, float loadFactor) {
//容量大小不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//容量大小小于 1 << 30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 返回自定义容量 2倍幂的大小
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
算法就是让初始二进制分别右移1,2,4,8,16位,与自己位或,把高位第一个为1的数通
过不断右移,然后把右移数字于自己之前做位或,从而保证符合大于cap并且是2的整数次幂
算法有点牛逼 我自己画的图太丑了 0.0. 于是乎借鉴了一张 嗷嗷嗷
HashMap hash函数有何区别? 于1.7有何区别
hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。
为什么要这么设计?
这块函数设计也叫扰动函数,目的是为了让哈希函数映射得比较均匀松散,降低hash之间的碰撞,采用计算机语言COR(异或)进行操作,提高程序运算效率
重点说道说道,它与1.7hash函数有何区别,这个过程可能有点漫长,需要追溯到丛林时代,那个荒古,风雨飘摇的时代。在很久很久以前.............. 流传着这么一个故事~~~~~~
话说key.hashCode() 函数调用的是key键值类型自带的哈希函数,返回int型散列值,32位2的进制范围-2147483648到2147483648 前后范围大约40亿空间,只要hash函数分配松散,很难出现hash碰撞,问题是40亿长度数组,内存肯定是放不下的,HashMap的默认扩容16,所以这个散列不能拿来进行使用,所以1.7中对数组的长度进行取模,得到的余数用来访问下表
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
补充一下,位运算比余运算要快0.0
这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
10100101 11000100 00100101
&
00000000 00000000 00001111----------------------------------
00000000 00000000 00000101 //高位全部归零,只保留末四位
但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。
这时候 hash 函数(“扰动函数”)的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
结果显示,当HashMap数组长度为512的时候(),也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。
另外Java1.8相比1.7做了调整,1.7做了四次右移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。
下面是1.7的hash代码:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
HashMap 1.8还做了那些优化
- 数组+链表改成了数组+链表或红黑树;
- 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
- 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容
- hash函数使用扰动函数,提高计算效率(以描述)
为什么要做这么优化呢?
- 减少发生hash冲突,链表长度过长使用红黑树将时间复杂度由O(n)降为O(logn)提交程序效率
- 1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环
补充:A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,形成了循环引用
3.这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,举个例子:
扩容前长度为16,用于计算 (n-1) & hash 的二进制n - 1为0000 1111,
扩容后为32后的二进制就高位多了1,============>为0001 1111。
因为是& (与或)运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;
第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
HashMap 的线程安全吗
在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题
具体可看put源码分析
后续有时慢慢整理HashSet(内部实现了HashMap) TreeMap LindedHashMap(基于HashMap) ,后续再整理JUC 线程池 ,CAS 相关
后续打算编写工作中使用的Es ,Redsi ,netty,webSocket,代码集群相关代码及注意细节。
参考:
JDK 源码中 HashMap 的 hash 方法原理是什么?
一个HashMap跟面试官扯了半个小时?(安琪拉,以前上海一个领导写得公众号)