序、慢慢来才是最快的方法。
HashMap的底层结构是基于分离链表发解决散列冲突的动态散列表。
- 在Java7中使用数组+链表,发生散列冲突的键值对会使用头插法添加到单链表中;
- 在Java8中使用数组+链表+红黑树,发生散列冲突的键值对会用尾插发添加到单链表中。如果单链表的长度大于8时且散列表容量大于64,会将链表树转化为红黑树。在扩容再散列时,如果红黑树的长度低于6则会还原给链表。
- HashMap的数组长度保证是2的整数次幂,默认数组容量是16,默认装载因子上限是0.75,扩容阈值是12(16*0.75)
- 在创建HashMap对象时,并不会创建底层数组,这是一种懒初始化机制。直到第一次put操作时才会通过resize()扩容操作初始化数组。
- HashMap的key和value都支持null,key为null的键值对会映射到数组下表为0的桶中。
使用 put(key, value) 存储对象到 HashMap 中,使用 get(key) 从 HashMap 中获取对象。
当我们给 put() 方法传递键和值时,我们先对键调用 hashCode() 方法,计算并返回的 hashCode 是用于找到 Map 数组的 bucket 位置来储存 Node 对象。
对 Key 求 Hash 值,然后再计算下标。
如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的 Hash 值相同,需要放到同一个 bucket 中)。
如果碰撞了,以链表的方式链接到后面。
如果链表长度超过阀值(TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表。
如果节点已经存在就替换旧值。
如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)。
调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
原理是如果两个不相等的对象返回不同的 hashcode 的话,那么碰撞的几率就会小些。这就意味着存链表结构减小,这样取值的话就不会频繁调用 equal 方法,从而提高 HashMap 的性能(扰动即 Hash 方法内部的算法实现,目的是让不同对象返回不同hashcode)。
- Java 7: 做 4 次扰动,通过无符号右移,让散列值的高位与低位做异或;
- Java 8: 做 1 次扰动,通过无符号右移,让高 16 位与低 16 位做异或。在 Java 8 只做一次扰动,是为了在随机性和计算效率之间的权衡
源码
public V put(K key, V value) {
return putVal(hash(key) /*计算散列值*/, key, value, false, true);
}
// Java 7:4 次位运算 + 5次异或运算
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 疑问 9:为什么 HashMap 要在 Object#hashCode() 上增加扰动,而不是要求 Object#hashCode() 尽可能随机?
// 为什么让高位与低位做异或就可以提高随机性?
// Java 8:1 次位运算 + 1次异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
并且采用合适的 equals() 和 hashCode() 方法,将会减少碰撞的发生。不可变性使得能够缓存不同键的 hashcode,这将提高整个获取对象的速度,使用 String、Integer 这样的 wrapper 类作为键是非常好的选择。
因为 String 是 final,而且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。
之所以选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。
而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。
引入红黑树就是为了查找数据快,解决链表查询深度的问题。
我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
HashMap 默认的负载因子大小为0.75。也就是说,当一个 Map 填满了75%的 bucket 时候,和其它集合类一样(如 ArrayList 等),将会创建原来 HashMap 大小的两倍的 bucket 数组来重新调整 Map 大小,并将原来的对象放入新的 bucket 数组中。
这个过程叫作 rehashing。
当调用 hash 方法找到新的 bucket 位置,这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为 <原下标+原容量> 的位置。
重新调整 HashMap 大小的时候,确实存在条件竞争。因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。
在调整大小的过程中,存储在链表中的元素的次序会反过来。因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部。
这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。多线程的环境下不使用 HashMap。
Java 给予 HashMap 的定位是一个相对 “通用” 的散列表容器,它应该在面对各种输入场景中都表现稳定。
开放地址法的散列冲突发生概率天然比分离链表法更高,所以基于开放地址法的散列表不能把装载因子的上限设置得很高。在存储相同的数据量时,开放地址法需要预先申请更大的数组空间,内存利用率也不会高。因此,开放地址法只适合小数据量且装载因子较小的场景。
因为当散列冲突加剧的时候,在链表中寻找对应元素的时间复杂度是 O(K),K 是链表长度。在极端情况下,当所有数据都映射到相同链表时,时间复杂度会 “退化” 到 O(n)。
而使用红黑树(近似平衡的二叉搜索树)的话,树形结构的时间复杂度与树的高度有关, 查找复杂度是 O(lgK),最坏情况下时间复杂度是 O(lgn),时间复杂度更低。
这是在查询性能和维护成本上的权衡,红黑树和平衡二叉树的区别在于它们的平衡程度的强弱不同:
平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1。优势是树的结点是很平均分配的;
红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。
- 数据覆盖问题:如果两个线程并发执行 put 操作,并且两个数据的 hash 值冲突,就可能出现数据覆盖(线程 A 判断 hash 值位置为 null,还未写入数据时挂起,此时线程 B 正常插入数据。接着线程 A 获得时间片,由于线程 A 不会重新判断该位置是否为空,就会把刚才线程 B 写入的数据覆盖掉)。事实上,这个未同步数据在任意多线程环境中都会存在这个问题。
- 环形链表问题: 在 HashMap 触发扩容时,并且正好两个线程同时在操作同一个链表时,就可能引起指针混乱,形成环型链条(因为 Java 7 版本采用头插法,在扩容时会翻转链表的顺序,而 Java 8 采用尾插法,再扩容时会保持链表原本的顺序)。
有 3 种方式:
- 方式 1 - 使用 hashTable 容器类(过时): hashTable 是线程安全版本的散列表,它会在所有方法上增加 synchronized 关键字,且不支持 null 作为 Key。
- 方法 2 - 使用 Collections.synchronizedMap 包装类: 原理也是在所有方法上增加 synchronized 关键字;
- 方法 3 - 使用 ConcurrentHashMap 容器类: 基于 CAS 无锁 + 分段实现的线程安全散列表;
这是想考对 HashMap 容量和扩容阈值的理解了。在构造器中传递的 initialCapacity
并不一定是最终的容量,因为 HashMap 会使用 tableSizeFor()
方法计算一个最近的 2 的整数幂,而扩容阈值是在容量的基础上乘以默认的 0.75 装载因子上限。
因此,以上两种情况中,实际的容量和扩容阈值是:
- 1w: 10000 转最近的 2 的整数幂是 16384,再乘以装载因子上限得出扩容阈值为 12288,所以不会触发扩容;
- 1k: 1000 转最近的 2 的整数幂是 1024,再乘以装载因子上限得出扩容阈值为 768,所以会触发扩容;
HashMap 的这些问题只想说句妈卖批!
HashMap 的这些问题只想说句妈卖批!
漫画:高并发下的HashMap - 掘金