2. HashMap总结

Map综述

Java为数据结构中的映射提供了一个接口java.util.Map,这个接口有四个常用的实现类:HashMapLinkedHashMapTreeMap以及HashTable,继承关系如下:

image

四个类的简单说明

HashMap

  • 根据键的hashCode值来存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但是遍历顺序却是不确定的
  • 最多允许一条记录的键(key)为null,允许多条记录的值(value)为null
  • 是线程不安全的,即任意时刻有多个线程可以同时操作HashMap,可能会导致数据不一致以及访问数据时的无限循环
  • 如果需要满足线程安全,可以使用Collections.synchronizedMap()来使HashMap成为线程安全的类,或者使用ConcurrentHashMap

HashTable

  • HashTable是遗留类(源自JDK1.0),与此相同的还有VectorStack,目前不推荐使用
  • HashTable的大部分功能与HashMap相似,不同的是HashTable还继承了Dictionary
  • HashTable不允许键和值为null
  • 是线程安全的,所有的公有方法均是同步方法,同一时间只能有一个线程访问HashTable,并发性不如ConcurrentHashMap。不需要线程安全的环境下可以使用HashMap替换,需要线程安全的环境可以使用ConcurrentHashMap替换

LinkedHashMap

  • LinkedHashMapHashMap的子类,底层使用双向链表来维护插入顺序
  • 在使用Iterator遍历LinkedHashMap时,默认情况下先得到的记录肯定是先插入的,也可以在构造时指定是使用LRU算法,将最近访问的元素移动到链表的尾部
  • 是线程不安全的,目前JUC包下没有对应的并发容器,可以采用Collections.synchronizedMap()来获取一个线程安全的LinkedHashMap

TreeMap

  • TreeMap实现了SortedMap接口,能够把它保存的记录根据键排序,默认是升序,也可以指定Comparator来进行比较
  • TreeMap构造时未指定比较器,则不允许键(key)为null。如果指定了比较器,则由比较器的实现决定
  • TreeMap是一个有序的key-value集合,通过红黑树实现的
  • 使用Iterator遍历TreeMap时,得到的记录是排过序的,与前面的三种Map一样,都是fail-fast(快速失败)的
  • TreeMap同样是线程不安全的,除了使用Collections.synchronizedMap()以外,还可以使用ConcurrentSkipListMap来代替

总结

对于上述四种Map接口的实现类,要求映射中的key是在创建以后它的哈希值不会改变,如果哈希值发生变化,则很有可能在Map中定位不到对应的位置

HashMap

类图

image

存储实现

从存储结构上来讲,HashMap采用了链地址法实现,数组+链表+红黑树,如下图所示

image

具体实现

从源码可知,HashMap中定义了

transient Node<K,V>[] table;

即哈希桶数组,而具体对象则是Node,下面是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;
    }
}

NodeHashMap的一个内部类,实现了Map.Entry接口,本身就是一个映射(键值对)

存储优势

HashMap使用的是哈希表来存储。哈希表为了解决冲突,可以采用开放地址法和链地址法等来解决问题。而在HashMap中采用的是链地址法,简单来说就是数组+链表的结合。在每个数组元素上都是一个链表,当数据插入时,通过哈希值计算出数据所在数组的下标,然后直接将数据置于对应链表的末尾

为了减少哈希冲突以及将哈希桶数组控制在较少的情况下,HashMap实现了一套比较好的Hash算法和扩容机制

Hash算法和扩容机制

HashMap的构造方法来看,主要是对以下几个字段进行初始化

transient Node<K,V>[] table;    // 哈希桶数组
int threshold;      // 当前哈希桶数组最多容纳的key-value键值对的个数
final float loadFactor; // 负载因子,可以知道负载因子在HashMap创建以后就不能被修改
transient int size;
transient int modCount;
  • 在初始化table时,可以通过构造函数指定初始容量,如果不指定则使用默认的的长度(16)。此时除了传入Map参数的构造函数以外,都不会进行Node数组的创建
  • loadFactor为负载因子,默认值为0.75
  • thresholdHashMap所能容纳的最多Node的个数,threshold = table.length * loadFactor,也就是说,在数组定义好长度以后,负载因子越大,所能容纳的键值对个数越多
  • size字段用于记录HashMap中实际存在的键值对数量
  • modCount用于记录HashMap内部结构变化的次数,主要用于迭代时的快速失败。需要注意的是,内部结构变化是指结构发生变化,例如新增一个结点、删除一个结点、改变哈希桶数组,但是在put()方法中,键对应的值被覆盖则不属于结构变化

