4. Map不仅仅是"映射"

535 阅读13分钟

Map既有映射的意思,也有地图的意思.所蕴含的知识恰如一张‘地图’.

1. Map

public interface Map<K,V> {

Map恰如其名'映射',将一个key映射到value,又恰如其名'地图',包含多组映射的对象.

以下是官方的Doc注释(节选): 一个Map对象不能包含重复的key,一个key最多只能映射一个value,这个类代替了Dictionary类,Map的顺序为迭代器返回的顺序,有的实现对返回的顺序做出了保证,而有的则没有.对于keyvalue是否为空,也需要看具体的实现.

Dictionary是一个和Map接口类似的完全抽象类,用Map代替该类也很简单,其一是因为抽象类的原因,Java类本身是单继承的关系,如果继承了该抽象类,就不能继承其他类了.另一个原因就是因为他的类里面全是抽象方法,定义为一个接口更加合适.

Map里面还有一个比较重要的就是内部接口Entry,他所对应就是一个key-value映射对象.

2.HashMap

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 
{

1. 概述

一提到Map,相比大家自然想到的是HashMap,在平时用得比较多的也是HashMap,面试中问的最多的也是HashMap,同时在其他Map实现中,和HashMap相关的也很多,所以我们就先来看看HashMap.

HashMap的结构贴在上面了,根据前面的文章,不难发现,AbstractMap是对Map的基本实现,用来解决实现Map接口的工作量.这种设计可以说是不错的,如果你自己想实现的Map和默认默认实现相差无几,你可以继承该抽象类然后重写指定的方法便可.

这里节省篇幅AbstractMap就不贴了,具体的实现大家可以自行查看.而HashMpa实现的其他接口在前面的文章中也讲到了,也不多提了.HashMap是一个基于'哈希表'实现的Map结构,有关'哈希表的内容,大家自行查阅.

接下来先看看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;
// 转为树的链表长度阈值
static final int TREEIFY_THRESHOLD = 8;
// 取消树化的链表长度阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 树化最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// Node实现了Map.Entry接口的对象,真正的数据存储的地方
transient Node<K,V>[] table;
// Entry集合
transient Set<Map.Entry<K,V>> entrySet;
// 数量个数
transient int size;
// 用于快速失败
transient int modCount;
// 初始容量  负载因子*总容量
int threshold;
// 加载因子
final float loadFactor;

2.方法

构造方法就不提,都是一看就懂的.但是在构造方法里面有一个比较特殊的方法:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这个方法的作用就是把计算大于或等于cap的第一个2的n次幂:

// 假设参数为100
n = 99 = 0110 0011;
---- 移位操作 >>>1 ----
n = 49 = 0011 0001
----- 或操作! -----
n = 115 = 01110011

----- 移位操作 >>>2 ----
n = 28 = 0001 1100
----- 或操作! -----
n = 127 = 0111 1111

----- 移位操作 >>>4 ----
n = 7 = 0000 0111
----- 或操作! -----
n = 127 = 0111 1111
.....

// 一系列步骤执行到最后
n = 127 = 0111 1111
n = n+1 = 128 = 1000 0000

上述一些列操作刚好得到了大于或等于cap的第一个2的n次幂,至于开头减一的原因就是为了解决自身是2的n次幂的情况:自身是2的n次幂时会多乘一个2.至于为什么容量一定要是2的n次幂,后续再提(1).

hash()

构造完了,那肯定是添加了,但是在添加之前,使用到了另一个关键的函数hash()

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算hash值的公式为: keyhashCode低16位和高16位进行异或.为什么拿到hashCode后还要进行^>>>操作?后续解答(2).

put()

代码较长,部分解释直接写在里面了.

// hash值,key值,value值,是否替换原有值 true不改变,是否支持重建,默认允许,就是删除旧值.
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 初始化table table就是真正存的数据的地方,resize方法贴下面了
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 该方法看下面解释
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 指定位置没有值,直接创建新节点插入.
        tab[i] = newNode(hash, key, value, null);
    else {// 发生碰撞
        Node<K,V> e; K k;
        // 判断hash值和key值是否相同,相同直接将原来的替换
        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) {
                // 
            	if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度大于等于8 则变成链表,binCount从0开始...
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 替换节点的操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 用于LinkedHashMap回调
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 判断是否需要扩容
    if (++size > threshold)
        resize();
    // LinkedHashMap回调
    afterNodeInsertion(evict);
    return null;
}

tab[i = (n - 1) & hash]这个操作就是获取指定位置的元素,n是哈希表的长度,默认16,通过前面的方法我们知道,哈希表的长度永远都是2的n次幂,而2的n次幂有什么一个特点,除最高位外,其余位都是0,而这个减1操作,直接就总位数减1,同时值全为1,这样就变成了一个掩码操作:

假设hash值为 11101010
默认长度16: 16 - 1 = 15 = 1111
1110 1010
		 & = 1010 = 10 得出下标等于10
0000 1111

问题解释(1): 长度一定要是2的n次幂的原因就是: 利用2的n次幂二进制数的特殊性,便于形成掩码计算位置.

问题解释(2): 从上述看得出,如果直接用hashCodehash值,那么要进行大量的计算(int范围大,重复取余之类的),而进行异或和位移的原因也很简单: 上述元素定位,只针对最后几位为1的,和高位无关,容易产生大量的碰撞.而异或操作就是加大最后几位的随机性.

resize()

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 这个if就是扩容判断. 其他else就是处理各种初始化情况
    if (oldCap > 0) {
        // 最大值直接返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 扩容为原来的2倍,阈值也为原来的2被
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
        // zero initial threshold signifies using defaults
        // 就是默认初始化值16
        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);
    }
    // 初始化 不进入下面的if 直接返回
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 重新定位元素
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 旧值重新计算在新哈希表的位置.
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order  处理链表的情况,同时还要保证链表顺序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 尾插法 
                        // 最高位为0的情况 说明你不需要改变,保持原状就好
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 最高位为1的情况 说明你在新容量的位置已经发生了改变.
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);

                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
             }
        }
    }
    return newTab;
}

