Map接口
HashMap实现类
这里我们分析HashMap的源码,是Java8中的源码。
实际上我们这里分析HashMap实现类,在之前分析HashSet的时候其底层源码就已经有了一定程度的分析。
因为HashSet的底层使用的就是HashMap
**详见我的另一篇文章 Java容器框架——Set接口及其实现类HashSet
Map接口实现类的特点:
-
Map中的key和value可以是任何应用类型的数据,用于保存具有映射关系的数据: Key-Value ,会封装到
HashMap$Node对象中 -
Map中的key不允许重复,原因和
HashSet一样-
Map中的所谓的key不允许重复,这里实际上要追溯到
HashMap的源码中,涉及到两方面的内容。- 什么叫做key重复了,那就是这个key对应的hash值以及equals()方法 。 hash值相同并且equals()为true
-
-
Map中的value可以重复
-
Map中的key可以为null,value也可以为null。 但是key为null只能有一个,value为null可以有多个
-
key和value之间存在单向一对一的关系,即通过指定的key总能找到对应的value
HashMap底层结构和源码
HashMap中底层核心成员变量
-
transient Node<K,V>[] table;-
HashMap中使用一个Node<K,V>[]类型的数组table来存储元素 -
这里的Node是
HashMap中的静态内部类,其成员变量部分源码为:- 里面记录了每个节点对应的hash值、对应的key、value以及next指向下一个节点的指针
-
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
-
transient int size;- 记录map中实际键值对的对数
-
transient Set<Map.Entry<K,V>> entrySet;- 保存缓存的
entrySet()。AbstractMap字段用于keySet()和values()
- 保存缓存的
HashMap的put方法
HashMap的put方法底层源码具体请查看HashSet底层源码分析。
但是特别需要注意的是,调用put方法,如果节点已经存在,那么就会用新的value值去替换旧的value值 。 这一点在HashSet中是无法体现的,因为HashSet底层虽然是HashMap,但是实际上它只用到了key,而value直接用了一个静态常量Object PRESENT 给偷鸡了
代码尝试:
可以通过下面的代码打印看到,key为“zhangsan”的,其value已经被替换成了新的value,即5
package com.nylonmin.mapDemo;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* 需求: 统计每个单词在文档中出现了多少次
*/
public class MapDemo {
public static void main(String[] args) {
Map<String,Integer> map = new HashMap<>();
map.put("zhangsan",1);
map.put("lisi",2);
map.put("wangwu",1);
map.put("zhangsan",5);
Set<Map.Entry<String,Integer>> entrySet = map.entrySet();
Iterator<Map.Entry<String, Integer>> iterator = entrySet.iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> node = iterator.next();
System.out.println(node);
}
/**
* 输出结果:
* lisi=2
* zhangsan=5
* wangwu=1
*/
}
}
这里实际上也就是在HashMap的final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) 方法中的某个if语句,具体如下:
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
HashMap的树化改造逻辑
这个树化方法的前提是某条链的长度超过了8
如果当前hashmap底层的数组长度小于 MIN_TREEIFY_CAPACITY(64) 那么就只会扩容,不会树化
如果hashmap底层的数组长度大于等于64 就会进行树化。
//进入这个方法的前提是某条链的长度超过了8
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果当前hashmap底层的数组长度小于 MIN_TREEIFY_CAPACITY(64) 那么就只会扩容,不会树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果hashmap底层的数组长度大于等于64 就会进行树化。
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
HashMap的hash方法的实现
实际上hash方法的实现很简单,但是巧妙之处在于扩容之后,某条链表中部分node的下标改变方式.
-
这里在分析
HashSet的时候实际上也有提到过 。 实际上某个key对应的哈希值是通过两方面的道德- 首先通过调用
hashCode()方法,获取到一个哈希值h - 然后将h 右移16位(就是先把h变为二进制,然后右移16位)
- 然后将1的结果和2的结果 进行异或操作,得到key对应的最终的哈希值
- 首先通过调用
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
计算出来了key对应的哈希值之后,这个key应该放到哪个下标呢? 仍然还是在putVal()方法中.
计算key应该放的下标的位置,其源码为:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
只用看这一句 。 n是hashMap底层数组table.length ,这里就是用n-1 和前面 求得到哈希值进行与操作
i = (n - 1) & hash]
扩容后节点索引会变?
好,我们继续拿一条链为例,那么我们为什么说!扩容之后!这条链上的某些节点的下标会变?
针对源码 i = (n - 1) & hash]
hash这个值不会变,因为他就只是一个简单的h = key.hashCode()) ^ (h >>> 16,key的值又不会变,那么h就不会变- 扩容之后,n-1 变了。 记住,hashMap的扩容机制是扩容为原来的2倍
hashMap它很有一个很巧妙的点在于,根本不需要去再次通过上面这些繁琐的操作去求key对应的新索引,只需要做一个简单的加法。
这意味着什么?!
-
我们始终可以发现,不管怎么样,每次都是扩容成2倍,为啥扩容成2倍,扩容2倍就相当于n-1的二进制就只是多了一个1
-
这啥意思,意思就是只要我原来hash值对应的这一位原来是1,那么它新对应的下标就是 原下标+原数组容量 对应上面hash2的新下标 5+16 = 21
-
那么又引出了一个新的问题,如果我一开始代码这么写的呢?
HashMap<String,Integer> map2 = new HashMap<>(7);那么第一次扩容是扩容成多少?-
▲ 这里要记住
HashMap扩容的步骤- 计算当前容量的两倍。
- 找到大于或等于这个值的最小 2 的幂。
-
也就是说 一开始确确实实,容量被初始化为7,但是第一次扩容,会被扩为16
-
-
Hashtable实现类
Map接口的另一个实现类——Hashtable,基本介绍
-
存放的元素是键值对,即K-V
-
Hashtable的键和值都不能为null,否则会抛出NullPointerException -
Hashtable使用方法基本上和HashMap一样 -
Hashtable是线程安全的,HashMap是线程不安全的Hashtable的底层相关方法,加上了synchronized关键字,所以说他是线程安全的
public synchronized V put(K key, V value) {…………}
Hashtable底层扩容机制
首先直接说结论:
| Map接口实现类 | 扩容方法名称 | 负载因子 | 无参构造器初始化 | 有参构造器初始化(指定长度) | ||
|---|---|---|---|---|---|---|
| 第一次添加元素后容量 | 后续扩容 | 第一次添加元素后容量 | 后续扩容 | |||
HashMap | resize() | 0.75 | 16 | 原容量*2 | 指定长度 | 1. 计算当前容量的两倍。 2. 找到大于或等于这个值的最小 2 的幂。 |
Hashtable | rehash() | 0.75 | 11 | 原容量*2+1 | 指定长度 | 原容量*2+1 |
Hashtable的底层扩容机制,其源码为:
最最重要的实际上就一行int newCapacity = (oldCapacity << 1) + 1; 就是前面所说的,将原容量扩充为2倍然后再+1 即得到新的容量。
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// overflow-conscious code
int newCapacity = (oldCapacity << 1) + 1;
……………………后面的源码就暂时省略
}
Propertied实现类
- Poroperties类继承自Hashtable类,并且实现了Map接口,也是使用一种键值对的形式来保存数据
- 使用特点和Hashtable类似,键不能存放null,值也不能存放null
- Properties还可以用于从xxx.properties文件中,加载数据到Properties类对象,并进行读取和修改
TreeSet与TreeMap
这类TreeMap和TreeSet之所以放在一起进行说明,实际上在于这二者之间的关系就是同HashSet、HashMap之间的关系。
TreeSet的底层实际上用的就是TreeMap
这里就直接以讲TreeSet为主,但是要知道,大多数时候,在讲解源码时实际上讲解的是TreeMap的源码
这里直接说明,无论是
HashSet还是TreeSet,其底层代码都是直接使用的对应的Map接口的实现类。所以
HashSet和TreeSet都设置了一个静态常量private static final Object PRESENT = new Object();这玩意儿
PRESENT就是用来偷鸡的,因为Value总归要放东西
TreeSet构造方法
一般我们使用的构造方法有两种,一种是无参构造方法,另一种是有参构造方法(这里的有参构造方法中的参实际上是Comparator<? super E> comparator,这里我们姑且称之为比较器)
这里看底层源码就可以明显看到其底层调用的就是对应到TreeMap的构造器
//无参构造器
public TreeSet() {
this(new TreeMap<E,Object>());
}
//有参构造器
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
添加元素
同构造方法,TreeSet的add()方法实际上使用的就是TreeMap的put()方法,所以我们直接分析put()方法的源码
▲ 这里首先要明确一个事儿,就是TreeMap的底层数据结构是红黑树,数据结构就不做具体介绍了,反正知道是棵树就完事儿了。
public V put(K key, V value) {
Entry<K,V> t = root;
//如果树的头结点为空,那么就直接插入
if (t == null) {
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
//获取比较器
Comparator<? super K> cpr = comparator;
//如果比较器不为空,这里说通俗一点就是我们构造器当时调用的是有参构造器
//传入了Comparator参数
if (cpr != null) {
do {
parent = t;
//调用当时设定的compare方法
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
//如果compare的结果是相等,那么用新的值来替换旧的值
//但是在TreeSet中,因为值都是PRESENT常量
//那么在形式上的体现就是——“新的键无法加入”
return t.setValue(value);
} while (t != null);
}
// 如果没有提供比较器,使用键的Comparable接口
// 下面的整个比较同上面的if中的语句
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
再着重强调一下上面的源码,用通俗易懂的话来说明:
-
如果初始化时调用的是有参构造器,传入了Comparator参数,那么就用传入的这个compare方法
-
如果初始化时调用的是无参构造器,那么就会使用key的CompareTo方法 来进行比较
-
不管使用哪个方法吧,总之,只要比较的结果是相同
- 那么在
TreeSet中体现的结果就是——新的键无法加入 - 在
TreeMap中体现的结果就是——新的键无法加入,旧的键对应的值更新
- 那么在
-
实际上,上面的源码也写了,本质上就是更新值,只不过
TreeSet的value永远是常量PRESENT这么个东西。
-
写个小demo来验证一下就知道了
这里稍微多说一句,实际上这个有参构造器中的Comparator参数,它是一个函数式接口,所以下面的代码我就直接用Lambda表达式来简化了
通过下面代码的输出结果可以看到ffff这个键,根本没有进去
package com.nylonmin.setDemo;
import java.util.TreeSet;
@SuppressWarnings("all")
public class TreeSetDemo {
public static void main(String[] args) {
TreeSet<String> set = new TreeSet<>((o1,o2)->{
return o2.length()-o1.length();
});
set.add("safd");
set.add("ad");
set.add("adddd");
set.add("ffff");
for(String s :set){
System.out.println(s);
}
// 输出的结果为
// adddd
// safd
// ad
}
}
再来个TreeMap的小demo。 通过下面的代码输出可以看到,键asdf的值更新成了222
package com.nylonmin.mapDemo;
import java.util.Map;
import java.util.TreeMap;
public class TreeMapdemo {
public static void main(String[] args) {
TreeMap<String,Integer> map = new TreeMap<>((o1,o2)->{
return o1.length() - o2.length();
});
map.put("asdf",123);
map.put("ss",1111);
map.put("sadss",11);
map.put("ssss",222);
for(Map.Entry<String,Integer> entry : map.entrySet()){
System.out.println(entry.getKey()+"--"+entry.getValue());
}
// 输出结果为
// ss--1111
// asdf--222
// sadss--11
}
}