Java集合框架————Map集合(1)
Collection集合的特点是每次进行单个对象的保存,如果现在要进行一对对象(偶对象)的保存就只能使用Map集合来完成,即Map集合中会一次性保存两个对象,且这两个对象的关系:key=value结构。这种结构最大的特点是可以通过key找到对应的value内容。
1.Map接口简述
首先来观察Map接口定义:public interface Map<K,V>
在Map接口中有如下常用方法:
Map本身是一个接口,要使用Map需要通过子类进行对象实例化。Map接口的常用子类有如下四个: HashMap、Hashtable、TreeMap、ConcurrentHashMap。
2.HashMap子类
HashMap是使用Map集合中最为常用的子类。
范例:Map基本操作
import java.util.HashMap;
public class MapDemo {
public static void main(String[] args) {
HashMap<Integer,String> map = new HashMap<>();
map.put(1,"hello");
map.put(2,"bad");
map.put(3,"man");
map.put(4,"Hello");
System.out.println(map);
System.out.println(map.get(2));
System.out.println(map.get(6));//查不到key 返回null
}
}
切记key值不能重复
根据key取得value。
范例:取得Map中所有key信息
import java.util.HashMap;
import java.util.Iterator;
import java.util.Set;
public class MapDemo {
public static void main(String[] args) {
HashMap<Integer,String> map = new HashMap<>();
map.put(1,"hello");
map.put(2,"bad");
map.put(3,"man");
map.put(4,"Hello");
// System.out.println(map);
// System.out.println(map.get(2));
// System.out.println(map.get(6));
Set<Integer> set = map.keySet();
Iterator<Integer> iterator = set.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
2.1HashMap内部实现基本点分析
首先,我们来一起看看 HashMap 内部的结构,它可以看作是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形
式存储,你可以参考下面的示意图。这里需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为树形结构。
从构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已。
所以,我们深刻怀疑,HashMap 也许是按照 lazy-load 原则,在首次使用时被初始化既然如此,我们去看看 put方法实现,似乎只有一个 putVal 的调用:
看来主要的密码似乎藏在 putVal 里面,到底有什么秘密呢?
从 putVal 方法最初的几行,我们就可以发现几个有意思的地方:
~如果表格是 null,resize 方法会负责初始化它,这从 tab = resize() 可以看出。
~resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。具体键值对在哈希表中的位置(数组 index)取决于下面的位运算:
i = (n-1) & hash
我们会发现,它并不是 key 本身的 hashCode,而是来自于 HashMap内部的另外一个 hash 方法。注意,为什么这里需要将高位数据移位到低位进行异或运算呢?这除是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。
下面进一步分析一下身兼多职的 resize 方法,面试官经常追问它的源码设计。
依据 resize 源码,不考虑极端情况(容量理论最大极限MAXIMUM_CAPACITY 指定,数值为 1<<30,也就是 2的 30 次方),我们可以归纳为:
门限值等于(负载因子)*(容量),如果构建 HashMap 的时候没有指定它们,那么就是依据相应的默认常量值。
门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据 putVal 中的逻辑,当元素。
个数超过门限大小时,则调整 Map 大小。
扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。
2.2 容量、负载因子和树化
前面我们快速梳理了一下 HashMap 从创建到放入键值对的相关逻辑,现在思考一下,为什么我们需要在乎容量和负载因子呢?
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。 既然容量和负载因子这么重要,我们在实践中应该如何选择呢?
如果能够知道 HashMap 要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:
负载因子 * 容量 > 元素数量
所以,预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时它是 2 的幂数,结论已经非常清晰了。
而对于负载因子,我建议:
- 如果没有特别需求,不要轻易进行更改,因为 JDK 自身的默认负载因子是非常符合通用场景的需求的。
- 如果确实需要调整,建议不要设置超过 0.75 的数值,因为会显著增加冲突,降低 HashMap的性能。
- 如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影
树化:
树化改造,对应逻辑主要在 putVal 和 treeifyBin 中。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 树化改造逻辑
}
上面是精简过的 treeifyBin 示意,综合这两个方法,树化改造的逻辑就非常清晰了,可以理解为,当 bin 的数量大于 TREEIFY_THRESHOLD 时:
- 如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
- 如果容量大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造。
那么,为什么 HashMap 要树化呢?
本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。
而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。
上一篇: 这一次搞懂SpringMVC原理
下一篇: 机器学习之决策树算法