一. HashMap 的数据结构组成
HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体。只是在JDK1.8中,链表长度大于8的时候,链表会转成红黑树!其中,数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到.链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。
常见的 hash 冲突处理方法:
- 开放定址法
- 链地址法
- 再哈希法
- 公共溢出区域法
二. HashMap 的扩容机制
HashMap 的初始容量为 16。我们在创建HashMap的时候,阿里巴巴规范插件会提醒我们最好赋初值,而且最好是2的幂。这样是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。 如果 bucket 满了(超过LoadFactor * (Capacity)),就要重新扩容。LoadFacto为0.75,为了最大程度避免哈希冲突,Capacity为当前数组大小。扩容主要分为两步:
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
- ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
扩容为2倍的原因:HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash % length。但是 % 运算的效率比位运算的效率低,所以,在源码中做了优化 hash&(length-1)。也就是说 hash%length == hash&(length-1) 。
为2的n次方的原因:因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。简单说就是为了减少 hash 碰撞。
ReHash 的原因:是因为长度扩大以后,Hash的规则也随之改变。【Hash的公式-> index = HashCode(Key) &(Length - 1)】原来长度(Length)是8你位运算出来的值是2 ,新的长度是16你位运算出来的值明显不一样了。
为什么要先高16位异或低16位再取模运算:只是为了降低 hash 冲突的几率。
三、HashMap的 get/put 过程
1. HashMap中 put 元素的过程
对key的hashCode()做hash运算,计算index;如果没碰撞直接放到bucket里;如果碰撞了,以链表的形式存在buckets后;
如果碰撞导致链表过长(大于等于 TREEIFY_THRESHOLD ),就把链表转换成红黑树(JDK1.8中的改动);如果节点已经存在就替换 old value(保证key的唯一性);如果bucket满了(超过LoadFactor * Capacity),就要resize。
2. HashMap中 get 元素的过程
对key的hashCode()做hash运算,计算index;如果在bucket里的第一个节点里直接命中,则直接返回;如果有冲突,则通过key.equals(k)去查找对应的Entry;
- 若为树,则在树中通过key.equals(k)查找,O(logn);
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
3. HashMap在链表元素数量超过8时改为红黑树的原因
JDK1.8 中 HashMap 的变化:
- 由数组+链表的结构改为数组+链表+红黑树。
- 优化了高位运算的 hash 算法:h ^ (h >>> 16)
- 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
- 在新的 Entry 节点在插入链表的时,1.8 使用了尾插法,而 1.7则使用的是头插法。
在解决hash冲突的时候,不直接用红黑树,而选择先用链表,再转红黑树的原因:因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
当链表转为红黑树后,什么时候退化为链表:为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换。
改为尾插法的原因:举个例子,我们现在往一个容量大小为2的put两个值,负载因子是 0.75 。在put第二个的时候就会进行resize。现在我们要在容量为2的容器里面 用不同线程 插入A,B,C,假如我们在resize之前打个短点,那意味着数据都插入了,但是还没resize那扩容前可能是这样的。我们可以看到链表的指向A->B->C。
因为 resize 的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
一旦几个线程都调整完成,就可能出现环形链表 :如果这个时候去取值,悲剧就出现了——Infinite Loop。
使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。
HashMap在并发编程环境下存在的问题:
- 多线程扩容,引起的死循环问题
- 多线程put的时候可能导致元素丢失
- put非null元素后get出来的却是null
在jdk1.8中,死循环问题已经解决。其他两个问题还是存在。
五、HashMap 一般用什么作为key
HashMap 中key 的值可以为 null, key为null的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置。
一般用Integer、String这种不可变类当 HashMap 当key,而且 String 最为常用。
- 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
若使用可变的key, 可能导致 put 进去的值,无法 get 出。
实现一个自定义的class作为HashMap的key该如何实现?此题考察两个知识点
- 重写hashcode和equals方法注意什么?
- 如何设计一个不变类
针对问题一,记住下面四个原则即可:
- 两个对象相等,hashcode一定相等
- 两个对象不等,hashcode不一定不等
- hashcode相等,两个对象不一定相等
- hashcode不等,两个对象一定不等
针对问题二,记住如何写一个不可变类:
- 类添加final修饰符,保证类不被继承。如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。
- 保证所有成员变量必须私有,并且加上final修饰。通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。
- 不提供改变成员变量的方法,包括setter。避免通过其他接口改变成员变量的值,破坏不可变特性。
- 通过构造器初始化所有成员,进行深拷贝(deep copy)。如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。
- 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝。这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。