为0不需要改变,而为1的就需要改变位置的原因:

// 旧容量扩容就是添加了一位0位:
16 = 10000
32 = 100000

假设key.hashCode = 101010

旧计算下标:
10000 - 1 = 1111 & 101010 = 1010 = 10
新的计算下标
100000 - 1 = 11111 & 101010 = 1010 = 10

位置还是一样的 则不需要进行换位置, newTab[j] = loHead 作用显而易见 

假设key.hashCode = 111010
旧容量计算位置:
10000 - 1 = 1111 & 111010 = 1010 = 10
新容量计算位置:
100000 - 1 = 11111 & 111010 = 11010 = 26

新元素的位置就是在高位多了1,也就是加上了旧的容量.newTab[j + oldCap] = hiHead 作用显而易见

总结一下上面的扩容: 其实容量扩容后,所形成的掩码的长度多了一位,(e.hash & oldCap)而这个操作刚好就是判断原值多出的一位是否为1,旧容量的最高位就是1.是否需要移动位置也取决于该位,其他位的结果都不变.

不得不说,这个2的n次幂数选得真对,太细节了!

3.杂谈

新增相关的就差不多了,关于树化的有兴趣的可以去看看.其实HashMap写到这里,理解了上面这些核心方法,阅读其他部分并没有什么太大的难度.其他方法留给读者自行查看(写累了....).

还有一些老生常谈的问题: 为什么负载因子是0.75?

这个问题也很简单,先想想如果没有负载因子或者负载因子过大会怎么样,如果没有,也就是在容量快慢时才进行扩容,但是我们知道HashMap时可以形成链表的,如果你的hashCode()没有设计好就存在这种情况,就占了两个坑,但是实际数据非常多的情况.这时候有了负载因子就可以进行定位,使得分布均匀一些.而负载因子设置得过大也存在上面的情况.过小就容易频繁resize()导致性能不佳. 总结: 为了更好的性能.

注意,HashMapput()方法是有返回值的,返回的是该key的旧值,如果没有,则为null,不过该null也可能是返回的旧值为null.显然null在此处存在二义性,这也就是为什么ConcurrentHashMap中不允许为空的原因.

后面的类可能会水起来了....

