HashMap

Collection 系列文章的总目录:

数据结构:

经典哈希表的实现:哈希桶 + 链表

所有操作的平均时间复杂度:O(1)

哈希表在 Java 中十分重要,以至于在 Object 中放入了 hashCode() 方法

首先要明确:

  • Entry[]:这是哈希表
  • Entry[0]:这是哈希表中的 0 号桶

hashCode():

回顾一下 hashCode() 的三个约定:

  • 在同一个 JVM 周期,无论对同一对象调用多少次 hashCode,返回的 int 都应该一样,前提是不修改 equals 方法中使用到的信息。
  • 若 x.equals(y) == true,则 x.hashCode() == y.hashCode()
  • 若 x.equals(y) == false,则 x.hashCode() 与 y.hashCode() 没有强制要求不相等。
    • 但程序员应该意识到,对于 unequals 的两个对象,hashCode 返回不同的值可以提高 hash table 的性能

要把无穷多个对象,映射到一个 int 值上,那么必然会有一样的值。

所以我们在写 hashCode() 的时候,要尽可能地让它分布均匀,以减少碰撞,提高性能

Java7 时代的 HashMap

HashMap 和大部分集合一样,只有在第一次放入元素的时候才会初始化空间,以节省内存。

// 哈希表的默认初始大小/容量,必须是2的幂次方。1 << 4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量,1 << 30 = 2^30
// int是32位,所以1最多只能向左移动31位,最左边那一位是符号位,所以只移动30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子:0.75 = 3/4
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 负载因子
final float loadFactor;

// 阈值:当元素个数大于等于阈值,并且要插入的元素对应桶的位置不为空,才会触发扩容
// threshold = capacity * loadFactor。
int threshold;

当插入的 key 为 null:

  • 会被插入到 0 号桶
  • 先遍历 0 号桶的元素,如果找到则替换,没有则插入
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

哈希算法:

  • 首先调用 hash() 变换 hashCode,以减少 hash 冲突
    • 如果 key 是字符串,会用特殊的 hash 算法,来减少冲突
  • 然后调用 indexFor(),得到对应桶的下标
int hash = hash(key);
int i = indexFor(hash, table.length);

indexFor() :

static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

这里 length 是 2 的幂次方,假设为 16

length - 1 = 0111 = 15

然后将变换后的 hash 按位与:010101001101 & 0111 = 0101

与 1 与会保留,与 0 与会变 0,所以 hash 会保留最后三位,即会得到一个 0 ~ 15 得值

那么就可以对应到表的第 0 ~ 15 的位置了。

得到桶的下标后,就开始遍历链表了,如果存在则替换,不存在则先检查容量扩容,再使用头插法插入

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 将原来桶的链表e连接到新节点的后面,然后将新节点放到桶里
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

扩容:

resize():

  • 先计算新容量,固定旧容量的两倍
    • resize(2 * table.length)
  • 然后创建新的 Entry[] 数组:
    • new Entry[newCapacity]
  • 然后重新计算所有元素的下标,放到新的哈希表中:
    • transfer(newTable, initHashSeedAsNeeded(newCapacity))

重新计算下标:

假设:oldLen = 16 = 1000, newLen = 32 = 10000

oldLen - 1 = 00111,newLen - 1 = 01111

旧的 index:010101001101 & 00111 = 00101

新的 index:010101001101 & 01111 = 01101

由此看出,对于同一个桶下标的元素,在容量变成两倍之后

要么会被放到原来下标 n 的位置,要么会被放到 n + newLen/2 下标的位置

比如 0 号桶的元素,扩容后,要么被放到 0 号桶,要么被放到 16 号桶

问题:

1、多线程下的死锁:

A:当一个线程在 put,而另一个线程在 resize 的时候,由于 resize 的时候,会将同一个桶中的链表反序复制,多线程下就有可能会形成一个环形链表。那么在 get 的时候,会进入死循环。

此时通过 jstack 可以看到有线程会卡在 HashMap 的 get 操作上死循环。

解决:使用 ConcurrentHashMap 或者锁,因为 HashMap 本身就不是线程安全的。

2、碰撞导致退化成链表:

A:CVE-2011-4858:Tomcat 的一个漏洞。Tomcat 内部是使用哈希表存储请求参数的,黑客可以使用精心构造的参数,使得产生大量 hash 碰撞,参数都被放到了一个桶里面,此时哈希表就退化成链表,CPU 被大量消耗在链表查找中。

Tomcat 的临时解决方案:限制参数个数

Java8 时代的 HashMap

在 Java8 中,如果一个桶里面的元素数量太多时,它们就会从链表转换成红黑树。类似于 TreeMap

Java8 中 HashMap 的部分 JavaDoc:

This map usually acts as a binned (bucketed) hash table, but when bins get too large, they are transformed into bins of TreeNodes, each structured similarly to those in java.util.TreeMap.

新的属性:

// 如果当前桶中的元素个数大于这个值,就会从链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;

// 在resize的时候,如果当前桶中的元素个数小于这个值,会从红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;

添加元素:

