Java集合-HashMap 详解

2021-12-30  本文已影响0人  栖风渡

Map

Map类图.png

java中的Map是一种可以存放键值对的数据集合,Map中的Key是不可重复的,同时一个Key只能对应一个 Value.

Map是用来替换Java中的Dictionary,

Map可以提供三个视图:

1. 将所有的Key返回为一个Set keySet()
1. 将所有的Value返回为一个Set valueSet()
1. 或者将Key-value返回为一个Set

像TreeMap这一类,可以保证元素的存放和获取顺序,但是HashMap并不能保证。

1. HashMap

HashMapMap的实现类,同时HashMap 允许使用null作为key,或者valueHashMap大致上跟HashTable是相同的(Hashtable是同步的,并且不允许null

HashMap是基于哈希表结构,其在没有hash冲突的情况下,进行添加,删除,查找等操作性能是很高的,只需要对指定位置进行一次从操作即可,其时间复杂度为 O(1),

在HashMap中,其主要的数据存储方式就是数组。 我们通过Hash算法,将当前元(Entry)的关键字通过某一个函数直接映射到数组中的某个位置,通过数组下标一次定位就可以完成操作。

在HashMap中,我们将上面题导的映射函数称之为 哈希函数,哈希函数的设计,决定了Hash冲突的次数,也就决定了当前HashMap的性能。

HashMap的基本操作例如 get,put 所需要的时间是固定的,HashMap的Iterator方法跟当前HashMap的容量成正比。 因此如果你想保证迭代器的性能,那么就不能将HashMap的初始容量设置的太大。

影响HashMap的关键因素:

1. **initial capacity**     
1.   **loadFactor**    (初始值和**loadfactory**共同决定了当前**hashMap**的扩容次数)
1. **key**的**hash**算法      (如果**Key**的**hash**值重复较多,那么也可以直接降低当前**hashmap**的性能)

1.1 HashMap基本原理

假设我们需要存入两个 <Key ->Value> 元素

A: <Chen -> henan>

B: <Wang -> shandong>

固定哈希算法为 函数f(x), indexA = f(Chen), indexB = f(Wang)

这样我们得到了A,B两个元素的数组角标,这样就把相应的Entry放入对应数组位置就可以,用图表示可以为:

Map-hash.png

1.2hash冲突

上面说到的hash函数,仅仅是指 将元素的Key转换成 index的算法,有时候我们并不能保证我们使用的hash算法能够保证 不同的键值对元素对应不同的 数组index,这样就有可能出现 hash(Chen) == hash(Wang)的情况,这就是我们说的hash冲突。

通常情况下解决hash冲突的方法有很多种,例如:开放定址算法(发生冲突,继续寻找下一块未被使用的地址),再散列算法,链地址法,在HashMap中,设计者使用了链地址法,也就是对于冲突的元素,使用链表进行存储

2 HashMap的实现

对于HashMap 如何存储键值对数据的呢?

HashMap在内存中是基于数组形式实现的:

    transient Node<K,V>[] table;   // 内部使用一个数组存储键值对元素

键值对元素的存储格式, 使用Node对键值对进行包装:

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; // Node包含当前键值对的hash值
        final K key; //key值
        V value; //value值
        Node<K,V> next; //下一个节点的Node, 当出现hash冲突时使用

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
 }

可以推出,一个HashMap的基本形式如下:

HashMap-结构.png

当链表数量超过8:

HashMap-树结构.png

对于 index0、index3、index7出现了hash碰撞,所以,这个节点存储的node就形成了一个单链表的形式。

如果通过hash算法定位到的数组位置 没有链表,那么 删除,替换,添加等操作的时间复杂度都是 O(1)

如果定位到的数组位置有hash冲突,那么这些操作的时间复杂度就为 O(n), n = 链表长度

3 HashMap源码分析

下面我们就从HashMap的一些基本操作代码入手,来探究下 HashMap的实现原理。

3.1 构造方法

HashMap的两个关键构造因子:

initial compacity 初始化容量, 这个参数 决定了当前HashMap可以拥有多少个key-value 实体

loadFactor: 这个值 决定了当前HashMap的 装填程度, 如果当前 容量超过 capacity loadFactor,那么就表示当前HashMap需要进行一次重新扩容,同时需要重新hash*。

因此,如果想要保证当前HashMap的性能, 适当的Map大小以及加载因子是关键。