具体分析

负载因子与threshold
  • 结合负载因子的计算公式可知,threshold是在此负载因子与当前数组对应下所允许的最大数目
  • Node结点的个数超过threshold时就需要resize(扩容),扩容后的容量是之前的两倍
  • 默认的负载因子值为0.75,这个值是对空间和时间效率的一个平衡选择,一般来说不需要修改
    • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子的值,这个值必须大于0
    • 如果内存空间紧张而对时间效率要求不高,可以增加负载因子的值,这个值可以大于1
哈希桶数组的长度
  • HashMap中,哈希桶数组table的长度必须为$ 2^n $(即一定为合数),这是一种非常规的设计
    • 常规的设计是把桶的个数设计为素数,相对来说素数导致冲突的概率小于合数
    • HashTable中,初始化桶的个数为11,这是桶个数设计为素数的应用(当然,HashTable不保证扩容以后还是素数)
  • HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化
  • 为了减少冲突,HashMap在定位哈希桶索引时,加入了高位参与运算的过程
红黑树的加入
  • 负载因子与Hash算法即使设计得再合理,也有可能出现链表过长的情况,一旦出现链表过长,则会严重影响HashMap的性能
  • 在JDK1.8中,引入了红黑树,在链表长度太长(默认超过8)时,会将链表转换为红黑树。利用红黑树插入、删除、查找都为$ logN $的时间复杂度来提升HashMap的性能

功能实现

主要从根据键获取哈希桶数组的索引位置、put()方法的详细执行以及如何扩容来分析

确定哈希桶数组的索引位置

在实现过程,HashMap采用了两步来键映射到对应的哈希桶数组的索引上

  1. 对键的哈希值进行再哈希,将键的哈希值的高位参与运算

    static final int hash(Object key) {
        int h;
        // h = key.hashCode() 第一步获取hashCode
        // h ^ (h >>> 16) 第二步将高位参与运算
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  2. 取模运算定位索引

    // n为哈希桶数组的长度
    // hash值为再哈希的结果
    // (table.length - 1) & hash
    p = tab[i = (n - 1) & hash]);