putVal():

  • 如果是第一次,初始化容量
  • 如果对应的桶为空,直接创建普通节点放入
  • 如果桶不为空
    • 如果第一个元素即是,则直接赋值给e
    • 如果节点是 TreeNode,调用 putTreeVal,返回值给 e
    • 如果是普通 Node,则遍历链表
      • 如果找到对应的节点,赋值给 e,然后 break
      • 如果到了链表尾部,则创建并加入新节点
        • 然后检查是否超过转换成红黑树的阈值 8,是则转换成红黑树
    • 如果 e 不为空,则替换 value,并返回旧的值
  • ++size,并判断是否超过阈值 threshold
    • 如果是,则resize()
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;
    // 如果桶为空,直接创建一个节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果桶的第一个元素的key一样,直接赋值给e
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            // 如果是红黑树节点,调用对应的put方法
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 如果是链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // 如果到了链表尾部,则创建新的节点,此时e为null
                    p.next = newNode(hash, key, value, null);
                    // 当前桶中的节点数量:binCount + 2
                    // 等效于:binCount > TREEIFY_THRESHOLD
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 转换成红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到对应的key,break
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果e不为空,表示已存在对应的Key,则替换它的value
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent:只有不存在这个元素或者为null的时候才put
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 回调函数
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 如果超过阈值,则扩容
    if (++size > threshold)
        resize();
    // 回调函数
    afterNodeInsertion(evict);
    return null;
}

哈希算法:

Java8 的 hash():

static final int hash(Object key) {
    int h;
    // 高16位保留。将高16位和低16位异或,放在低16位。
    // (异或:不同为1,相同为0。和0异或等于保留,和1异或等于取反)
    // 防止某些高位不同,而低位相同的 hashCode 在 indexFor 时产生碰撞
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扩容:

resize():

  • 根据是否第一次初始化等条件,计算新容量和新阈值
    • 如果是普通扩容,新容量为旧容量的2倍,新阈值为旧阈值的两倍
  • 使用新的容量,创建新的 table
  • 遍历旧 table ,得到每个桶
    • 拿到桶的第一个 Node,并清除旧桶
    • 如果桶只有一个 Node,直接计算新下标,移动到新的 table
    • 如果是 TreeNode,调用 split
    • 如果是普通 Node,即链表
      • 保持链表的原始顺序,将链表拆成 2 链表:低位链表和高位链表
      • 将低位链表放到新 table 中旧 index 的桶
      • 将高位链表放到新 table 中旧 index + oldCap 的桶
  • 返回新的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;
    if (oldCap > 0) {
        // 边界值处理
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新容量为旧容量的2倍(oldCap << 1),由于负载因子不变,所以阈值Threashold也是原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 如果阈值大于0,使用阈值作为初始容量的值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 如果容量等于0并且阈值等于0,则容量取默认容量,阈值=负载因子*容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 确保新的阈值有值
    // 如果旧容量>0,但newThr==0,则计算新的阈值=负载因子*容量
    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"})
    // 使用新的容量,创建新的table(Node[])
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 遍历旧的table
        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
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 如果是普通Node,即链表
                    // 复制到新table,保留链表原始顺序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 如果计算出的新位置还是和旧的一样,则放到低位链表:loHeah和loTail
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 如果新位置和旧的不一样,则放到高位链表:hiHeah和hiTail
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 低位链表放到新table的原位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 高位链表放到新table的(原位置+oldCap)
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

判断链表元素的新位置是否变化:

(e.hash & oldCap) == 0

假设:
oldCap为16: 0000 0001 0000
hash:0101 01001101

那么e.hash & oldCap:
	0000 0001 0000
	0101 0100 1101
------------------
=   0000 0000 0000

如果e.hash的第5位为1,则e.hash & oldCap = 10000(16)

HashMap 核心考点

Q:为什么容量必须是 2 的幂?

  • 方便将哈希值映射到桶的位置:hash & (size - 1)

  • 方便扩容,原先同一个桶中元素的位置要么不变,要么被移动到 index + oldCap 的位置上

Q:一个对象是怎么被映射到哈希桶的?

  • hashCode() -> hash()/rehash -> index

Q:HashMap 的容量、负载因子、扩容阈值、变树阈值

  • 默认初始容量:16
  • 默认负载因子 loadFactor:0.75f
  • 扩容阈值 threshold:16 * 0.75 = 12
  • 变树阈值 treeify_threshold:8

Q:Java8 对 Java7 进行了什么改进?

  • 当链表过长时,将链表转换成红黑树,提升性能
  • 扩容复制的时候,按循序复制链表的元素,避免多线程下链表成环,提高安全性
  • rehash 的时候,使用高低位异或,较少哈希碰撞,提高性能

Q:线程安全问题?

  • 无论是 Java7 还是 Java8,HashMap 都不是线程安全的
  • 但是 Java8 减少了 HashMap 在多线程下的问题

HashTable

HashTable 是线程安全的 HashMap,它就是将所有方法都加上了 synchronized

posted @ 2020-03-30 00:51  demo杰  阅读(419)  评论(0编辑  收藏  举报