Map接口的特点,Map接口下有哪些实现类,HashMap的特点及其详细解读
Map接口是Java集合体系中双列集合的顶级接口,我们已经知道单列集合Collection接口下的实现类,如ArrayList,
LinkedList,Vector等单列集合中存储的都是一个个value,而双列集合与单列集合的不同之处在于双列集合也就是Map集合中存储的不再是一个个value,而存储的是映射关系,简单来说Map集合中存储着一一对对key->value,而每对键值则代表一种mapping的关系,而这种mapping关系就决定了Map集合的一个基本特点:
不可重复性或者说唯一性,也就是说在Map集合中每对key->value中key是不可重复的,即key与value是一一对应的,但value是可以重复的.
Map的常用实现类: HashMap,Hashtable,TreeMap
HashMap:
基本特点及用法:
HashMap作为Map接口的常见实现类之一,具有HashMap的基
本特性: 即key的不可重复性,值的可重复性,除此之外HashMap的另一特点是key的无序性,这里的无序性指元素的实际存储顺序与元素添加的顺序无关,而元素的实际存储顺序则与HashMap的存储结构有关
从创建一个HashMap开始:
HashMap<String,String> hashMap = new HashMap<>();
在创建HashMap这个双列集合类时,我们可以基于泛型机制直接为HashMap分别指定key和value的存储类型.
此处选择String类型作为HashMap中key的类型,而通常情况下也会选择String做为HashMap中key的类型
这是因为HashMap的key一定是唯一的,并且是一经创建便不可改变的
而我们知道String类对象已经创建便不可改变,这是因为String类对象的底层是基于一个final的关键字修饰的char类型的数组:
private final char value[];
被final修饰的对象一经创建便不可改变,这决定了String的不变性,同时也决定了为什么通常采用String类型作为HashMap的key类型.
HashMap实现了Map中定义的一些方法,借助HashMap方法我们可以更好地理解hashMap的特点以及HashMap底层的存储特点:
put(Object key,Object value)用于向HashMap中添加一对键值,运行以下这段程序观察结果
HashMap<String,String> map1 = new HashMap<>();
map1.put("a","a");
map1.put("a","b");
System.out.println(map1);
可以看到虽然向HashMap中put了两对键值,但最终的输出结果却只有a->b这对键值,这验证了HashMap中键值的唯一性,也就是说对同一个key而言,当value不同时,会发生值的覆盖,从而保证key的唯一性.
put()是有返回值的,运行下面这一段程序我们来观察返回结果:
System.out.println(map1.put("a","a"));
System.out.println(map1.put("a","b"));
第一个返回值为null,第二个返回值为a
查看api中的解释我们可以知道put()方法的返回值为同一个key的上一个value对象,这样就很好解释了,第一条语句中a->a被第一次添加到HashMap中,所以会返回null,而第二行代码在put()时,由于键值a->a已经存在了,因此该方法会返回键a之前的值a.
除此之外put()的另一特点是允许存储的key->value中value为null,并允许存储一对key为null的键值
运行如下代码观察结果
map1.put("e","e");
map1.put("a","a");
map1.put("x","x");
map1.put(null,"c");
map1.put("y",null);
map1.put("h",null);
System.out.println(map1);
可以看到输出结果中键值的顺序并不是键值添加的顺序.
至于HashMap是如何保证key的唯一性以及HashMap是如何在底层存储key-value的,需要对put()方法进行深度地解读.
HashMap如何保证key的唯一性以及put()方法的内部执行过程:
首选我们点击进入源码的第一层:
可以看到在我们传入key与value后put()方法内部调用了一个putVal()的方法,而该方法的第一个参数是hash()方法的返回值,而这个hash()的参数是我们本次传入的key.所以我们需要点击查看这个hash()
通过观察上述代码,我们易知该方法返回的是key的hash值,但这个hash值只是一个初步的hash值,而这也就解释了为什么HashMap中允许有且仅有一个key为null,当我们传入的key为null时,会默认返回0做为null的hash值,
在获得了元素的hash值后会进入putVal()方法,putVal()方法的源代码如下:
这段源码较长,我们逐一地去分析
627行中首先声明了一个Node类型的数组tab,和一个Node类型的引用p;
在这之前需要对HashMap的底层存储结构做一个简单的介绍
HashMap的底层存储借助了三种数据结构来实现,他们分别是
哈希表,链表以及红黑树(一种自平衡的二叉树)
哈希表也就是一种特殊的数组,而它的特殊之处在于向哈希表中存储数据元素时需要根据根据传入元素的hash值确定它在数组中的索引位置,而hash值需要借助hash函数进行计算,所以我们在向HashMap中的put()在进入putVal()之前需要调用hash()来计算当前key的初步hash值,这是HashMap保证key的唯一性的第一个步骤,在进入putVal()方法后第一行声明的Node类型的tab数组就是HashMap底层存储所用到的第一种数据结构——哈希表
该数组是Node类型的,也就是说该数组中存储着一个个Node类型的节点,也就是数组每个位置上挂载的链表的首节点,Node是HashMap的一个内部类,而Node类中封装了四个成员属性:
final int hash;
final K key;
V value;
Node<K,V> next;
分别存储着key的hash值,key->value以及后继指针
进入第一个if()判断
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
当我们添加第一个元素时此时hash表为空,则此时会调用resize()方法对创建hash表:
此时oldCap为0,会进入else的所对应的代码块为hash表指定容量,也就是hash表默认的初始容量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
也就是说HashMap底层的hash表的初始容量为16
hash表初始化完成后,会执行下一个if判断
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
括号中的i = (n - 1) & hash的结果才真正确定了该键值在Hash数组中的索引,而p引用则指向了当前位置的Node节点,若p为null则说明当前位置没有元素,创建一个Node对象并封装该键值信息存入hash表中,并将next置空,作为链表的首节点.
如果进入了else判断则说明当前位置已经有元素了,也就是说此时发生了哈希冲突的情况,需要进行进一步的判断
第一个if条件代表此时hash值相同的情况下key也相同,则使用引用e记录下冲突的节点
if (e != null) {
// existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
在e不为空的情况下会对冲突的key进行value的覆盖,并返回旧的value,这样正是put()有返回值的原因
若只是hash值冲突但key不冲突则会进入下一个else if代码块
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
若满足了p instanceof TreeNode这个条件,说明这个位置上不仅有元素,而且此时节点类型为树节点类型,说明此时该位置已经挂载了一科红黑树,则会将Node节点添加至红黑树中.
若此时节点类型不为树节点类型,则说明此时该位置上只是挂载着一个链表,则遍历该链表找到当前链表的尾结点,但在插入链表之前,还需要再做一次判断.即判断链表上节点的个数是否到达阈值 TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
TREEIFY_THRESHOLD是HashMap内部定义的一个常量,值为8.
如果当前链表的长度为8,则会进入treeifyBin()进一步判断是否需要将此链表转为红黑树.
点击进入该方法:
进入该方法后将tab的长度也就是hash数组的长度与常量MIN_TREEIFY_CAPACITY做比较,该常量的值为64
static final int MIN_TREEIFY_CAPACITY = 64;
若当前数组的长度小于64,则会对tab进行横向扩容,即调用resize()
我们可以看到扩容后的hash数组的新容量为旧容量的2倍
但若tab的长度已经超过64,则不会进行扩容,此时链表结构会转化为红黑树结构
但若此时链表的长度没有到达阈值8,则将Node节点挂载至链表的末尾.
需要注意的是在hash数组进行扩容时还会用到一个常量,也就是装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
该装载的因子的值为0.75,当横向数组中的元素个数达到当前容量的0.75时便会扩容至当前容量的两倍,
装载因子的设置是为了避免出现hash聚集现象而导致红黑树高度过大从而降低查询效率.
综上所述:
HashMap保证key的唯一性借助了hashcode和equals()方法实现,利用了hashcode是为了提高效率,
而使用equals()则真正确保了键值的唯一性,这是因为采用String作为key时,即使是不同的String的hash值也有可能相同,例如我们执行下面两行代码:
System.out.println("通话".hashCode());
System.out.println("重地".hashCode());
可以看到此时通话和重地的hashcode的值相同,则此时会进一步根据equals()比较内容是否相同
除此之外HashMap还有一种衍生的数据结构,也就是HashSet,HashSet是Set接口下的一个单列集合
但其底层依然使用了HashMap的结构
例如:当我们创建HashSet时,点击查看构造方法
public HashSet() {
map = new HashMap<>();
}
其底层实质上是创建了一个HashMap,而当我们向HashSet中添加元素时
public boolean add(E e) { return map.put(e, PRESENT)==null;}
底层代码依旧调用了HashMap的put()方法,
所以HashSet可以看做是将HashMap结构中的key列单独抽取了出来,由于HashMap中key的唯一性和Set集合不允许存储重复元素的特点,所以将key列单独封装为一个Set集合.
得益于底层的hash表,虽然HashMao在map接口的众多实现类中查询获取效率较高,但HashMap也存在着一定的缺点,那就是安全性问题,所以HashMap适合在单线程条件下使用,而Hashtable则是线程安全的,
ConcurrentHashMap这一集合则可以有效地解决HashMap的安全问题,关于ConcurrentHashMap,以及Hashtable,TreeMap等集合的解读可查看更多文章