对于第一步:

  • 对于任意对象,只要它的hashCode()方法返回的哈希值一样,经过hash()这个方法,那么再哈希的结果都是一样的。
  • 在JDK1.8中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:h = key.hashCode()) ^ (h >>> 16,主要是从速度、功效、质量来考虑的,这么做可以在哈希桶数组较小时,也能保证高低位都参与到哈希值的计算中,同时不会有太大开销

对于第二步:
* 一般在计算哈希值对应的数组索引时,会采用取模运算,但是模运算的消耗是比较大
* 在HashMap中是通过(table.length - 1) & hash的方式来计算对应的数组索引。这是一个很巧妙的做法,前面提到过哈希桶数组的长度始终为$ 2^n $,在优化了操作速度以外,(table.length - 1) & hash这个运算等同于对table.length取模,即hash % table.length,但是&%运算效率更高

举例说明,n为哈希桶数组的长度

image

put() 方法的实现

image

  1. 判断哈希桶数组是否为null或者长度为0,否则执行resize()进行扩容
  2. 根据键值key计算得到的数组索引i,如果table[i] == null,直接新建结点进行添加,然后执行6。如果table[i] != null,继续执行3
  3. 判断table[i]的首个元素是否与key相等(指的hashCode、地址以及equals()),如果相等,直接覆盖value,然后返回旧值。否则继续执行4
  4. 判断table[i]是否为TreeNode,即table[i]是否是红黑树,如果是红黑树,则在树中插入新的结点,同时如果树中存在相应的key,也会直接覆盖value,然后返回旧值。否则继续执行5
  5. 遍历table[i],判断链表长度是否大于8,
    • 大于8的话会将链表转换为红黑树,在红黑树中执行插入操作
    • 否则进行链表的插入操作
    • 遍历过程中若发现key已经存在,直接覆盖value后,返回旧值即可
  6. 插入成功后,判断实际存在的键值对数是否超过了最大容量threshold,如果超过进行扩容

put() 方法源码

public V put(K key, V value) {
    // 对key的hashCode进行再哈希
    return putVal(hash(key), key, value, false, true);
}

/**
 * 插入时如果key已存在,对值进行替换,然后返回旧值
 * 如果onlyIfAbsent为true,则不会对已存在的值进行替换
 * 如果不存在,直接插入,然后返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. table为空或者长度为0则进行创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 计算key在哈希桶数组中的下标,如果这个位置没有节点则直接进行插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3. 如果key存在,直接覆盖value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 判断链表是否是红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 5. 该链是链表
        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;
                }
                // key 已存在直接覆盖
                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;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 6. 超过最大容量就进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

扩容机制

扩容resize()就是重新计算容量,并将结点重新置于哈希桶数组。Java中的数组是无法自动扩容的,方法是使用各新数组代替已有的容量小的数组,然后将结点重新放入哈希桶中

final Node<K,V>[] resize() {
    // 1. 记录扩容前的哈希桶数组
    Node<K,V>[] oldTab = table;
    // 2. 记录旧哈希桶数组的长度,如果为null就是0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 3. 记录旧的最大限制
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 4. 旧长度大于0
    if (oldCap > 0) {
        // 判断旧的长度是否已经达到最大的容量,
        // 如果是,则修改键值对的最大限制为Integer.MAX_VALUE
        // 并在以后的操作,都不在会扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新的数组长度为旧长度的两倍,同时threshold也会扩大两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 5. 此时是首次创建哈希桶数组,但是在创建HashMap时指定了键值对的最大限制
    // 那么哈希桶数组的长度为这个最大限制(这个值是 2^n)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 6. 此时是首次创建哈希桶数组,将容量与限制设置为默认值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 7. 如果新的限制是0,也就是还没有计算过
    // 则通过新的长度与负载因子来计算出
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 8. 将新的限制保存到threshold
    threshold = newThr;
    // 9. 创建新的哈希桶数组并赋值给table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 10. 旧的哈希桶数组里面存有结点,需要将结点置于新的哈希桶数组
    if (oldTab != null) {
        // 从头到尾遍历旧的哈希桶数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果是空的就跳过
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 这个索引位置只有一个结点,直接再hash后置于新的哈希桶数组
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 这个链是红黑树,对红黑树进行再hash
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 直接是一个单向链表,将单向链表的每个结点进行再hash存入新的哈希桶数组
                // 相比JDK1.7(1.7是会在扩容后将之前的链表倒置)会保留结点之前的顺序
                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;
                        }
                        // 旧的索引+oldCap
                        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;
                    }
                    // 将旧索引+oldCap放置新的哈希桶数组中
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

在JDK1.8中,HashMap的哈希桶数组长度为$ 2^n $(扩容是之前的2倍),所以元素的位置要么是在原位置,要么就是原位置+之前容量的位置

举个例子来说明,n是哈希桶数组的长度,

  • 图(a)表示扩容前的key1和key2两种key确定索引位置的示例
  • 图(b)表示扩容后的key1和key2两种key确定索引位置的实例
  • hash1是key1对应的哈希值与高位的运算结果(hash2同理)

image

元素在重新计算哈希值以后,因为哈希桶数组长度n变为2倍,那么n-1的mask范围在高位多1bit,因此新的索引变化就如下:

image

因此,在将已有的哈希桶结点放入新的哈希桶数组时,就不需要每个结点都重新计算hash,只需要看原来的hash值新增的1bit是1还是0,是0的话索引不变,是1的话索引就变成“旧索引+oldCap”

线程安全性

HashMap是线程不安全的,在多线程场景下使用HashMap可能造成无限循环而导致CPU使用100%

无限循环的出现是因为如果有多个线程在HashMap中插入新值,并同时触发了resize()对哈希桶数组进行扩容,在对同一条链的所有结点进行再hash分配到新的哈希桶数组的过程中,可能会使链上的某个结点指向自身前面的结点,而不是后面的结点,那么在后面的get()/put()操作中,对相应的链访问时就会出现无限循环

总结

  1. 扩容是一个很消耗性能的操作,所以在使用HashMap时,最好能估算一下大致的容量,避免HashMap频繁的扩容
  2. 负载因子是可以自己修改的,值可以大于1,但不能小于等于0。默认值0.75是一个权衡空间与时间效率的值,一般没有特殊需求最好不要轻易修改
  3. HashMap是线程不安全的,在并发环境中使用HashMap可能会出现数据不一致、数据丢失以及无限循环等问题,建议使用ConcurrentHashMap
  4. 红黑树的引入优化了HashMap的性能,同时扩容机制也相比JDK1.7更为优化

参考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章