【Java集合框架】Map
HashMap(可以存null键)
- JDK1.7之前采用数组+链表的结构,插入元素使用头插法。JDK1.8之后采用数组+链表+红黑树结构,使用尾插法。
- 在JDK1.7时,可能会因为hashcode计算的不得当而造成链表过长的问题,查找时可能会遍历链表,时间复杂度O(n)。JDK1.8添加了红黑树结构,红黑树是平衡查找树,查找过程是二分的,复杂度O(logn)。
- 默认容量为16,加载因子为0.75,阈值threshold=容量*加载因子,当table数组内元素个数超过阈值时扩容为原来的2倍。HashMap规定容量必须是2的n次方,方便在put时计算key的hash值来确定其存放位置,计算Node存放位置时先利用key的hash值计算出更好的hash值(二次hash,让hash值的高位也参与到运算中),然后用计算出来的hash值 & (table.length - 1)即可得到对应数组下标,用与运算"&“代替取余运算”%",提高了效率。如果用户传入的初始容量不是2的n次方则内部会使用tableSizeFor()方法找到第一个大于此数并满足要求的数作为初始容量。
- 每次put元素时会检查:链表长度是否大于8并且HashMap中Node总数是否超过64,如果链表长度超过8但Node总数小于64则扩容,如果两个条件都满足则将此链条转为红黑树。每次remove元素时如果链表长度小于6则将红黑树恢复为链表结构。
- 每次扩容时需要将原来数组中的内容转移到新数组中,因此每个Node都需要rehash来计算在新数组中存放的位置。因为计算下标是使用“&”操作,因此当数组扩容时只需要判断Node在计算下标时新增的一位是0还是1即可,如果是0则说明计算后的下标与原来下标相同,那么直接将原来的元素移动到新数组中同一个下标的位置即可。如果是1则说明计算后的下标大于原来的下标,而这个差值正好是原来数组的大小,因此计算出的新下标为:“旧下标+原来数组长度”。这个规律提高了resize()的效率,但还是需要遍历每一个Node,因此在创建HashMap时最好估计最大容量,尽可能的省去不必要的扩容开销。
- HashMap可以存放key为null的键值对,默认将Node存放在数组[0]号位置。
- HashMap支持fail-fast。
put()流程
-
计算key的hash值,确定在数组中的下标。如果当前位置值为null则直接插入,如果当前位置有值则判断key是否是同一个,如果是则覆盖旧值。如果不是同一个key则说明hash冲突,采用拉链法插入。在拉链遍历的过程中也会判断key是否是同一个,如果都不是则插入到链表中(1.8尾插,之前是头插)。
//判断key是否相同: 两个key的hashcode相等 并且 (两个key引用相同 或者 调用equals方法返回true) //这也是为什么用作key的对象必须实现hashCode()方法和equals()方法的原因 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
-
插入后会判断当前链表长度是否大于8并且HashMap中Node总数是否超过64,如果符合则转换为红黑树,如果大于8但总数小于64则扩容。在put的最后会更新size值,并依据table的size是否超过阈值来判断是否进行扩容。
get()流程
同样使用hash()函数计算对应的数组下标,查看对应位置Node中的key是否与传入的key相同,如果不同则继续遍历链表,如果是红黑树则按照红黑树的方式遍历。
为什么初始容量为16、加载因子为0.75?
初始容量设置过大则浪费空间,设置过小则需要频繁扩容浪费资源。
加载因子太小则浪费空间,导致频繁扩容。过大则会增加hash碰撞几率。负载因子0.75 就是冲突的几率 与空间利用率权衡的最后体现,也是一个程序员实验的经验值。
一个bucket空和非空的概率为0.5,通过牛顿二项式等数学计算,得到这个loadfactor的值为log(2),约等于0.693。
最后选择选择0.75,可能0.75是接近0.693的四舍五入数中,比较好理解的一个,并且默认容量大小16*0.75=12,为一个整数。
链表转红黑树的阈值为什么是8?
在JDK源码注释中提到:
理想状态中,在随机哈希码情况下,对于默认0.75的加载因子,桶中节点的分布频率服从参数为0.5的泊松分布,即使粒度调整会产生较大方差。
由对照表,可以看到链表中元素个数为8时的概率非常非常小了,所以链表转换红黑树的阀值选择了8。
哈希表的最小树化容量为什么是64?
当HashMap中Node节点总数小于64时认为哈希碰撞的几率较大,这种情况下发生链表过长的几率也就比较大,但此时节点总数较少,应优先选择扩容而避免不必要的树化。
为什么HashMap是线程不安全的?
JDK1.7采用头插法,多线程访问时可能会有多个线程同时扩容,在**resize()扩容方法中会调用transfer()**方法来转移原来数组中的元素到新数组中去。当一个线程A刚执行到transfer()时,保存了当前状态的下一个需要遍历的节点,而恰好该线程CPU时间片耗尽,轮到另一个线程B执行resize()方法,待线程B执行完transfer()方法后,此时HashMap的结构已经和线程A保存的状态不同。当线程A再继续执行transfer()方法时,可能会造成两个节点相互指向对方。在下一次遍历到这里时(如执行put()、get()等)会造成死循环问题。
JDK1.8采用尾插法,避免造成死循环的问题,但HashMap依旧是非线程安全的。假设此时多线程访问HashMap,当线程A要添加元素到数组中的一个空闲位置时,会发现数组中的这个位置是null值,然后准备对这个空闲位置赋值。此时线程B也发现了这个空闲位置,然后线程B也准备对这个空闲位置赋值,因此就造成了两个线程必定有一个线程添加的值被另一个线程添加的值所覆盖。另外,对于HashMap的更新size操作也不是原子的,所以这个操作也可能引发线程安全问题。
Hashtable(k、v都不能为null)
-
初始容量11,加载因子0.75,线程安全版的HashMap,几乎每一个方法都加了synchronized修饰。
-
底层维护一个table数组存放Entry,采用拉链法解决hash冲突,即数组+链表,也是头插法。Hashtable直接将key的hashcode对数组长度取模来计算数组下标。
-
key、value都不能为null,否则报空指针异常。
-
在Hashtable里求元素存的位置用的是除留余数法(index = hash % length),每次扩容为2倍+1
hashtable的新容量是:2倍+1,即始终为奇数。我们知道“除2以外所有的素数都是奇数”,而当哈希表的大小为素数时,简单的取模哈希的结果分布更加均匀,从而降低哈希冲突。
HashMap与Hashtable的异同
- HashMap线程不安全,Hashtabl线程安全。
- HashMap可以使用null作为key,默认放在table中[0]号数组位置。Hashtable的key和value都不能为空。
- HashMap对key的hashcode进行了二次hash,以获得更好地散列值,然后用得到的散列值计算数组下标。Hashtable直接用key的hashcode对数组长度取余得到数组下标。
- HashMap默认容量16,每次扩容为原来的2倍。Hashtable默认容量11,每次扩容为原来的2倍+1。
LinkedHashMap
- 继承自HashMap,拥有与HashMap几乎相同的特性。向table数组中添加的不再是Node而是从Node继承来的Entry,Entry里面增加了指向前一个节点和后一个节点的指针,即变成了双向链表,所以LinkedHashMap可以维护插入的顺序。
- LinkedHashMap中有Entry类型的head和tail分别代表双向链表的头和尾。
Properties
- Properties是Hashtable类的子类,它相当于一个key、value都是String类型的Map,主要用于读取配置文件。
TreeMap
- 红黑树,每个key-value对作为红黑树的一个节点。TreeMap存储key-value对时,需要根据key对节点进行排序,有两种排序方式:
- 自然排序:TreeMap的所有key必须实现Comparable接口,而且所有的key应该是同一个类的对象,否则会抛出ClassCastException。
- 自定义排序:创建TreeMap时,传入一个Comparator对象,该对象负责对TreeMap中的所有key进行排序。
WeakHashMap
- 结构类似老HashMap,数组+单向链表,但key是弱引用,没有其他强引用指向的话会被回收,每次调用get()和put()时,在方法内部会首先剔除被回收掉的Entry,然后再执行相应的获取、添加操作。
- 每个key-value是一个Entry,WeakHashMap中存放的Entry继承自WeakReference,也就是说key是一个弱引用,如果除了WeakHashMap外没有其他强引用指向key,则在下次gc时key-value组成的Entry会被移除。
- 当没有其他强引用指向key时,key会被回收,而且被回收的key会被添加到ReferenceQueue中。在每次执行put()和get()操作时,先调用getTable()方法获取Entry数组,在getTable()方法中调用expungeStaleEntries()方法,该方法会不断在ReferenceQueue中取出元素(每个元素都是被回收的弱引用----也就是key),计算该key所在的数组下标。然后遍历对应的链表,找到链表中键值为key的Entry,将其从链表中移除。
参考:
上一篇: Maven的profile使用
下一篇: 这一次带你彻底搞懂watermark