聊完了List篇后,现在开启了Map篇的深入了解,也就分析一下平常使用到的一些Map。首先还是来总体看一下接口的继承关系:
本章内容主要就是针对标记为绿色的5中Map进行分析,还是总结一下这5种Map的区别:
AbstractMap
在介绍具体Map之前,也看一下这个AbstractMap,也就是骨架抽象Map,先回顾一下Map接口:
既然在Map接口中都定义了用来存放key和value的集合以及键值对的集合,那么在AbstractMap中就有一部分方法可以根据这个来实现了:
其实从这2个类我们就可以看出,Map中保存key集合和value集合,所以也没有必要把Map继承至Collection,Map就相当于是Set和List的结合,所以上一节的接口图中,Map是单独拎出来的。
HashMap
终于到了关键的HashMap部分了,在HashMap的源码里,涉及的东西非常多,包括hash、位运算、扩容机制、数据结构等,是个非常需要学习的一种集合。
常量:
HashMap中定义了非常多的常量
//数组默认容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载系数
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//转成数结构的阈值,单链表的长度大于8转成树
static final int TREEIFY_THRESHOLD = 8;
//低于6时,由树转成单链表
static final int UNTREEIFY_THRESHOLD = 6;
//转成树时,要求总容量最低树
static final int MIN_TREEIFY_CAPACITY = 64;
//底层数据结构,使用数组保存数据
transient Node[] table;
//阈值,也就是集合元素大于阈值进行扩容
int threshold;
//负载系数,阈值等于数组容量*负载系数
final float loadFactor;
数据结构:
在HashMap中,键值对是使用Node来保存,不管这个键值对多简单,都必须按照这个格式
static class Node implements Map.Entry {
final int hash; //key的hash值
final K key; //key
V value; //value
Node next; //下一个Node
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
hash算法:
//对一个object进行hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//根据hash值在数组上找到对应的index
p = tab[i = (n - 1) & hash]
- 这里当key是null时,hash值直接是0;
- hash值默认是32位的二进制数,这里为什么要右移动16位再取异或(相同为1不同为0),主要是为了加上干扰函数,让hash值分散的更散一点,也就是让一个32位的二进制数高位和低位都参与到。
- 按照正常的想法,一个数组长度为n,根据hash值取出index在0到n-1之间,这时应该进行取模运算,但是在计算机中除法是很浪费性能的,所以采用与运算来做,但是这里达到同样效果的条件是n必须是2的n次方。
- 上一条也就说明,HashMap数组的大小必须是2的n次方,每次扩容直接翻倍。
新增键值对:
public V put(K key, V value) {
//计算出hash值
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; //HashMap的底层数组
Node p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//扩容过程,下面再说
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//数组该位置为空,直接存放Node
tab[i] = newNode(hash, key, value, null);
else {
//p指向数组上的Node
//链表操作使用指针,思路要明确
Node e;
K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//hash相同 且key相同,说明是要更新值,直接e指向这个节点p
e = p;
else if (p instanceof TreeNode)
//树形结构,先不看
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
//链表结构
for (int binCount = 0; ; ++binCount) {
//先是e = p.next
if ((e = p.next) == null) {
//p已经是最后一个节点了,在p后面加一个节点,e就不用指向
//新节点了
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//树化
treeifyBin(tab, hash);
break;
}
//e指向的这个节点,就是要替换的node
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//指向下一个节点,进行循环
p = e;
}
}
if (e != null) {
//说明key已经存在,所以替换这个key对应的node的值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
扩容:
final Node[] resize() {
//table就是现在的数组
Node[] oldTab = table;
//旧的容量和旧的阈值
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
//新的容量和新的阈值
int newCap, newThr = 0;
//原来数组不为空,是进行扩容的
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
//限制最大数组长度
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果新的容量是远容量的2倍且小于最大值就赋值新容量为旧容量double
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//这个是原来容量为0,新容量等于旧的阈值 不太会跑这里
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
//默认容量是16 阈值是16*0.75=12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 阈值等于新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//以新容量创建数组
Node[] newTab = (Node[])new Node[newCap];
//指向新数组
table = newTab;
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
//gc
oldTab[j] = null;
if (e.next == null)
//数组这个地方只存放了一个node
//使用node的hash值和新数组进行取余,进行存放
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//这里是一个树结构,正确做法是把树里元素取出来放到新
//数组里
((TreeNode)e).split(this, newTab, j, oldCap);
else {
//这里理解起来非常复杂,按照正常逻辑,这是一个链表,我直接遍历一遍取出每个node和新数组
//与运算得出新的index即可存放,但是这里却不用,有个优化。假如这里节点是A->B->C->D,这
//4个元素的hash值和旧数组长度n-1取余得到index相同,那和新数组长度-1的结果只会看最新一位是
//0还是1,如果是0,则新数组的index和旧数组一样,如果是1则在新数组的位置是index+旧数组长度
//也正是这个特性,所以把该点的链表分成俩个,存放到新数组里。
//这俩指针是下标不变的链表
Node loHead = null, loTail = null;
//这俩指针是下标要加旧数组长度的链表
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
//这个是0的话,就说明这个值的hashCode和数组长度与运算后和原来一样
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//新数组index和旧数组一样的链表
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//新数组index+旧数组长度
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
-
关于这里为什么要使用树,这里的原因也非常简单也就是链表遍历很慢,当链表很长时,则非常耗时,关于树的知识点暂时先不介绍,后面再说。
看到这里,我们也大概知道了HashMap的底层结构,是数组+链表+树来实现,这里来用几张图来加深一下印象:
(1)刚开始初始化,size=0,数组为空
(2)紧接着添加元素会发现数组为空,就会初始化出数组长度为10的空数组,这里集合真实长度size依旧为0
(3)插入了一个元素,元素用Node包裹,插在数组上
(4)继续插入一堆元素,暂时没有出现hash碰撞的情况,也就是多个key取完hash是一样的情况
简化一下图
(5)这时插入的一个对象出现hash碰撞,所以会出现在链表里
(6)继续添加很多元素,这时链表会越来越多
(7)当某个链表的长度大于8时,把链表转成红黑树
其实还有HashMap的删除键值对和修改键值对没有说,不过这个从上面增加键值对原理以及底层实现,就很好推理出来了,就是根据key,计算出hash值,找到这个Node的位置,进行处理。
遍历:
从前面的Map接口中,我们知道里面有3个集合,分别是key的Set集合,value的集合以及键值对的集合,所以我们也可以从这3个集合来遍历Map中的值,当然不管哪一种也都是不支持在遍历迭代器时在迭代器外部删除或者新增数据,会造成ConcurrentModificationException。
keySet的实现:
//如果不获取keySet的话,这个对象是不会创建的
public Set keySet() {
Set ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
//这里为了防止无用内存消耗,所以默认是不创建实例
return ks;
}
//键的Set集合
final class KeySet extends AbstractSet {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
//key的迭代器
public final Iterator iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
}
//key集合的迭代器,都必须继承至HashIterator
final class KeyIterator extends HashIterator
implements Iterator {
//调用nextNode方法
public final K next() { return nextNode().key; }
}
//为什么要继承至这个呢,原因很简单,就是为了遍历时,保证modCount,防止你
//遍历key时不使用迭代器删除元素
abstract class HashIterator {
Node next; // next entry to return
Node current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node[] t = table;
//首先current和next都是null
current = next = null;
index = 0;
//这里就是一个对数组的从头遍历,当t[index]等于null退出
//退出时next就指向数组上的一个不为空的node
if (t != null && size > 0) {
do {
} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
//这里也就是遍历Node
final Node nextNode() {
Node[] t;
//e就是下一个元素,如果是链表的话
Node e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
//取不出来该值
throw new NoSuchElementException();
//取出当前Node之后,就需要考虑next的Node了
//如果直接e.next为空,则说明e是没有链表结构或者e是链表尾部
if ((next = (current = e).next) == null && (t = table) != null) {
//这时就需要去遍历tab了,找到下一个Node赋值给next
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//删除必须使用迭代器
public final void remove() {
//略
}
}
从这里发现,遍历HashMap也是非常一个耗时的事,需要先从遍历数组开始,所以刚开始插入的键值对顺序和遍历的顺序就不会是一样的,也说明HashMap的插入键值对和取出遍历是无序的。
关于values和entry的迭代器也没必要说了,和这个key的集合一样,迭代器也是继承HashIterator。
最后看个小例子:
@Test
fun testHashMap(){
val testMap = HashMap()
testMap["1"] = "one"
testMap["2"] = "two"
testMap["3"] = "three"
testMap["4"] = "four"
val keyIterator = testMap.keys.iterator()
while (keyIterator.hasNext()){
if (keyIterator.next() == "2"){
keyIterator.remove()
}
}
for (value in testMap.values){
println("$value")
}
for ((key,value ) in testMap){
println("$key")
println("$value")
}
}
打印结果:
最后用一张图总结来完结HashMap
Hashtable
说完HashMap,然后第一个想到的就是Hashtable,说Hashtable是HashMap线程安全的版本,不过它俩的区别可不止这样,虽然不推荐使用,但是我们依旧可以看看其源码,分析一下不同点。吐槽一点,Hashtable居然不是驼峰命名法。
继承基类不一样:
Hashtable的父类居然是Dictionary,不过这个东西里的接口和AbstractMap一样的。
public class Hashtable
extends Dictionary
implements Map, Cloneable, java.io.Serializable
对null支持不同:
前面看HashMap源码时,在插入键值对时,我们注意到当key是null时直接其hash就是0,当value是null时会挨个判断,但是在Hashtable中,是不支持key或者value是null的。
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
...
}
线程安全:
Hashtable的所有方法都加了锁,这也导致了不论是访问速度还是写入都非常慢,如果想使用线程安全的Map推荐使用ConcurrentHashMap。
初始容量和扩容机制不一样:
//默认初始化
public Hashtable() {
this(11, 0.75f);
}
Hashtable的默认容量是11,而不是HashMap的16,这样设计是非常有道理的,再看一下扩容:
protected void rehash() {
int oldCapacity = table.length;
HashtableEntry[] oldMap = table;
//新集合容量是旧容量*2+1
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
return;
newCapacity = MAX_ARRAY_SIZE;
}
//新数组
HashtableEntry[] newMap = new HashtableEntry[newCapacity];
modCount++;
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
//首先遍历数组采用倒序
for (int i = oldCapacity ; i-- > 0 ;) {
for (HashtableEntry old =
(HashtableEntry)oldMap[i] ; old != null ; ) {
//该点可能是链表,遍历链表,取出所有entry加到新的数组中去
HashtableEntry e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (HashtableEntry)newMap[index];
newMap[index] = e;
}
}
}//
-
由这个扩容过程我们就知道了,Hashtable是不存在当链表长度超过临界值变为树的操作。
-
hash算法不一样,这个也是数组初始长度和HashMap不一样以及扩容不一样的主要原因,看一下Hashtable的hash算法:
//返回32位二进制的hash值 int hash = key.hashCode(); //直接和31位的1取与,让所有位数都参与到 //然后除以数组长度,得到一个分布在0-length-1的值 int index = (hash & 0x7FFFFFFF) % tab.length; HashtableEntry entry = (HashtableEntry)tab[index];
这里有个特别耗时的操作就是除法,所以Hashtable采用奇数或者素数的数组长度,可以让结果更加散列,链表的情况减少。HashMap使用的方式是不使用除法且数组长度为2的幂次方,所以链表会多一点,但是计算hash特别快,各有千秋。
最后用一张对比图来总结一下:
LinkedHashMap
说完HashMap紧接着说一下LinkedHashMap,首先这个类是继承HashMap的,底层保存数据方式和HashMap一样,只不过加了一个双向链表,来保证一个功能,也就是键值对加入时的顺序和遍历的顺序一样,看源码前,不妨看个小例子:
@Test
fun testLinkedHashMap(){
val testMap = HashMap()
//按顺序插入100个值
for (i in 0 .. 99){
testMap["$i"] = "$i value"
}
for ((key,value ) in testMap){
println("$key = $value")
}
}
使用HashMap的打印结果:
使用LinkedHashMap的打印结果:
当然这里的设计思路也非常简单,就是记录一下添加的顺序,然后遍历再按这个顺序取,那么如何做呢?
扩展Node:
static class LinkedHashMapEntry extends HashMap.Node {
LinkedHashMapEntry before, after;
LinkedHashMapEntry(int hash, K key, V value, Node next) {
super(hash, key, value, next);
}
}
在原来的Node上加一个前后指针,然后双向链表需要俩个指针来操作:
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMapEntry head;
/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMapEntry tail;
定义2个LinkedHashMapEntry类型的指针,那么接下来就可以处理逻辑了,按我们正常想法是重新put什么方法,但是一想在HashMap中都有了,所以在添加成功和删除等节点后会有个回调,在回调方法里进行处理即可。
插入成功回调中处理:
//HashMap中增加节点后会调用这个函数
void afterNodeAccess(Node e) {
//把Node封装一层
LinkedHashMapEntry last;
if (accessOrder && (last = tail) != e) {
LinkedHashMapEntry p =
(LinkedHashMapEntry)e, b = p.before, a = p.after;
//把节点link到链表尾部即可
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
其他删除节点,也是类似,在双向链表里删除即可。
遍历:
遍历时,就不能按照HashMap的遍历顺序了,需要按照链表的顺序即可。
abstract class LinkedHashIterator {
LinkedHashMapEntry next;
LinkedHashMapEntry current;
int expectedModCount;
LinkedHashIterator() {
next = head;
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMapEntry nextNode() {
LinkedHashMapEntry e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
public final void remove() {
Node p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
和预期一样,这里迭代器的nextNode方法是按照双向链表来执行的。
accessOrder:
这里还有一个参数在构造函数里,也就是accessOrder,啥意思呢,比如我这个Map是按顺序插入的,这时我重新插入或者get某个值,这时会认为是最新操作的,直接看个例子:
@Test
fun testLinkedHashMap(){
val testMap = LinkedHashMap(16,0.75f,true)
for (i in 0 .. 99){
testMap["$i"] = "$i value"
}
testMap["50"] = "500"
testMap["60"]
for ((key,value ) in testMap){
println("$key = $value")
}
}
- 这里使用3参数构造函数,在添加完100个数据,对key是50和60进行了操作,那打印会变成:
这里的原理也非常简单,当assessOrder是true时,每个操作会记录。
WeakHashMap
关于WeakHashMap的使用在Android中非常多,很多开源库都用这个来处理缓存,那具体有什么用呢,直接看个小例子就明白了:
//定义2个map,一个是HashMap一个是WeakHashMap
val hashMap = HashMap()
val weakHashMap = WeakHashMap()
//定义2个对象
var aTestDemo: TestDemo? = TestDemo("a")
var bTestDemo: TestDemo? = TestDemo("b")
//分别加入2个键值对
hashMap[aTestDemo!!] = "aaa"
hashMap[bTestDemo!!] = "bbb"
weakHashMap[aTestDemo] = "aaa"
weakHashMap[bTestDemo] = "bbb"
//hashMap移除一个a键值对
hashMap.remove(aTestDemo!!)
//赋值null,需要GC
aTestDemo = null
btnGC.setOnClickListener{
//手动GC
System.gc()
Thread.sleep(4000)
//肯定还有一个
for ((key, value) in hashMap) {
println("hashMap $key = $value")
}
//由于是WeakHashMap,所以也只有1个了
val iteratior = weakHashMap.entries.iterator()
while (iteratior.hasNext()){
val item = iteratior.next()
println("${item.key} ${item.value}")
}
}
打印结果:
- 在WeakHashMap中的key是弱引用,当这个引用是弱可达时,那WeakHashMap会自动删除这个键值对,就比如上面例子的aTestDemo,它虽然被HashMap移除,且置为null,但是WeakHashMap依旧可以指向它,按正常来说是不会被GC的,但是这里是弱引用,所以会被GC掉。
话不多说,直接来看看原理:
(1)首先来看一下什么是弱引用,在之前我们的理解弱引用是比软引用更弱的引用,当GC时发现这个对象只有弱引用,那么这个引用就可以被回收了。直接看一下源码定义:
//被弱引用修饰的变量,当GC时发现它只有弱引用时,则直接进行回收
public class WeakReference extends Reference {
//单参数构造函数,使用get获取referent
public WeakReference(T referent) {
super(referent);
}
//这里传入一个队列是什么意思呢,也就是当发现referent对象这时只有弱引用时,GC会把
//它回收,同时放入到队列q中,这时就知道哪些对象是弱引用被回收的了
public WeakReference(T referent, ReferenceQueue q) {
super(referent, q);
}
}
(2)知道了这个原理后,那么就能大概猜出原理了,把key当成弱引用,当key被只有弱引用时,会把这个key放到一个队列中,再取出队列中的key,和现有的HashMap做对比移除这个键值对,那我们来看看是不是这样做的:
//这里Entry进行了扩展
private static class Entry extends WeakReference implements Map.Entry {
V value;
final int hash;
Entry next;
//构造函数,这里传入一个queue,调用super(key,queue)完,这时当
//key是只有弱引用时,会把key放入到queue中
Entry(Object key, V value,
ReferenceQueue queue,
int hash, Entry next) {
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
...
}
(3)那么既然会有一个queue来存放弱可达的对象,那什么时候把Map中这个key对应的键值对给删了呢,当然不是实时删除,因为没有设置观察者模式,那就在每次获取值的时候给判断删除:
//根据key获取键值对
public V get(Object key) {
//这里要判断key是否为空,和HashMap一样,WeakHashMap也可以存放空key,但是为什么
//要当key是空时使用一个特定的key来替代呢,因为弱引用构造函数不能传入null
Object k = maskNull(key);
int h = hash(k);
//获取当前最新的数组,这里就有删除键值对的操作
Entry[] tab = getTable();
int index = indexFor(h, tab.length);
Entry e = tab[index];
//由这里可见没有使用树结构,就直接是链表
while (e != null) {
if (e.hash == h && eq(k, e.get()))
return e.value;
e = e.next;
}
return null;
}
private Entry[] getTable() {
expungeStaleEntries();
return table;
}
//删除不需要的键值对
private void expungeStaleEntries() {
//队列的poll函数,就是取出队列头的元素,注意这里不是阻塞的,当
//队列为空时,直接返回null,而不是等待机制,具体其他方法后面再说
for (Object x; (x = queue.poll()) != null; ) {
//加锁操作
synchronized (queue) {
Entry e = (Entry) x;
int i = indexFor(e.hash, table.length);
//在table中删除该键值对
Entry prev = table[i];
Entry p = prev;
while (p != null) {
Entry next = p.next;
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
// stale entries may be in use by a HashIterator
e.value = null; // Help GC
size--;
break;
}
prev = p;
p = next;
}
}
}
}
好了,除了get外,其他比如遍历、修改等操作都有类似的逻辑,在操作前处理弱引用key。
现在还是总结一下流程:
TreeMap
这部分需要红黑树相关知识,等后续再更新。