3.HashTable

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

1. 概述

HashTable这一''古老''的类,原本不想讲的,出于''惯例'',还是拿出来提一手(鞭尸).但是具体细节不多概述,就提一些比较关键的点.也可以说是和HashMap进行一个比较吧.

首先,HashTablekeyvalue都不能为空,扩容的话是原来的两倍加一.容量也没有像HashMap那样做2的n次幂限制.再者也没有树化节点.但是,他是线程安全的,在不是那么高要求的并发下,可以考虑使用它.

他的获取操作也加上了synchronized.

至此,了结.

4.ConcurrentHashMap

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {

1. 概述

HashMap再多线程下存在问题,诸如数据错乱之类的,这也都是熟知的.关于ConcurrentHashMap的一些特性不对赘述,网上也很多相关文章.

如果像上面这样写ConcurrentHashMap,那真的太过庞大,毕竟涉及到的知识点太多.这里就不多写了(主要是懒),写点总览,以后有时间再补上(很蓝的吧).

ConcurrentHashMap使用了CAS和分段锁保证并发安全性.只锁该节点所在的链表:synchronized (f) ,就是哈希表的具体元素,其他不受影响,变量使用了volatile保证了内存可见性,同时禁止指令重排序.总体变是这种情况,但其中细节很多.

5.LinkedHashMap

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>
{

1. 概述

LinkedHashMap提供了有序的迭代遍历(HashMapConcurrentHashMap无序),他再内部使用了一个双向链表维护顺序. 从上面的类定义信息来看,该类和HashMap相差无几,但是性能相比略弱,这是因为维护链表所带来的开销,但是在迭代方面,性能比HashMpa要好.

2.方法

LinkedHashMap方法没什么好讲的,大部分都是回调钩子方法,用于维护链表.

// 用于使用put或get后移动指定元素到尾节点位置。该节点最近使用了
// 该方法依赖accessOrder,默认为false也就是默认不移动,如果想实现lru可以改为true
void afterNodeAccess(Node<K,V> p) { }
// 添加节点后是否需要对链表进行操作 依赖removeEldestEntry(first) 默认为false。删除头节点判断.
void afterNodeInsertion(boolean evict) { }
// 删除头节点后调整链表
void afterNodeRemoval(Node<K,V> p) { }

LinkedHashMap继承了HashMap,实现了钩子方法,这些钩子方法在HashMap中都有调用:

if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
        afterNodeAccess(e);
        return oldValue;
}

像一些对key进行操作的地方,如果改key是已经存在的,就会使用afterNodeAccess(),LinkedHashMap实现如下:

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        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;
    }
}

该方法就是把该节点在LinkedList链表中移动最后,默认是不执行的,accessOrder默认初始化为false,移动至法仅在key存在的情况下,不存在时不会调用该方法的. 如果不想要对最近使用过的key进行删除,就需要在初始化时指定为true,这样下面删除的时候就不会删除该映射.

afterNodeInsertion(boolean evict)

这个方法是针对添加一个节点后,对LinkedHashMap中的链表进行何种操作:

void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

evict默认为true上面写HashMap的时候提到过,而removeEldestEntry(first)就是实现LRU的关键方法,默认是false也就是不执行删除头节点操作.

afterNodeRemoval

void afterNodeRemoval(Node<K,V> e) { // unlink
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.before = p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a == null)
            tail = b;
        else
            a.before = b;
    }

这个方法很简单,就是对删除的节点的前后节点进行维护.

3. 杂谈

LinkedHashMap难点不多,就是有点饶,方法跳来跳去的,容易晕,慢慢看还是挺好接受的.像一些老生常谈的实现LRU等你理解了就是基本操作了,同时你还可以利用HashMap的钩子方法自己去做点什么,而不是使用LinkedHashMap.

因为是继承HashMap,keyvalue自然都允许为空.主要还是在维护链表.

他与TreeMap的不同就是免受比较器的困扰,而没有TreeMap的成本开销,但是这也是一个坏处,就是不能根据排序器来指定顺序.TreeMap是基于红黑树实现的有序map.

TreeMap就留个各位自行查看了(写不动了...),哪天心情好补上!!!