Java Map整理

290 阅读10分钟

概述

key-value键值对的集合
常用实现: HashMap, LinkedHashMap

HashMap

特性

无序,线程不安全
查询速度快,时间复杂度为O(1)
存储结构: 数组+链表(数组的元素由链表构成)
容量始终为2的n次方
extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable : 可被克隆,支持序列化

结合源码

问题1:为何、如何保证hashMap的容量始终要为2的n次方
问题2:put,get源码实现
问题3:何时如何扩容
问题4:如何计算元素所在数组索引位置,如何处理hash冲突,扩容后如何保证通过hash计算出的索引位置不会改变。

核心属性

transient Node<K,V>[] table : 存储数据即key-value键值对

The table, initialized on first use, and resized as necessary. When allocated, length is always a power of two.
(We also tolerate length zero in some operations to allow bootstrapping mechanics that are currently not needed.)
该表在首次使用时初始化,并调整为必要。分配时,长度始终是2的幂。
(在某些操作中,我们还允许长度为零,以允许目前不需要的引导机制。)

transient Set<Map.Entry<K,V>> entrySet

Holds cached entrySet(). Note that AbstractMap fields are used for keySet() and values().
保存缓存的entrySet()。请注意,使用AbstractMap字段用于keySet()和values()。

transient int size : 数量

The number of key-value mappings contained in this map.
此映射中包含的键-值映射数。

int threshold: 阈值:容量*负载系数

The next size value at which to resize (capacity * load factor).
要调整大小的下一个大小值(容量*负载系数)

final float loadFactor: 负载系数 默认0.75

The load factor for the hash table.
哈希表的负载因子。

tableSizeFor(int cap)

给定cap,返回>=cap的最小2的n次方的值
例如 cap=3 return 4, cap=8,return 8, cap=9, return 16

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;
    }

思路:
对n转化为2进制再进行考虑,如5的二进制为(8位)0000 0101 则>=5且为2的n次方的值为8(0000 1000)
那如何得到上边的结果呢:通过不断无符号右移使得当前最低位到非0的最高位皆为1,
最后+1即得到了我们想要的结果
至于为什么要先减1呢:原因是考虑cap本身为2的n次方的情况,应该返回cap本身,以n=8为例,0000 1000,
无符号右移按位或后得到0000 1111
加1后->0001 0000 = 16 ,不符合预期,而减1后 即n=7
7的8位二进制表示为0000 0111,无符号右移按位或后得到0000 0111,加1后0000 1000 = 8 为预期值

示例(32位)

n=5 -> 0000 0000 0000 0000 0000 0000 0000 0101
>>>1 -> 0000 0000 0000 0000 0000 0000 0000 0010
|= -> 0000 0000 0000 0000 0000 0000 0000 0111
>>>2 -> 0000 0000 0000 0000 0000 0000 0000 0001
|= -> 0000 0000 0000 0000 0000 0000 0000 0111
... n = 0000 0000 0000 0000 0000 0000 0000 0111
n+1 = 0000 0000 0000 0000 0000 0000 0000 1000
n+1 = 8

putVal

