JAVA 集合类的认识(3)—— HashMap

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/AnselLyy/article/details/81094409

HashMap

AbstractMap

  • AbstractMap 是 Map 接口的的实现类之一,也是 HashMap, TreeMap, ConcurrentHashMap 等类的父类。
  • Abstract 默认是不支持添加操作的,实现类需要重写 put() 方法

AbstractMap 中的内部类

  • SimpleImmutableEntry, 表示一个不可变的键值对
  • SimpleEntry, 表示可变的键值对

HashMap 概述

HashMap 是一个采用哈希表实现的键值对集合,继承自 AbstractMap,实现了 Map 接口。
HashMap 的特殊存储结构使得在获取指定元素前需要经过哈希运算,得到目标元素在哈希表中的位置,然后再进行少量比较即可得到元素,这使得 HashMap 的查找效率极高。
当发生 哈希冲突(碰撞) 的时候,HashMap 采用 拉链法 进行解决,因此 HashMap 的底层实现是 数组+链表

HashMap 特点

  • 底层实现是 链表数组,JDK 8 后又加了 红黑树
  • 实现了 Map 全部的方法
  • key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法
  • 允许空键和空值(但空键只有一个,且放在第一位( Table[0] ),下面会介绍)
  • 元素是无序的,而且顺序会不定时改变
  • 插入、获取的时间复杂度基本是 O(1)(前提是有适当的哈希函数,让元素分布在均匀的位置)
  • 遍历整个 Map 需要的时间与 桶(数组) 的长度成正比(因此初始化时 HashMap 的容量不宜太大)
  • 两个关键因子:初始容量、加载因子

除了不允许 null 并且同步,Hashtable 几乎和他一样

HashMap 的 13 个成员变量

  1. 默认初始容量:16,必须是 2 的整数次方
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  2. 默认加载因子的大小:0.75,可不是随便的,结合时间和空间效率考虑得到的
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
  3. 最大容量: 2^ 30 次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
  4. 哈希表的加载因子
    final float loadFactor;
  5. 当前 HashMap 修改的次数,这个变量用来保证 fail-fast 机制
    transient int modCount;
  6. 阈值,下次需要扩容时的值,等于 容量*加载因子
    int threshold;
  7. 树形阈值:JDK 1.8 新增的,当使用 树 而不是列表来作为桶时使用。必须必 2 大
    static final int TREEIFY_THRESHOLD = 8;
  8. 非树形阈值:也是 1.8 新增的,扩容时分裂一个树形桶的阈值,要比 TREEIFY_THRESHOLD 小
    static final int UNTREEIFY_THRESHOLD = 6;
  9. 树形最小容量:桶可能是树的哈希表的最小容量。至少是 TREEIFY_THRESHOLD 的 4 倍,这样能避免扩容时的冲突
    static final int MIN_TREEIFY_CAPACITY = 64;
  10. 缓存的 键值对集合(另外两个视图:keySet 和 values 是在 AbstractMap 中声明的)
    transient Set<Map.Entry<K,V>> entrySet;
  11. 哈希表中的链表数组
    transient Node<K,V>[] table;
  12. 键值对的数量
    final float loadFactor;

HashMap 的初始容量和加载因子

由于 HashMap 扩容开销很大(需要创建新数组、重新哈希、分配等等),因此与扩容相关的两个因素

  • 容量:数组的数量
  • 加载因子:决定了 HashMap 中的元素占有多少比例时扩容

以上两个因素成为了 HashMap 最重要的部分之一,它们决定了 HashMap 什么时候扩容。
HashMap 的默认加载因子为 0.75,这是在时间、空间两方面均衡考虑下的结果:
- 加载因子太大的话发生冲突的可能就会大,查找的效率反而变低
- 太小的话频繁 rehash,导致性能降低

HashMap 中的链表节点

transient Node<K, V>[] table;

Node 实现

