写在前面
这篇文章我接着来总结Java结合框架中的另一个接口:Map接口。
Collection接口的特点是每次进行单个对象的保存,如果现在要进行两个对象的保存就只能用Map接口来实现,且这两个对象的关系是:key=value
结构。这个结构最大的特点是可以通过key找到对应的value内容。
- Map接口定义:
public interface Map<k,v>;
- Map中定义的方法:
public V put(k key,V value);
向Map中追加数据public V get(Object key);
根据key取得对应的value,如果没有返回nullpublic Set<K> keySet();
取得所有key信息、key不能重复public Collection<V> values();
取得所有value信息,可以重复public Set<Map.Entry<k,v>> entrySet();
将Map集合变为Set集合
Map本身是一个接口,要使用Map需要通过子类进行对象实例化
Collection保存数据的目的一般用于输出(Iterator),Map保存数据的目的是为了根据key查找,找不到返回null。
Set接口与Map接口的关系:
Set是Map的小马甲(Set内部就是使用Map来存储元素,将元素存储到内部Map的key)
Set下的两个常用子类:
- HashSet:(HashMap的小马甲)
- TreeSet:(TreeMap的小马甲)
1、HashMap子类(常用)
先来看Map的基本操作:
public class MapPractice {
public static void main(String[] args) {
Map<Integer, String> map = new HashMap<>();
map.put(1, "Hello");
map.put(1, "hello");
map.put(3, "Java");
map.put(2, "Nanfeng");
System.out.println(map);
//根据key取得value
System.out.println(map.get(2));
//查找不到返回null
System.out.println(map.get(99));
}
}
取得Map中所有key信息:
public class MapPractice {
public static void main(String[] args) {
Map<Integer, String> map = new HashMap<>();
map.put(1, "Hello");
map.put(1, "hello");
map.put(3, "Java");
map.put(2, "Nanfeng");
Set<Integer> set = map.keySet();
Iterator<Integer> iterator = set.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
这部分的重点是对源码进行分析
:
1.1 HashMap内部实现
几个比较重要的属性:
- final float loadFactor-负载因子(默认为0.75);
- int threshold-容量 = table.length * loadFactor;
- int TREEIFY_THRESHOLD = 8-默认树化阈值;
- int UNTREEIFY_THRESHOLD = 6-默认解除树化阈值;
内部hash()与Object的hash()有什么区别?
观察源码可以发现,HashMap与ArrayList比较相似,也是在第一次添加元素的时候才初始化。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
上面的代码里,put()方法调用了一个内部的putVal()方法,先根据key值进行hash运算,再来看看内部hash()的具体实现:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
上面这段代码表示,当key值为空的时候,把value放在下标为0的位置,也就是数组中第一个位置,当key不为空的时候,返回后边那一串代码的值,那么这一长串代码代表啥意思呢?下面我们来具体分析一下:(h = key.hashCode()) ^ (h >>> 16)
这是一个异或运算,(h >>> 16)代表无符号右移16位,key.hashCode()是Object类提供的求哈希值的方法。那么为什么在HashMap中不直接采用key值的hashCode方法计算哈希值呢?
看下面这个例子:
public class MapPractice {
public static void main(String[] args) {
String str = "你好";
System.out.println(str.hashCode());
}
}
由于Object提供的hashCode得到的是一个32位整数值,直接将其作为数组下标,会浪费大量空间。
而将h右移16位,其实就是只保留了高16位,当数字越来越大的时候,低位的数字没多大意义。
将这两者进行异或运算,实际就是将高低16位都参与异或运算,减少hash冲突。
再来看看右移16位后的结果,差别不是一般的大:
public class MapPractice {
public static void main(String[] args) {
String str = "你好";
System.out.println(str.hashCode());
System.out.println(str.hashCode() >>> 16);
}
}
再来详细分析putVal()方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 此时哈希表还未初始化,第一次调用put方法
if ((tab = table) == null || (n = tab.length) == 0)
// 哈希表初始化
n = (tab = resize()).length;
// key值计算后的数组下标未存储元素,将元素直接添加到数组该索引下
if ((p = tab[i=(n-1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 哈希表已经初始化并且key运算后的下标已经有元素了
else {
Node<K,V> e; K k;
// 此时要插入的元素key值与数组元素key值相等,更新数组元素的value即可
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) {
// 链表中没有一个元素的key与当前元素key值相等,采用尾插法将当前元素插入到链表中
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 当前节点的key与链表中某一节点key值相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 此时并未添加新节点,只是更新了value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 此时确实添加了新节点
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在putVal()中,有这么一句代码,if ((p = tab[i=(n-1) & hash]) == null)
。真正的数组下标为i=(n-1) & hash
,hash是上面说过的高16位整数,n是哈希表的大小。那么这里就有两个问题:
为何HashMap中哈希表的大小始终为2n?
2n-1二进制所有位数都为1,保证哈希中的所有索引下标都有机会被访问到。如果不是2n,比如说n=15,那么n-1就是14,二进制为1110,我们算的是下标的位置,如果最后一位为0,它与hash进行与运算之后,怎么也算不出1111这种下标,浪费了空间。
为何hash()得出来的值仍然不能直接作为数组下标?
即便保留高16位,得到的数仍有可能很大。而 i = (n - 1) & hash能保证得到的下标一定在数组的长度范围内。
举个栗子:
public class MapPractice {
public static void main(String[] args) {
int temp = "hello world".hashCode() >>> 16;
System.out.println(temp);
System.out.println(temp & 15);
int temp1 = "南风知我意".hashCode() >>> 16;
System.out.println(temp1);
System.out.println(temp1 & 15);
}
}
1.2 容量、负载因子和树化
- 树化逻辑:
当前数组下标对应的链表长度(添加完新节点后) >= 8 并且此时哈希表的长度 >= 64,才会将此链表树化,否则只是做了简单的扩容处理。 - 树化原因:
当链表长度过长时,哈希表的CURD时间复杂度会退化为O(n)。将链表变为红黑树,会将时间复杂度降为O(logn)。 - 负载因子:
决定了哈希表达到容量的百分比大小进行扩容阈值。
若负载因子 > 0.75:增加了哈希表的利用率,哈希冲突概率明显增加
若负载因子 < 0.75:降低了哈希冲突的概率,空间利用率降低 - 容量:
扩容
:resize(初始化长度为16,但不是等数组全部填满才开始扩容,当容量达到16*0.75的时候就要扩容了)
每次哈希表扩容时扩容为原先的2倍
当红黑树节点个数小于6时,在下一次resize过程中,将红黑树退化为链表(节省空间)。
table.length:当前哈希表的长度,即数组的元素个数
map.size:当前存储的具体元素个数
这两者毫无关系,当发生第一次扩容的时候,size依然是存储的具体元素个数,而length是哈希表的长度,由16变为32。
size和length的大小关系也不能确定,如果哈希冲突特别频繁,一个数组索引下面都有可能有一个大的红黑树。
2、Hashtable子类
观察Hashtable:
public class MapPractice {
public static void main(String[] args) {
Map<Integer, String> map = new Hashtable<>();
map.put(1, "Hello");
map.put(1, "hello");
map.put(3, "Java");
map.put(2, "Nanfeng");
System.out.println(map);
}
}
考点:请解释HashMap和Hashtable的区别
- 推出版本:
HashMap是JDK 1.2提出的,Hashtable是JDK 1.0提出的 - 性能:
HashMap采用异步处理,性能高,Hashtable采用同步处理,性能较低 - 安全性
HashMap非线程安全,Hashtable线程安全 - null操作
HashMap允许存放null,有且只有一个,Hashtable的key和value都不为空,否则出现NullPointerException。
3、ConcurrentHashMap子类
concurrentHashMap如何高效实现线程安全?
与HashTable对比,HashTable是一个内置线程安全的类(1.0版本之后出现的),性能比较低,一张表就一把锁,并发线程数为1,即使其他线程在另外数组里操作都是互斥的,锁粒度太粗,锁的个数也太少了。
1.7版本采用Segement结构,一张表变为16把锁,锁粒度变细,由锁一张大表的区域变成锁一个小的哈希表区域。
1.8版本锁粒度更细,只锁数组元素一个元素,锁个数更多,支持的并发线程数更多。
Hashtable保证线程安全:
使用synchronized同步方法,锁当前Hashtable对象即整张哈希表,支持的并发线程数为1。
JDK 1.7 与JDK 1.8 ConcurrentHashMap的设计区别?
JDK1.7 ConcurrentHashMap结构
- 使用Segement+哈希表
- Segement初始化为16后无法扩容,扩容发生在每个Segement对应的小哈希表
- 使用Lock体系保证线程安全(Segement是ReentrantLock子类),整张表有16把锁(锁的对象是Segement),支持的并发线程数为16
JDK1.8 ConcurrentHashMap结构
- 结构与JDK 1.8 HashMap基本一致,使用哈希表+红黑树,不再使用Segement
- 使用CAS机制+Synchronized同步块来保证线程安全,锁的对象为哈希表的数组元素(锁的粒度更细,锁的个数也更多,锁的个数会随着哈希表的扩容而增加,16,32,64.。。。)
4、TreeMap子类
TreeMap是一个可以排序的Map子类,它是按照key的内容排序的。
观察TreeMap的使用:
public class MapPractice {
public static void main(String[] args) {
Map<Integer, String> map = new TreeMap<>();
map.put(2, "C");
map.put(0, "A");
map.put(1, "B");
System.out.println(map);
}
}
TreeMap里的排序依然是按照Comparable接口完成的。
有Comparable出现的地方,判断依据就依靠compareTo()方法完成,不再需要equals()与hashCode()。
HashMap、TreeMap、Hashtable的关系与区别:
- 这三个类都是Map接口的常用子类,其中HashMap使用哈希表+红黑树(JDK1.8之后,JDK1.8之前单纯哈希表实现),TreeMap使用红黑树,Hashtable使用哈希表。
- Hashtable使用synchronized同步方法(将整个哈希表上锁,锁粒度太粗),线程安全,性能很低。
HashMap、TreeMap采用异步处理,线程不安全,性能较高。 - 关于null
HashMap key与value都允许为null
TreeMap key不能为null,value允许为null
Hashtable key与value均不为null
关于TreeMap的key值不能为空的解释:
要把元素放到TreeMap中,由于key值所在的类必须要覆写comparable或者comparator接口,然后去调用compareTo或者compar方法,这俩方法都是对象方法,必须有对象,null不能调用,所以key值绝对不能为空,否则会空指针异常。
5、Map集合使用Iterator输出(******)
Map接口与Collection接口不同,Collection接口有Iterator()方法可以很方便的取得Iterator对象来输出,而Map本身没有这个方法。
观察Collection接口与Map接口数据保存的区别:
在Map接口里有一个很重要的方法,可以将Map集合转为Set集合:
public Set<Map.entry<K,V>> entrySet();
Map如果要调用Iterator接口输出,是一个间接使用的模式,直接看代码:
public class MapPractice {
public static void main(String[] args) {
Map<Integer, String> map = new HashMap<>();
map.put(1, "Hello");
map.put(3, "Java");
map.put(2, "Nanfeng");
//1.将Map集合转为Set集合
Set<Map.Entry<Integer, String>> set = map.entrySet();
//2.获取Iterator对象
Iterator<Map.Entry<Integer, String>> iterator = set.iterator();
//3.输出
while (iterator.hasNext()) {
//4.取出每一个Map.Entry对象
Map.Entry<Integer, String> entry = iterator.next();
//5.取得key和value
System.out.println(entry.getKey() + "=" + entry.getValue());
}
}
}
6、关于Map中key的说明
在之前使用Map集合的时候使用的都是系统类作为key(Integer,String等),实际上我们也可以采用自定义类作为key,这时一定要记得覆写Object类的hashCode()和equals()方法。
//自定义类作为key,系统类作为value
class Person {
private Integer age;
private String name;
public Person(Integer age, String name) {
this.age = age;
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "Person{" + "age=" + age + ",name='" + name + "\'" + "}";
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null || getClass() != obj.getClass())
return false;
Person person = (Person) obj;
return Objects.equals(age, person.age) && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(age, name);
}
}
public class MapPractice {
public static void main(String[] args) {
Map<Person, String> map = new HashMap<>();
map.put(new Person(15, "张三"), "zs");
System.out.println(map.get(new Person(15, "张三")));
}
}