这个看似简单,我们只要说出对hashmap的理解,自由发挥就好,但是好的回答自然会让面试官刮目相看。围绕这个点要是答好了会引申出超级多的知识点
在这里个人简单总结下:
回答顺序
通过下面的回答顺序,对于初级开发来说妥妥的够了!
Map的接口结构
将集合的框架体系说出来吧!顺便把collection的框架体系也说出来吧!
双列集合:
单列集合
HashMap的put
顺便说下HashSet的底层就是HashMap!
源码如下
HashSet和HashMap的区别是:HashSet将value值设为空对象(空的Object)
put过程如下:
- HashMap底层维护了Node数组的table。(ransient Node<K,V>[] table)
- 当创建对象时,将加载因子(loadfactor)初始化为0.75
- 当添加 k - v 值时,通过key的哈希值得到在table的索引。然后判断该索引处是否有元素
- 如果没有元素直接添加。如果该索引处有元素,继续判断该元素的key和准备加入的key是否相等,如果相等,直接替换val;如果不相等直接添加(1.7头插,1.8尾插),而且需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容。
- 当我们初始化HashMap后,HashMap的长度为0,当首次put元素的时候才将HashMap的长度初始化为16。 然后阈值是16 * 0.75 = 12,也就是当节点数量超过12就进行扩容,扩容的倍数为2(length长度:16 -> 32 ;threshould阈值:12 -> 24)
- version >=Java8,如果一条链表的元素个数超过TREEIFY THRESHOLD(默认是8),并且
table的大小>=MIN TREEIFY CAPACITY(默认64),就会进行树化(红黑树)
注:hashmap的容量是在第一次put的时候才初始化其容量的,而不是new hashmap的时候指定!
hashmap的hash算法
JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。
为什么要用异或运算符?
举个栗子:
- 如果用与&运算, 1 & 0 = 0; 1& 1 = 1; 0 & 1 = 0; 0 & 0 = 0; (与运算如果都为1才为1)
- 如果用或|运算, 1 | 0 = 1; 1| 1 = 1; 0 | 1 = 1; 0 | 0 = 0; (或运算只要有一个是1就是1)
- 如果用异或^运算,1 ^ 0 = 1; 1^ 1 = 0; 0 ^ 1 = 1; 0 ^ 0 = 0; (异或运算如果两个位不相同就为1)
可见使用异或运算保证了0和1的结果均匀分布,而且保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。
扩容为什么要扩容2倍
查看源码,发现源码中无论是在桶中第一次存放数据,还是在在resize()的时候将元素重新分配,都是使用hash & (n - 1)这个算法来确定元素在桶的位置 (hash与上长度-1)
当HashMap的容量是16时,它的二进制是10000,15的二进制是01111,与hash值得计算结果如下
如果容量不是2的n次幂的情况,当容量为10时,二进制为01010,9的二进制是01001,向里面添加同样的元素,结果为
可以看出,有三个不同的元素进过&运算得出了同样的结果,严重的hash碰撞了
为什么要重写hashcode和equals?
一般的,hashmap中的key建议使用String类型,如果想要使用自定义的对象用作hashmap的key,就要重写hashcode和equals方法
重写hashcode和equals对HashMap的重要性
在hashmap中是通过hashcode决定元素放入哪个索引(桶)中,然后通过equals判断和索引中对应链表中的每个元素是否相同,如果有相同 ->替换,如果不相同 -> 直接放 ( jdk version >8尾插法、<8头插法)
hashcode和equals的原理解读
如果不重写hashcode方法,hash值就是内存分配的地址值;重写hashcode方法,hash值就是根据对象中的成员变量值经过一系列的算法求得的。
如果不重写equals方法,调用equals方法默认走地址值;重写equals方法后调用equals是通过变量值进行比较。
不重写hashcode和equals对HashMap的影响
如果在hashmap中如果只重写了hashcode没重写equals:如果我们添加两个相同内容的User对象放入hashmap,添加完第一个User后,添加第二个User时,hash值和第一个User相同,会走equals到同一个索引中找是否存在相同的元素,此时我们因为没重写equals,比较的是地址值(两个虽然内容相同的对象地址值是不同的),因此第二个相同的User也插入到了同一个索引中。
如果在hashmap中如果只重写了equals没重写hashcode:
这个最好理解,上面说到:两个虽然内容相同的对象地址值是不同的,因为没重写hashcode,hash值是地址值 ===> 两个相同内容的对象地址值不同,hash值也不同 ===> 他们两个被放到了hashmap不同的索引中。
因此,hashcode和equals必须重写,两个缺一不可,如果缺一个,就会在Hashmap中添加出相同的key对象,通过这个key对象会查出多个数据可能会导致系统的bug。同时还会导致HashSet失去了去重功能
数据结构
version < JDK 1.8数组 + 链表
version >= JDK 1.8 数组 + 链表 + 红黑树
**链表转化成红黑树:**在JDK 1.8 之后红黑树是在索引中链表长度>8,且索引数量 > 64 才开始树化,如果链表长度 > 8但是索引数量 < 64,暂时不树化,而是在树化的方法中treeifyBin中进行resize扩容
红黑树转化成链表:
在对应索引的节点数小于6时,从该索引中的红黑树结构转化成链表
为什么链表转化成红黑树阈值为8,红黑树转化成链表阈值为6?
原因就是二者之间的差值可以防止链表和树之间的频繁转换!
如果红黑树转化成链表阈值也设为8,如果索引中对应元素个数大于8就从链表转换成红黑树,小于8则从树结构转换成链表。如果HashMap不停的插入,删除元素,链表个数在8左右徘徊,就会频繁的发生红黑树转链表,链表转红黑树。避免数据结构频繁转换造成性能的浪费!
HashMap为什么使用红黑树
如果用二叉查找树,由于它的不平衡特性,极端情况下的时间复杂度会升级为O(n)
红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最好和最坏情况下均为O(log n)。加快检索速率。
JDK 1.8 之前没有红黑树,为了提高效率采用红黑树,避免黑客使用哈希碰撞攻击hashmap,使得索引中的链表过长,增大查询时间复杂度,消耗系统性能。
数据结构
version < JDK 1.8数组 + 链表
version >= JDK 1.8 数组 + 链表 + 红黑树
注:
JDK 1.8 之前没有红黑树,为了提高效率采用红黑树,避免黑客使用哈希碰撞攻击hashmap,使得索引中的链表过长,增大查询时间复杂度,消耗系统性能。
**链表转化成红黑树:**在JDK 1.8 之后红黑树是在索引中链表长度>8,且索引数量 > 64 才开始树化,如果链表长度 > 8但是索引数量 < 64,暂时不树化,而是在树化的方法中treeifyBin中进行resize扩容
红黑树转化成链表:
在对应索引的节点数小于6时,从该索引中的红黑树结构转化成链表
为什么链表转化成红黑树阈值为8,红黑树转化成链表阈值为6?
原因就是二者之间的差值可以防止链表和树之间的频繁转换!
如果红黑树转化成链表阈值也设为8,如果索引中对应元素个数大于8就从链表转换成红黑树,小于8则从树结构转换成链表。如果HashMap不停的插入,删除元素,链表个数在8左右徘徊,就会频繁的发生红黑树转链表,链表转红黑树。避免数据结构频繁转换造成性能的浪费!
线程安全
Hashmap是线程不安全的,不支持并发写,线程安全的map有HashTable、SynchronizedMap、ConcurrentHashMap
性能上HashTable <= SynchronizedMap < ConcurrentHashMap
现在扯到了ConcurrentHashMap,那就和面试官聊聊ConcurrentHashMap吧! 这不就完美了!
ConcurrentHashMap
ConcurrentHashMap 底层结构是数组 + 链表 + 红黑树
。
JDK1.7和JDK1.8中的ConcurrentHashmap对比
-
JDK1.7中的ConcurrentHashMap
内部主要是一个Segment数组,而数组的每一项又是一个HashEntry数组,元素都存在HashEntry数组里。因为每次锁定的是Segment对象,也就是整个HashEntry数组,所以又叫分段锁。
而且在jdk1.7中最多只能有16个segment,也就是说最大支持16个并发
-
JDK1.8中的ConcurrentHashMap
舍弃了分段锁的实现方式,元素都存在Node数组中,每次锁住的是一个Node对象,而不是某一段数组。
对比JDK1.7,ConcurrentHashMap在jdk1.8中的优化有哪些:
- 锁的粒度更细:JDK1.7中以segment作为锁,而segment下面还有HashEntry数组,JDK1.8中以Node作为锁,所以支持的写的并发度更高
- JDK1.8的链表中引用了红黑树
put操作
ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:
-
根据 key 计算出 hashcode 。
-
判断是否需要进行初始化。即为当前 key 定位出的 Node,如果当前node中的数据为空则利用 CAS 尝试写入,失败则自旋保证成功。
-
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
-
如果都不满足,则利用 synchronized 锁写入数据,锁住的是整个node。
-
如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
总的来说就是当一个node中放入第一个数据的时候使用CAS自旋操作,后续的数据放入都是使用细粒度的synhronized锁
ConcurrentHashMap对比HashTable和Synchornizedmap的优势
同样是线程安全的Map,ConcurrentHashMap比HashTable效率要高。
HashTable和Synchornizedmap在写入的时候是锁住整个Node[ ],读的时候也是加锁的。
而ConcurrentHashMap写的时候采用自旋锁 + 分段锁(第一次添加的时候是自旋锁,后序添加锁定的是一个Node),写的时候锁的粒度更小。ConcurrentHashMap效率提高主要的地方是在读上,读的时候完全不加锁。
get操作
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。
**注:**TreeMap用的是红黑树,查找的时候效率高
没有ConcurrenTreeMap的原因是CAS在如果用在树形结构上会太复杂
因此出了个基于跳表结构实现的并发map:ConcurrentSkipListMap 跳表实现。