另一个影响HashMap性能的关键就是 Keyhash值,如果有大量Key的hash值是重复的,那么当前HashMap的性能也会降低。

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor; // hashMap的加载因子,简单的说就是 hashMap可以进行扩容时的容量占比
        this.threshold = tableSizeFor(initialCapacity); //对于给定的容量,hashTable都转换为 相应的2^n.
    }

3.2 HashMap.put

  public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

关于 hash(key)算法解释参见:

HashMap的hash() - Black_Knight - 博客园 (cnblogs.com)

HashMap中的hash函数 - 淡腾的枫 - 博客园 (cnblogs.com)

可以看到 HashMap.hash确实在兼容性能的基础上做到了尽量减少hash碰撞。


3.2.1 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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length; // 参见 3.2.2  创建一个长度为16的数组
        if ((p = tab[i = (n - 1) & hash]) == null) //如果 通过hash值找到的位置没有存放,那么直接创建新的node,并将值放入。
            tab[i] = newNode(hash, key, value, null);
        else { //以下就是处理hash冲突的步骤了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p; //如果指定hash位置已经存放了Node,并且key的值 相等,那么就直接进行替换
            else if (p instanceof TreeNode) //如果指定结点已经变成了 树,说明这里冲突太多,执行树图的存放操作,数的操作参见 # 4.1.1
                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);
                        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)))) //编辑寻找当前Key的Node,找到就跳出。
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for 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;
    }

3.2.2 数组的初始化方法:

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //第一次调用的话, table为null,
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            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
            newCap = DEFAULT_INITIAL_CAPACITY; //第一次初始化,默认的容量就是16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //第一次初始化,扩容阈值就是 16*0.75
        }
        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]; //第一次初始化(不设置容量的情况下),这里就创建一个长度为16的数组
        table = newTab;
        if (oldTab != null) { //第一次,这里不会走
           。。。
        }
        return newTab;
    }

3.3.3 链表长度太长,链表将会变成树

    static final int TREEIFY_THRESHOLD = 8; // 默认链表最长的长度为8

判断是否满足将当前链表变成树的条件:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)// 如果数组为空,或者当前数组长度小于 默认长度64,那么就直接进行扩容
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) { //指定 hash位置的结点存在
            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);//这里的操作就是关键一部了, 将链表变成标准的树结构
        }
    }

将链表变成树结构:

有关红黑树的介绍:

【老实李】JDK1.8中HashMap的红黑树 - 简书 (jianshu.com) //这个只是说明白了一小部分

解读HashMap中的红黑树操作 - 知乎 (zhihu.com) // 这个讲的比较深入。

         final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;
            for (TreeNode<K,V> x = this, next; x != null; x = next) { //开始遍历并且格式化之前创建的 树结构
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null; //首先将当前树的左右二叉树置为空
                if (root == null) { //第一次进行的时候,这里就将第一个作为当前树的跟。
                    x.parent = null;
                    x.red = false; //红黑树根节点必须是黑的
                    root = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    for (TreeNode<K,V> p = root;;) {
                        int dir, ph;
                        K pk = p.key;
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);

                        TreeNode<K,V> xp = p;
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            root = balanceInsertion(root, x);
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root);
        }

以上,分析了HashMap的插入方法,

  1. 第一次存放数据的时候,首先创建一个数组,(默认数组长度为16, 默认加载因子为0.75)
  2. HashMap通过特殊的hash算法尽可能的减少Hash碰撞。 // keyhash值得前16位和16位异或,然后取与当前容量,就是当前节点得index值
  3. 如果出现hash碰撞,那么就将相同 index位置变成一条链表
  4. 如果链表长度较长(>=8),并且当前hashMap得容量超过 64,那么就需要将当前链表变成一个红黑树结构,同时又由于红黑树得自平衡性,可以保证查找删除等操作得时间复杂度在 O(logn)

3.3 HashMap.remove()

    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) { //tab不为空并且数组长度>0,
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;//找到节点
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);//找到树结构得节点
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;//找到链表结构的节点
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //树节点需要特殊处理
                else if (node == p)
                    tab[index] = node.next;//如果是链表的第一个,那么就直接移除
                else
                    p.next = node.next;//如果是链表中间的一个,那么就删除中间的
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

总结

HashMap在进行数据存储的时候使用了尽可能减少碰撞的hash算法,同时 使用了 数组、链表、红黑树的数据结构,尽可能的将性能和空间进行平衡,这也体现了源码工程师的智慧

上一篇下一篇

猜你喜欢

热点阅读