/**
     * Implements Map.put and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通过i=(n-1)&hash计算索引位置    
        if ((p = tab[i = (n - 1) & hash]) == null)
            //若当前索引位置无数据,直接赋值
            tab[i] = newNode(hash, key, value, null);
        else {
            //若当前索引位置已有数据p,即出现了冲突
            Node<K,V> e; K k;
            //判断当前位置数据p的key是否与k相同
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //判断p位置的数据是否为TreeNode类型(红黑数)  
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //遍历p位置链表的各个节点,若当前链表中尚不存在节点的key
            与传入的key相等,插入p链表末端;若存在,返回对应节点e    
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //插入新节点后,判断当前链表长度是否达到阈值,若达到,
                        变更数据结构为更方便查找的红黑树 TREEIFY_THRESHOLD=7
                        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;
                }
            }
            //若传入的key已经存在对应的节点e,重新赋值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;
        //若长度达到了阈值,进行扩容 
        值得注意的是此处的threshold并不是table数组的长度,resize方法中对该值进行了重新赋值
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

put操作的整体流程如下:

1、通过(n-1)&hash计算该key在table数组中的位置索引i
2、判断当前table是否为空,若为空,初始化table数组,并重新计算threshold
3、判断i位置处是否已存在数据,即是否存在冲突
4、若i处尚不存在数据,赋值table[i],size+1,判断size值是否超过threshold值,
若超过,resize()扩容
5、若i处已存在数据pp为链表的根节点),且p的key与传入的key相同(hash值相等且key值相等)
赋值e=p(e不为空,代表传入的key在当前集合中已存在,最后做修改操作)
6、若i处已存在数据p,且p为红黑树(链表长度超过指定阈值,优化为红黑树),
通过putTreeVal赋值
7、若i处已存在数据p,且皆不满足56,遍历链表P,若存在key对应的数据,赋值e,
若不存在,p链表尾部插入该节点,插入后,判断链表长度是否超过阈值,若超过,变更为红黑树
8、若i处已存在数据p,且e不为空(即key值已存在),做修改操作e.val=value,并返回e的旧值

通过i=(n-1)&hash计算索引位置(数据在数组tab中的位置)

由此可以解答问题1,为何要保证数组的长度始终要为2的n次方:
数组长度为2的n次方,意味着n-1后的二进制,低位的值全为1,
在与hash进行按位与操作时,hash低位的值来决定索引位置,
降低出现冲突(不同hash值得到了相同的索引位置)的概率。

我们来比较n为8与n为9的情况(hash1: 0100, hash2: 0101):
n=8:
n-1=7 对应的二进制为 0111
7&hash1 -> 0111 & 0100 -> 0100 -> 4
7&hash2 -> 0111 & 0101 -> 0101 -> 5

n=9:
n-1=8 对应的二进制为 1000
8&hash1 -> 1000 & 0100 -> 0000 -> 0
8&hash2 -> 1000 & 0101 -> 0000 -> 0\

再总结一下:按位与操作,对应位皆为1结果为1,否则为0; 而8的二进制1000,低3位皆为0,
导致不管hash值的低三位为啥,按位与后皆变成了0

resize()

扩容&threshold赋值

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //table不为空且长度大于0
        if (oldCap > 0) {
        	//超过最大容量,赋值threshold为int的最大值>table.length
            即后续不会再触发扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //若当前table扩容后(*2)未超最大容量限制,且当前长度大于默认容量16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //oldThr大于0 ,即初始化HashMap时给定了初始值,如new HashMap<>(4)
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //初始化时未给定初始化的情况,即new HashMap<>()    
        else {               // zero initial threshold signifies using defaults
            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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //table不为空,即需要扩容,遍历旧数据重新计算各数据在扩容后的数组中的位置
        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;
                            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);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

Get

看完了put方法后,再来看get方法后,清晰了很多

首先还是根据hash值跟table.length-1计算索引值,
然后通过进一步比较key的值来确定具体的值

问题解答

我们再次在对看源码时携带的问题做出解答

问题1:为何、如何保证hashMap的容量始终要为2的n次方

为何:通过容量-1与hash做按位与计算来计算索引值,始终保证容量为2的n次方可以使得
索引位的决定性放在随机性更大的hash值上,减少冲突的出现。
如何:通过tableSizeFor方法,在上边已经有详细的说明

问题2:put,get源码实现

见上方

问题3:何时如何扩容

通过比较size的值与threshold来判断是否需要扩容,threshold一般情况下为容量*loadFactor(默认为0.75)
例如:table.length=16, 则threshold=12

问题4:如何计算元素所在数组索引位置,如何处理hash冲突,扩容后如何保证通过hash计算出的索引位置不会改变。

keyHash&(table.length)=元素所在索引位置i

若存在冲突,使用链表存储冲突的元素,put和get时,
通过确认两者的key的hash值和值是否相等来判断是否为同一个元素。

跨容时,若table已存在数据,重新遍历已存在数据,重新计算索引位置放入扩容后的数组中。

附加问题:HashMap计算hash值的优化

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

我们知道元素的索引位置通过(table.length-1)&hash得到,
所以hash值的随机性决定了冲突出现的频率,而我们实际应用中table.length一般不会太大,
所以在与hash进行按位与运算时,hash的高位值就失去了干扰作用,
通过hash^(hash>>>16),使得hash的高位对hash的低位形成干扰,
从而使得hash值的从最低到最高皆会对索引的计算产生影响,
从而降低冲突出现的概率。
  

解密EntrySet

看过Map源码的同学,可能对entrySet都或多或少抱有一些疑问,这里以我最开始看源码的角度展开

疑问1:entrySet何时被初始化

解答:entrySet属性在entrySet()方法中被初始化,需要注意的是entrySet()会在HashMap的toString方法中被调用。

    transient Set<Map.Entry<K,V>> entrySet;
    以上为entrySet在HashMap中的定义,然而我们断点跟进new HashMap<>()的时候,奇怪的发现,entrySet已经被初始化了
    如下图

那么entrySet是何时初始化的呢
实际上我们debug模式下往往会忽略掉一个重点,debug模式下各对象是怎么呈现给我们的呢:
答案就是 toString(),我们debug进入new Hash()方法,idea debug呈现窗会给我们呈现this对象也就是当前的新建的HashMap对象,
即会调用HashMap的toString方法,我们再来看下HashMap toString()的实现:

如上两图所示
第一幅图为toString实现,HashMap未重写toString(),所以使用的为父类AbstractHashMap的实现
toString()中调用了entrySet()方法,即图二的实现,从而完成了HashMap的entrySet的初始化。
接下来,我们再对上述结论做下验证,去掉无关debug断点,在调用entrySet()中加入条件断点entrySet == null,
如下:

结合断点位置以及之前的结论,我们做下预测,在执行entrySet()之前,entrySet未被初始化,entrySet==null,会进入断点,
实际执行,进入断点,如图所示:

总结:

HashMap的entrySet属性,在调用entrySet()方法时才完成初始化的,
而debug模式下,因为HashMap的toString()中调用了entrySet()方法,给了一个最开始就初始化完成的错觉。

疑问2:entrySet数据是何时维护的

解答:HashMap的entrySet中并不维护实际的数据,只是提供了一个迭代器,数据依然取自table[], debug模式下看到的数据其实是entrySet迭代器给table的数据做了一个影射呈现给了我们而已。

从上两图HashMap的两种迭代方式的实现可以看到,迭代时数据取自table

LinkedHashMap

特性

LinkedHashMap<K,V> extends HashMap<K,V> implements Map

通过继承关系可得知,LinkedHashMap继承自HashMap

相比于HashMap,LinkedHashMap我们关注的相比于HashMap的特性为:有序性(遍历HashMap数据的顺序等于插入顺序)

LinkedHashMap在构建节点时,维护顺序

LinkedHashMap如何保证有序性

实现逻辑为:LinkedHashMap中维护一个头尾节点,且在每个节点中维护before、after节点,
put数据时,按插入顺序构建链表。

TreeMap

特性

有序(相比于LinkedHashMap的有序,此处的有序指的不是插入顺序,而是key值遵循规则保持有序,例如数字从小到大)
基于红红黑树实现
可以通过自定义比较器来控制元素顺序\