static class Node<K,V> implements Map.Entry<K,V> {
    // 哈希值,即位置
    final int hash;
    // 键
    final K key;
    // 值
    V value;
    // 指向下一个节点的指针
    Node<K,V> next;

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

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

HashMap 中的添加方法: put()

//添加指定的键值对到 Map 中,如果已经存在,就替换
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<K,V>[] tab; Node<K,V> p; int n, i;
    //如果当前 哈希表内容为空,新建,n 指向最后一个桶的位置,tab 为哈希表另一个引用
    //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 {
        //如果要插入的桶已经有元素,替换
        // e 指向被替换的元素
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
        //p 指向要插入的桶第一个 元素的位置,如果 p 的哈希值、键、值和要添加的一样,就停止找,e 指向 p
            e = p;
        else if (p instanceof TreeNode)
        //如果不一样,而且当前采用的还是 JDK 8 以后的树形节点,调用 putTreeVal 插入
            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,就要树形化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //如果找到要替换的节点,就停止,此时 e 已经指向要被替换的节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //存在要替换的节点
        if (e != null) {
            V oldValue = e.value;
            //替换,返回
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //如果超出阈值,就得扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

插入逻辑

  1. 先调用 hash() 方法计算哈希值
  2. 然后调用 putVal() 方法中根据哈希值进行相关操作
  3. 如果当前哈希表内容为空,新建一个哈希表
  4. 如果要插入的桶中没有元素,新建个节点并放进去
  5. 否则从桶中第一个元素开始查找哈希值对应位置
    1. 如果桶中第一个元素的哈希值和要添加的一样,替换,结束查找
    2. 如果第一个元素不一样,而且当前采用的还是 JDK 8 以后的树形节点,调用 putTreeVal() 进行插入
    3. 否则还是从传统的链表数组中查找、替换,结束查找
    4. 当这个桶内链表个数大于等于 8,就要调用 treeifyBin() 方法进行树形化
  6. 最后检查是否需要扩容

关键方法

  • hash(): 计算对应的位置
  • resize(): 扩容
  • putTreeVal(): 树形节点的插入
  • treeifyBin(): 树形化容器

HashMap 中的哈希函数: hash()

HashMap 中通过将传入键的 hashCode 进行无符号右移 16 位,然后进行按位异或,得到这个键的哈希值。

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

HashMap 中的初始化/扩容方法: resize()

每次添加时会比较当前元素个数和阈值:

if (++size > threshold)
    resize();

Tips

  • 新初始化哈希表时,容量为默认容量,阈值为: 容量*加载因子
  • 已有哈希表扩容时,容量、阈值均翻倍
  • 如果之前这个桶的节点类型是树,需要把新哈希表里当前桶也变成树形结构
  • 复制给新哈希表中需要重新索引(rehash),这里采用的计算方法是
    • e.hash & (newCap - 1),等价于 e.hash % newCap

HashMap 中的获取方法: get()

  • 先计算哈希值
  • 然后再用 (n - 1) & hash 计算出桶的位置
  • 在桶里的链表进行遍历查找

时间复杂度一般跟链表长度有关,因此哈希算法越好,元素分布越均匀,get() 方法就越快,不然遍历一条长链表,太慢了。

不过在 JDK 1.8 以后 HashMap 新增了红黑树节点,优化这种极端情况下的性能问题。

总结

  • HashMap 有个缺点:不是同步的。

当多线程并发访问一个哈希表时,需要在外部进行同步操作,否则会引发数据不同步问题。
你可以选择加锁,也可以考虑用 Collections.synchronizedMap 包一层,变成个线程安全的 Map

// 最好在初始化时就这么做。
Map m = Collections.synchronizedMap(new HashMap(...));
  • 红黑树优化

当 HashMap 中有大量的元素都存放到同一个桶中时,这时候哈希表里只有一个桶,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
针对这种情况,JDK 1.8 中引用了 红黑树(时间复杂度为 O(logn)) 优化这个问题。

  • HashMap 中 equals() 和 hashCode() 有什么作用?

HashMap 的添加、获取时需要通过 key 的 hashCode() 进行 hash(),然后计算下标 ( n-1 & hash),从而获得要找的同的位置。
当发生冲突(碰撞)时,利用 key.equals() 方法去链表或树中去查找对应的节点。

  • 你知道 hash 的实现吗?为什么要这样实现?

在 JDK 1.8 的实现中,是通过 hashCode() 的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16)
主要是从速度、功效、质量 来考虑的,这么做可以在桶的 n 比较小的时候,保证高低 bit 都参与到 hash 的计算中,同时位运算不会有太大的开销。

猜你喜欢

转载自blog.csdn.net/AnselLyy/article/details/81094409