HashMap
基本概述
HashMap,基于哈希表实现,继承了AbstractMap并且实现了Map接口。
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>,Cloneable, Serializable{ final float loadFactor; int threadhold; transient int size; transient int modCount; }
|
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 2 3
| public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
|
putVal流程
如果定位到的数组位置没有元素就直接插入
如果定位到的数组位置有元素,要和插入元素的key进行比较:
- 如果key相同,就直接覆盖
- 不相同判断p是否是一个树节点,如果是就调用上面的方法将元素插入树中,如果不是就尾插法插入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
|
JDK1.7,put方法
- 如果定位到数组位置没有元素就直接插入
- 有元素,遍历链表依次比较,如果相同则覆盖,不同采用头插法进行覆盖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }
modCount++; addEntry(hash, key, value, i); return null; }
|
扩容
HashMap的默认容量大小是16.当实际存储的键值对数量超过了threshold时,HashMap将触发扩容操作,重新调整内部的桶数组大小,以保持哈希表的性能
HashMap采用整数扩容,简单来说就是,新建一个更长的数组,然后遍历原数组的每个元素,将其重新计算哈希值并放入新的数组中
- 扩容操作是一个相对耗时的过程,因为需要重新计算元素的哈希值和重新插入新元素到新数组中。为了减少扩容操作的频率,可以通过在创建
HashMap对象时指定初始容量和负载因子来进行调优
HashMap在进行扩容时,使用的rehash方式十分巧妙,因为每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只多了一个bit位,所以同一个桶中的节点要么在原来的位置,要么被分配到“原位置+旧容量”的位置
正式因为这样巧妙地rehash方式,既省去了重新计算hash值的时间,同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后的每个桶上的节点一定小于等于原来的桶的节点数,保证rehash之后不会出现更严重的hash冲突
死循环问题
JDK1.7之前,对冲突数据采用头插法。也就是新插入的数据会处于链表的表头。在并发扩容的时候,会出现死循环问题
对底层链表进行扩容时,会出现并发死链问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Void transfer(Entry[] newTable,boolean rehash){ int newCapacity=new Table.length; for(Entry<K,V> e:table){ while(null!=e){ Entry<K,V> next=e.next; if(rehash){ e.hash=null==e.key?0:hash(e.key); } int i=indexFor(e.hash,newCapacity); e.next=newTable[i]; newTable[i]=e; e=e.next; } } }
|
JDK8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),担任不意味着能够在多线程环境下能够安全扩容,还会出现其他问题(比如扩容丢数据)
存在的线程安全的问题
- 扩容死循环:在JDK1.7中,HashMap使用头插法插入元素,当多个线程同时进行扩容操作时,可能会导致环形链表的形成,从而陷入死循环。
- 元素丢失:当多个线程同时执行put操作时,如果它们计算出的索引位置相同,就会造成前一个key被后一个key覆盖的情况,从而导致元素丢失
- get为null:当一个线程执行put操作导致扩容时,而另一个线程同时执行get操作,有可能导致get返回null的情况。因为在扩容过程中,HashMap的结构发生了变化,get操作可能会在旧的结构中查找元素而导致找不到