HashMap
HashMap
基本概述
HashMap,基于哈希表实现,继承了AbstractMap并且实现了Map接口。
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
threshold
,用于确定在哈希表中触发扩容操作的阈值。也就是说,当实际存储的键值对数量超过了threshold
时,HashMap
将触发扩容操作,重新调整内部的桶的大小loadFactor
,是一个比例值,表示哈希表在多满的状态下会触发扩容,默认0.75
$$
threshold=initialCapacity*loadFactor
$$
JDK1.8
之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决Hash
冲突(两个对象调用的hashCode
方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”)
JDK1.8
之后再解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,会首先调用treeifyBin()
方法。这个方法会根据HashMap
数组来决定是否转换为红黑树。只有当数组长度大于或者等于64
的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是执行resize()
方法对数组扩容。
Put方法
JDK1.8
HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户使用
1 | public V put(K key, V value) { |
putVal流程
如果定位到的数组位置没有元素就直接插入
如果定位到的数组位置有元素,要和插入元素的key进行比较:
- 如果key相同,就直接覆盖
- 不相同判断p是否是一个树节点,如果是就调用上面的方法将元素插入树中,如果不是就尾插法插入
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
JDK1.7,put方法
- 如果定位到数组位置没有元素就直接插入
- 有元素,遍历链表依次比较,如果相同则覆盖,不同采用头插法进行覆盖
1 | public V put(K key, V value) |
扩容
HashMap
的默认容量大小是16.当实际存储的键值对数量超过了threshold
时,HashMap
将触发扩容操作,重新调整内部的桶数组大小,以保持哈希表的性能HashMap
采用整数扩容,简单来说就是,新建一个更长的数组,然后遍历原数组的每个元素,将其重新计算哈希值并放入新的数组中- 扩容操作是一个相对耗时的过程,因为需要重新计算元素的哈希值和重新插入新元素到新数组中。为了减少扩容操作的频率,可以通过在创建
HashMap
对象时指定初始容量和负载因子来进行调优
HashMap在进行扩容时,使用的rehash方式十分巧妙,因为每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只多了一个bit位,所以同一个桶中的节点要么在原来的位置,要么被分配到“原位置+旧容量”的位置
正式因为这样巧妙地rehash方式,既省去了重新计算hash值的时间,同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后的每个桶上的节点一定小于等于原来的桶的节点数,保证rehash之后不会出现更严重的hash冲突
死循环问题
JDK1.7
之前,对冲突数据采用头插法。也就是新插入的数据会处于链表的表头。在并发扩容的时候,会出现死循环问题
对底层链表进行扩容时,会出现并发死链问题。
1 | Void transfer(Entry[] newTable,boolean rehash){ |
JDK8
虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),担任不意味着能够在多线程环境下能够安全扩容,还会出现其他问题(比如扩容丢数据)
存在的线程安全的问题
- 扩容死循环:在JDK1.7中,HashMap使用头插法插入元素,当多个线程同时进行扩容操作时,可能会导致环形链表的形成,从而陷入死循环。
- 元素丢失:当多个线程同时执行put操作时,如果它们计算出的索引位置相同,就会造成前一个key被后一个key覆盖的情况,从而导致元素丢失
- get为null:当一个线程执行put操作导致扩容时,而另一个线程同时执行get操作,有可能导致get返回null的情况。因为在扩容过程中,HashMap的结构发生了变化,get操作可能会在旧的结构中查找元素而导致找不到